mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 05:21:57 +00:00
184 lines
9.2 KiB
Python
184 lines
9.2 KiB
Python
"""查询结果里哪几列是标识列:报告里显示这几列的数不分千位(evidence.render_cell 的 plain,单元格显示规则 3 版起)。
|
||
|
||
部署上撞过:产线编码这类数值型的编码列(编码 1203)在报告的表格里写成「1,203」,读的人以为是个量。千分位只该给量用。
|
||
按序看三样信号,第一样说得出的算数;都说不出的照旧分千位:
|
||
|
||
1. 查询当时冻结的数据目录(查询快照的 schema_artifact 指向的表结构快照里的 catalog)给源列标的度量类型:identifier
|
||
(标识)、status(状态)、attribute(属性)不分千位;flow(流量)、stock(存量)、ratio(比率)是量,分千位;
|
||
2. 结果列取自的源列名像编码、编号:CODE、ID、NO 整个词或者在结尾(STATION_CODE、order_id、userNo),带「编码」「编号」
|
||
「代码」的;
|
||
3. 结果列是分组键(GROUP BY 里有它,写列、写别名、写序号都算),不是聚合。
|
||
|
||
SUM、COUNT、AVG 这类聚合出来的列是量,分千位,不看后面几样;MIN、MAX 取的是那一列本身的值,按那一列判。SQL 解析不了、
|
||
结果列在 SQL 里找不到(SELECT *、UNION)的,只按结果列名本身和目录里唯一那张表判。
|
||
|
||
只看查询快照(SQL、列名)和它指向的表结构快照(都按内容寻址、不会变):同一份快照什么时候判都是同一个结果,组装、
|
||
流式渲染、出口复核一致。离线核对页(bundle/verifier.js)没法解析 SQL,照引用上记下的 plain 渲染(见 evidence._resolve_cell)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from collections.abc import Callable, Mapping
|
||
from typing import Any
|
||
|
||
from app.data.names import name_key
|
||
|
||
#: 目录里不是量的度量类型:这些列的数是编号、码值、属性,不分千位
|
||
PLAIN_MEASURES = frozenset({"identifier", "status", "attribute"})
|
||
#: 目录里是量的度量类型
|
||
COUNTED_MEASURES = frozenset({"flow", "stock", "ratio"})
|
||
#: 列名像编码、编号:CODE / ID / NO 整个词、或者以 _CODE、_ID、_NO 结尾(不分大小写);驼峰的 stationCode、orderId、userNo;
|
||
#: 带「编码」「编号」「代码」的
|
||
_CODE_NAME = re.compile(r"(?i:(?:^|_)(?:code|id|no)$)|[a-z0-9](?:Code|Id|ID|No|NO)$|编码|编号|代码")
|
||
#: 解析 SQL 按这几种读法依次试(快照里没记是哪种库):先通用读法,读不通再换,取第一种读得通的——同一条 SQL 结果固定
|
||
_READINGS = (None, "mysql", "postgres", "oracle", "sqlite")
|
||
|
||
|
||
def code_like(name: Any) -> bool:
|
||
"""列名像编码、编号(_CODE_NAME)。"""
|
||
return isinstance(name, str) and bool(_CODE_NAME.search(name.strip()))
|
||
|
||
|
||
def plain_columns(snapshot: Mapping[str, Any], schema: Callable[[str], Any] | None = None) -> frozenset[str]:
|
||
"""查询快照里要按标识列显示(数不分千位)的结果列名(照快照 columns 的写法)。
|
||
|
||
schema 取表结构快照的内容(参数是 schema_artifact,取不到返回 None),用来读冻结的数据目录;不给就不看目录。
|
||
不抛异常:分析出了意外就当判断不了(照旧分千位)。"""
|
||
columns = [str(c) for c in snapshot.get("columns") or []]
|
||
if not columns:
|
||
return frozenset()
|
||
try:
|
||
catalog = _frozen_catalog(snapshot, schema)
|
||
select = _outer_select(snapshot.get("sql"))
|
||
return frozenset(c for c in columns if _plain(c, select, catalog))
|
||
except Exception: # noqa: BLE001 - 判断不了就照旧分千位,不能让报告渲染崩掉
|
||
return frozenset()
|
||
|
||
|
||
def _frozen_catalog(snapshot: Mapping[str, Any], schema: Callable[[str], Any] | None) -> dict[str, dict[str, str]]:
|
||
"""冻结的数据目录里各列的度量类型:{表名的 name_key: {列名的 name_key: 度量类型}}。表名带库名的,最后一段也记一份。"""
|
||
artifact = snapshot.get("schema_artifact")
|
||
content = schema(artifact) if schema is not None and isinstance(artifact, str) and artifact else None
|
||
frozen = content.get("catalog") if isinstance(content, dict) else None
|
||
out: dict[str, dict[str, str]] = {}
|
||
for table, entry in (frozen or {}).items() if isinstance(frozen, dict) else ():
|
||
notes = entry.get("notes") if isinstance(entry, dict) else None
|
||
cols = notes.get("columns") if isinstance(notes, dict) else None
|
||
measures = {name_key(str(c)): str(item["measure"]["value"]) for c, item in (cols or {}).items()
|
||
if isinstance(item, dict) and isinstance(item.get("measure"), dict)
|
||
and isinstance(item["measure"].get("value"), str)}
|
||
if measures:
|
||
for key in {name_key(str(table)), name_key(str(table).rsplit(".", 1)[-1])}:
|
||
out.setdefault(key, {}).update(measures)
|
||
return out
|
||
|
||
|
||
def _outer_select(sql: Any) -> Any:
|
||
"""最外层的 SELECT(sqlglot 的节点);解析不了、最外层不是一个 SELECT(UNION 这类)的为 None。"""
|
||
from sqlglot import exp
|
||
|
||
from app.data.sql_semantics import _parse
|
||
|
||
for read in _READINGS:
|
||
root, _ = _parse(sql, read)
|
||
if root is not None:
|
||
return root if isinstance(root, exp.Select) else None
|
||
return None
|
||
|
||
|
||
def _plain(column: str, select: Any, catalog: dict[str, dict[str, str]]) -> bool:
|
||
from sqlglot import exp
|
||
|
||
item = _projection(select, column) if select is not None else None
|
||
if item is None:
|
||
# SELECT *、UNION、解析不了:只看结果列名本身,目录按唯一那张表找
|
||
tables = _tables(select) if select is not None else {}
|
||
measure = _measure(catalog, next(iter(tables.values())) if len(tables) == 1 else None, column)
|
||
return measure in PLAIN_MEASURES if measure else code_like(column)
|
||
value = _unwrap(item.unalias())
|
||
if isinstance(value, exp.AggFunc) and not isinstance(value, (exp.Min, exp.Max, exp.AnyValue)):
|
||
return False # SUM / COUNT / AVG……:量
|
||
if isinstance(value, (exp.Min, exp.Max, exp.AnyValue)):
|
||
value = _unwrap(value.this)
|
||
if isinstance(value, exp.Column):
|
||
tables = _tables(select)
|
||
table = tables.get(name_key(value.table)) if value.table else \
|
||
(next(iter(tables.values())) if len(tables) == 1 else None)
|
||
measure = _measure(catalog, table, value.name)
|
||
if measure in PLAIN_MEASURES:
|
||
return True
|
||
if measure in COUNTED_MEASURES:
|
||
return False
|
||
if code_like(value.name):
|
||
return True
|
||
return _grouped(select, item)
|
||
|
||
|
||
def _unwrap(node: Any) -> Any:
|
||
from sqlglot import exp
|
||
|
||
while isinstance(node, (exp.Paren, exp.Cast, exp.TryCast)):
|
||
node = node.this
|
||
return node
|
||
|
||
|
||
def _projection(select: Any, column: str) -> Any:
|
||
"""结果列 column 对应的那一项选取(按别名或列名,先一字不差、再按 name_key)。找不到、或者对上不止一项的为 None。"""
|
||
items = [p for p in select.expressions if p.alias_or_name]
|
||
for same in (lambda a: a == column, lambda a: name_key(a) == name_key(column)):
|
||
hits = [p for p in items if same(p.alias_or_name)]
|
||
if hits:
|
||
return hits[0] if len(hits) == 1 else None
|
||
return None
|
||
|
||
|
||
def _tables(select: Any) -> dict[str, str]:
|
||
"""这一层 FROM / JOIN 里的实表:{别名或表名的 name_key: 表名(带库名的照写)}。子查询、CTE 不算实表。"""
|
||
from sqlglot import exp
|
||
|
||
with_ = select.args.get("with_") or select.args.get("with")
|
||
ctes = {name_key(c.alias_or_name) for c in with_.expressions} if with_ is not None else set()
|
||
sources = [(select.args.get("from_") or select.args.get("from"))]
|
||
sources += [j for j in select.args.get("joins") or []]
|
||
out: dict[str, str] = {}
|
||
for source in sources:
|
||
table = getattr(source, "this", None)
|
||
if not isinstance(table, exp.Table) or name_key(table.name) in ctes:
|
||
continue
|
||
name = ".".join(p for p in (table.db, table.name) if p)
|
||
out[name_key(table.alias_or_name)] = name
|
||
return out
|
||
|
||
|
||
def _measure(catalog: dict[str, dict[str, str]], table: str | None, column: str) -> str | None:
|
||
if not table or not catalog:
|
||
return None
|
||
for key in (name_key(table), name_key(table.rsplit(".", 1)[-1])):
|
||
if key in catalog:
|
||
return catalog[key].get(name_key(column))
|
||
return None
|
||
|
||
|
||
def _grouped(select: Any, item: Any) -> bool:
|
||
"""这一项选取是分组键:GROUP BY 里写了同样的表达式、它的别名,或者它的序号(GROUP BY 1)。"""
|
||
from sqlglot import exp
|
||
|
||
group = select.args.get("group")
|
||
if group is None:
|
||
return False
|
||
value = _unwrap(item.unalias())
|
||
position = next((k for k, p in enumerate(select.expressions, start=1) if p is item), None)
|
||
alias = name_key(item.alias) if item.alias else None
|
||
for key in group.expressions:
|
||
key = _unwrap(key)
|
||
if isinstance(key, exp.Literal) and key.is_int and int(key.this) == position:
|
||
return True
|
||
if alias and isinstance(key, exp.Column) and not key.table and name_key(key.name) == alias:
|
||
return True
|
||
if key == value:
|
||
return True
|
||
return False
|
||
|
||
|
||
__all__ = ["COUNTED_MEASURES", "PLAIN_MEASURES", "code_like", "plain_columns"]
|