审批质询遮罩收口(第 3 轮复核):标签列按作用域认取自哪个来源,UNION 另一支、子查询里的遮罩列流进标签列照收;取模、整除、整数相除按算出来的值认;CAST 的类型参数不当边界换

复核留下的一条 major(第 2 轮只修了一部分)和两条次要问题:

- 分档标签列:以前按名字认,从「所有名字都只装着标签」起往下剔。遮罩列原样选出(SELECT vip_level)时名字就是它自己,
  永远剔不掉;结果的 UNION 按位置那一遍只往里加标签,另一支取的是遮罩列也不剔;派生来源里的 UNION 根本不按位置看。
  于是标签在前、遮罩列在后(不起别名、别名不同、外层再套子查询、CTE 里的标签再 UNION),遮罩列经按原名选出的子查询、
  以标签的别名传出,或者两个子查询 JOIN 起来同名,等级 1 撞上 THEN 1 都不收,WHERE vip_level = 1 在查询清单、事件、
  质询工件里照原样封存(对基线是回退)。现在 _label_columns 按作用域认每一项取自哪个来源:结果的这一列在 UNION 的
  每一支里都要是结果全为写死常量的 CASE / IIF,或者取自子查询、CTE、LATERAL 里只装着标签的一列(派生来源里的 UNION
  也按位置看每一支,写了列名表的按列名表);取自数据源里的表的一律不算,哪怕和标签同名。没写表名的列,派生来源出
  这一列的就是它(SQL 执行过,别的来源就没有同名的列,不然报列名有歧义),按这一列 USING、NATURAL 合成的除外;递归的
  CTE、UNION 里落在某一支 * 里的列认不准,照收(多遮)
- 取模、整除:_arithmetic、_worked_out 补上 %、MOD、DIV、//(往零取整,同 SQL)。顺带 / 照数据源的算法算:SQLite、
  Postgres 里两个写成整数的数相除只留整数部分(sqlglot 记在 typed 上),以前一律按小数算,age >= 25 AND age < 53 / 2
  在 SQLite 里夹住了 25 却认不出;写成小数的(53.0 / 2)、MySQL、DuckDB 照小数算
- CAST 的类型参数(第 2 轮引入,只多遮):_halves 只收往上走得到这一边的字面量(_lifts),CAST(30000 AS DECIMAL(10, 2))
  的 10、2 不再当成算出边界的数换掉,SQL 检查结果的片段里别处的 10、2 也不跟着换;_wraps 里 CAST 只算被转换的那个值,
  Oracle 的 CAST … DEFAULT 0 ON CONVERSION ERROR 里的 0 照原样
- 模块说明的已知局限照实更新:标签列认得出、认不准的几种写法;认得出的算式补上取模、整除和整数相除,认不出的补上
  位运算、POWER、CAST 改了值的

测试:单元补标签在前、遮罩列在后的八种写法(不起别名、别名不同、子查询里的 UNION、写了列名表的 CTE、CTE 里的标签再
UNION、经按原名选出的子查询传出、两个子查询 JOIN 同名、RIGHT JOIN USING 同名列),按作用域仍认得出的五种标签(外层连上
数据源里的表不写表名、按别的列 USING、两层子查询写了表名、UNION 两支、派生来源的 UNION 经 * 带出);取模、整除、整数
相除的六种写法和照小数算的三种;CAST 的类型参数和 DEFAULT。端到端补 UNION 标签在前的两种写法、取模和整数相除算出来的
边界。每处实现都逐个去掉验证过测试变红(十一个变异全红)
This commit is contained in:
ThearchyHelios
2026-10-06 16:01:28 +08:00
parent 5d4d1fdc15
commit 76b485a0ca
3 changed files with 423 additions and 85 deletions
+248 -74
View File
@@ -43,9 +43,11 @@
汇总里套着结果全是写死常量的 CASE(SUM(CASE WHEN amount <= 20000 THEN 1 ELSE 0 END))只是按条件数个数,同 COUNT
- 按遮罩列分档的标签(CASE、IIF 的结果全是写死的常量):标签文字是 agent 写在 SQL 里的,不当遮罩值,SQL、回答正文里
照原样;标签列当分组键(每一档一行)照常显示,不分组的逐行标签列整列遮(格子里看不出某一笔落在哪一档),可 agent
在正文里写出「这一笔是大单」遮不住,按 id 分组再按档数个数也看得出(同按阈值查出某几行)。只有只装着标签的列不收
(选出项本身就是这样的 CASE / IIF,或者经别名、子查询原样传下去的),同一个名字在哪一处装着别的就照收;标签恰好和
别的列里的遮罩值相同时,正文里的档名、SQL 里写成字符串的标签也换(多遮)
在正文里写出「这一笔是大单」遮不住,按 id 分组再按档数个数也看得出(同按阈值查出某几行)。只有只装着标签的列不收:
结果的这一列在 UNION 的每一支里都是这样的 CASE / IIF,或者按作用域认得出取自子查询、CTE 里只装着标签的一列;有一支
取自数据源里的表(遮罩列原样选出、经子查询按原名传出,哪怕起了标签的名字)就照收。认不准的也照收(多遮),比如
按这一列 USING、NATURAL 合成的同名列,递归的 CTE,UNION 里落在某一支 * 里的列,写死的字符串、和列一起算的值。
标签恰好和别的列里的遮罩值相同时,正文里的档名、SQL 里写成字符串的标签也换(多遮)
- 「夹住某一个值」(_pinned):范围比较在整条 SQL 里配对,只有 CASE 的不同分支、SELECT 里不筛行的不同项(选出列、
GROUP BY、ORDER BY)、UNION 的不同支各算各的,两头误差都有:把找值拆进这几处(CASE WHEN age > 25 THEN … ELSE CASE
WHEN age >= 25 …,两个选出列 SUM(age >= 25)、SUM(age > 25) 再自己相减)认不出,边界照原样留着,正文里复述也不遮;
@@ -58,9 +60,11 @@
隔开的一律按夹住算,分档很多时会成批多遮
- 找某一个值的写法按语法树认常见的几种:等值、IN、简单 CASE、把它夹住的范围,被比较的一边套了 CAST、函数、汇总
(CAST(age AS INTEGER) = 25、HAVING MAX(age) BETWEEN 25 AND 25),值换个写法(25.0、025、2.5e1)、写成几个数的
算式(13900000000 + 1、BETWEEN 20 + 5 AND 30 - 5,算式里的数都换)。更绕的写法认不出:值藏进和列一起算的算式
(age - 25 = 0、buyer_phone - 1 = 13900000000)、拼接('1390' || '0000001')、函数拼出来的值(REPLACE、SUBSTR),
短的值照原样封存,长号码只在整个写出来时按值换
算式(加减乘除、取模、整除:13900000000 + 1、BETWEEN 20 + 5 AND 30 - 5、125 % 100、50 DIV 2,算式里的数都换;
两个整数相除照数据源的算法,SQLite、Postgres 里 53 / 2 是 26,MySQL 里是 26.5)。更绕的写法认不出:值藏进和列
一起算的算式(age - 25 = 0、buyer_phone - 1 = 13900000000)、位运算(24 | 1)、拼接('1390' || '0000001')、
函数拼出来的值(REPLACE、SUBSTR、POWER)、CAST 改了值的(CAST(25.6 AS INTEGER) 按 25.6 认,CAST(53 AS REAL) / 2
按整数相除算),短的值照原样封存,长号码只在整个写出来时按值换
- 质询工件里 SQL 检查结果的片段是检查器按语法树重新写出来的,按整条 SQL 里整个换掉了的字面量照值换:同一个值在
片段里别处出现也换(多遮,算式里换掉的 0、1 这类也是);截断处半截的值去掉(停在字符串、带引号的名字、注释里的,
从开头的引号、注释起整个去掉)。说明里码值表列出的码值和别的字一样按值换,恰好是遮罩值的码值也换(多遮)
@@ -751,12 +755,12 @@ def _label_columns(sql: Any, kind: str | None, columns: list[str]) -> dict[str,
"""结果里只装着分档标签的列:列名(小写)→ 标签在格子里的写法。标签(CASE、IIF 写死的结果,分档的名字、档位编号)
是 agent 自己写在 SQL 里的字,不是数据源里的值:这几列派生自遮罩列也不收进遮罩值(_Secrets.add),不然 SQL 里的
标签、回答正文里的档名都会被换成「已遮罩」。
只装着标签:这一列的选出项本身是结果全为写死常量的 CASE / IIF,或者经别名、子查询、CTE 原样传下去的这样一列
(_label_texts);同一个名字在哪一处装着别的(vip_level AS 等级、UNION 另一支取的是遮罩列),就不算——改了名字的
遮罩列的 1、2、3 撞上同一条 SQL 里按条件数个数的 THEN 1 ELSE 0,照样是遮罩值。没起别名的标签列按位置认(每一支的
那一项都是标签)。解析不了的一个都不算(标签照旧当遮罩值,宁可多遮)。"""
from sqlglot import exp
只装着标签:结果的这一列在 UNION 这类的每一支里,都是结果全为写死常量的 CASE / IIF,或者取自派生来源(子查询、
CTE、LATERAL)里只装着标签的一列(_item_label)。一项取自哪个来源按作用域认,不按名字猜:按名字认时,遮罩列原样
选出(SELECT vip_level)的名字就是它自己,永远算只装着标签,UNION 另一支、子查询里的遮罩列流进标签列也认不出,
等级 1 撞上 THEN 1 就不收。取自数据源里的表的(哪怕和标签同名)、有一支装着别的、认不准的(说不清取自哪个来源、
递归的 CTE、几支的 * 展开在哪说不清)都不算,照收(宁可多遮);同名的几列有一列不算就都不算。解析不了的一个都
不算。"""
from app.data.sql_semantics import _parse, _read
try:
@@ -766,47 +770,53 @@ def _label_columns(sql: Any, kind: str | None, columns: list[str]) -> dict[str,
if root is None:
return {}
try:
items = [item for select in root.find_all(exp.Select) for item in select.expressions]
# 先当每个名字都只装着标签,再去掉装着别的,直到不再变:名字一层层往下传,先认哪一层说不准
known: dict[str, set[str]] = {name: set() for item in items if (name := _output_name(item))}
for _ in range(len(items) + 2):
found: dict[str, set[str]] = {}
spoiled: set[str] = set()
for item in items:
if not (name := _output_name(item)):
continue
texts = _label_texts(item, known)
if texts is None:
spoiled.add(name)
else:
found.setdefault(name, set()).update(texts)
found = {name: texts for name, texts in found.items() if name not in spoiled}
if found == known:
break
known = found
out = {c.lower(): known[c.lower()] for c in columns if c.lower() in known}
width = len(columns)
for top, role, _listed in _blocks(root):
if role != "result":
continue
branches = _branches(top)
seen: dict[int, list[set[str] | None]] = {}
for branch in branches:
for item, place in zip(branch, _places(branch, width)):
if place is not None and place < width:
seen.setdefault(place, []).append(_label_texts(item, known))
for place, texts in seen.items():
if len(texts) == len(branches) and all(t is not None for t in texts):
out.setdefault(columns[place].lower(), set()).update(*texts)
return out
result = next((top for top, role, _listed in _blocks(root) if role == "result"), None)
if result is None:
return {}
memo: _LabelMemo = {}
found: dict[str, list[set[str] | None]] = {}
for place, column in enumerate(columns):
name = column.lower()
found.setdefault(name, []).append(_result_label(result, place, name, len(columns), memo))
return {name: texts for name, parts in found.items() if (texts := _all_labels(parts)) is not None}
except Exception: # noqa: BLE001 - 分析本身的缺陷不能让回答出错:一个都不算(宁可多遮)
logger.warning("质询的 SQL 标签列分析失败", exc_info=True)
return {}
def _label_texts(item: Any, known: dict[str, set[str]]) -> set[str] | None:
"""一个选出项只装着标签时,标签在格子里的写法:结果全为写死常量的 CASE / IIF 取它的各个结果(数按两种写法:25、
25.0),单独一列、名字在 known 里(经别名、子查询传下来的标签列)取那一列的;不是为 None。"""
#: 标签列分析里「这个来源不出这一列」(_block_label、_source_label):和 None(出这一列,但装着别的、认不准)分开
_ABSENT: Any = object()
#: 标签列分析记下的结果:(id(那块查询), 列名) → 标签的写法、None 或 _ABSENT
_LabelMemo = dict[tuple[int, str], Any]
def _result_label(top: Any, place: int, name: str, width: int, memo: _LabelMemo) -> set[str] | None:
"""结果(width 列)的第 place 列(列名 name)只装着标签时标签的写法,不然为 None:UNION 这类的每一支,那一项都要
只装着标签。落在 * 里的(认不出是哪一项)按列名经 * 往下找,只有一支时才找(几支的 * 各展开成什么说不清)。"""
from sqlglot import exp
leaves = _leaves(top)
if not all(isinstance(leaf, exp.Select) for leaf in leaves):
return None
out: set[str] = set()
for select in leaves:
items = list(select.expressions)
item = next((i for i, p in zip(items, _places(items, width)) if p == place), None)
if item is not None:
texts = _item_label(item, select, memo)
elif len(leaves) == 1:
texts = _star_label(select, name, memo)
else:
return None
if texts is None or texts is _ABSENT:
return None
out |= texts
return out
def _item_label(item: Any, select: Any, memo: _LabelMemo) -> set[str] | None:
"""select 的一个选出项只装着标签时,标签在格子里的写法:结果全为写死常量的 CASE / IIF 取它的各个结果(数按两种
写法:25、25.0);单独一列取它在 FROM 里取自的那一列的(_source_label);别的为 None。"""
from sqlglot import exp
node = item
@@ -824,22 +834,165 @@ def _label_texts(item: Any, known: dict[str, set[str]]) -> set[str] | None:
elif value is not None:
out.add(_plain_text(value))
return out
if isinstance(node, exp.Column) and not isinstance(node.this, exp.Star) and node.name.lower() in known:
return known[node.name.lower()]
if isinstance(node, exp.Column) and not isinstance(node.this, exp.Star) and node.name:
texts = _source_label(select, node.table.lower() or None, node.name.lower(), memo)
return None if texts is _ABSENT else texts
return None
def _star_label(select: Any, name: str, memo: _LabelMemo) -> Any:
"""select 经 *(t.* 只看 t)带出的 name 这一列只装着标签时标签的写法;没有 *、* 带不出这一列为 _ABSENT,带出来的
装着别的、认不准为 None。"""
from sqlglot import exp
out: set[str] = set()
seen = False
for item in select.expressions:
if not _is_star(item):
continue
qualifier = (item.table.lower() or None) if isinstance(item, exp.Column) else None
texts = _source_label(select, qualifier, name, memo)
if texts is None:
return None
if texts is not _ABSENT:
out |= texts
seen = True
return out if seen else _ABSENT
def _source_label(select: Any, qualifier: str | None, name: str, memo: _LabelMemo) -> Any:
"""select 的 FROM、JOIN 里读到的那一列(qualifier 是写的表名或别名,没写为 None)只装着标签时标签的写法;没有哪个
来源出这一列为 _ABSENT,出这一列但装着别的、认不准为 None。
- 写了表名的只看那个来源(对不上的是外层的表,认不准);
- 没写的看所有来源:派生来源出这一列的就是它——SQL 执行过,别的来源就没有同名的列,不然会报列名有歧义。按这一列
连起来的(USING 里写着它、NATURAL)例外:几个来源的这一列合成一列,外连接时取的可能是另一边的值;
- 数据源里的表一律不算(遮罩列本身、别的列原样选出来,哪怕和标签同名),VALUES、函数这类来源也认不准。"""
sources = _from_sources(select)
if qualifier is not None:
sources = [(alias, source) for alias, source in sources if alias == qualifier]
if len(sources) != 1:
return None
found: list[set[str]] = []
opaque = False
for _alias, source in sources:
derived = _derived_block(source)
if derived is None:
opaque = True
continue
texts = _block_label(derived[0], derived[1], name, memo)
if texts is None:
return None
if texts is not _ABSENT:
found.append(texts)
if not found:
return None if opaque else _ABSENT
if opaque and _merging(select, name):
return None
return set().union(*found)
def _block_label(block: Any, listed: list[str] | None, name: str, memo: _LabelMemo) -> Any:
"""派生来源的那块查询(listed 是写的列名表)出的 name 这一列只装着标签时标签的写法;不出这一列为 _ABSENT,出这一列
但装着别的、认不准为 None。一条 SELECT 按选出项的名字找,没有的经 * 往下找;UNION 这类按位置看每一支,列名取写的
列名表,没写的取第一支的。"""
key = (id(block), name)
if key not in memo:
# 先记成 None:递归的 CTE 绕回自己时按认不准算
memo[key] = None
memo[key] = _block_label_of(block, listed, name, memo)
return memo[key]
def _block_label_of(block: Any, listed: list[str] | None, name: str, memo: _LabelMemo) -> Any:
"""_block_label 真去找的那一步(不看记下的结果)。"""
from sqlglot import exp
leaves = _leaves(block)
if not all(isinstance(leaf, exp.Select) for leaf in leaves):
return None
if listed is None and len(leaves) == 1:
named = [item for item in leaves[0].expressions if _output_name(item) == name]
if not named:
return _star_label(leaves[0], name, memo)
return _all_labels([_item_label(item, leaves[0], memo) for item in named])
width: int | None = None
if listed is not None:
if name not in listed:
return _ABSENT
place, width = (listed.index(name) if listed.count(name) == 1 else None), len(listed)
else:
first = list(leaves[0].expressions)
places = [p for item, p in zip(first, _places(first, None)) if _output_name(item) == name]
if not places:
# 第一支没明写这一列:有 * 时说不清它在不在、在哪个位置
return None if any(_is_star(item) for item in first) else _ABSENT
place = places[0] if len(places) == 1 else None
if place is None:
return None
texts: list[set[str] | None] = []
for select in leaves:
items = list(select.expressions)
item = next((i for i, p in zip(items, _places(items, width)) if p == place), None)
texts.append(_item_label(item, select, memo) if item is not None else None)
return _all_labels(texts)
def _all_labels(texts: list[set[str] | None]) -> set[str] | None:
"""几项都只装着标签时,标签的写法合起来;有一项不是为 None。"""
return set().union(*texts) if all(t is not None for t in texts) else None
def _from_sources(select: Any) -> list[tuple[str, Any]]:
"""一条 SELECT 的 FROM、JOIN 里的来源:(名字, 来源节点),名字是别名,没起别名的是表名(小写)。"""
from_ = select.args.get("from_")
nodes = [from_.this] if from_ is not None and from_.this is not None else []
nodes += [join.this for join in select.args.get("joins") or [] if join.this is not None]
return [((node.alias_or_name or "").lower(), node) for node in nodes]
def _derived_block(source: Any) -> tuple[Any, list[str] | None] | None:
"""FROM、JOIN 里的一个来源 → (它的那块查询, 写的列名表):FROM 里的子查询、LATERAL、指向 WITH 里一段的表名。数据源里
的表、VALUES、函数这类为 None(出哪些列、装着什么,SQL 里看不出)。"""
from sqlglot import exp
from app.data.sql_semantics import _visible_ctes
node, alias = source, None
if isinstance(node, exp.Lateral):
node, alias = node.this, node.args.get("alias")
if isinstance(node, exp.Table):
if node.args.get("db") or node.args.get("catalog") or not isinstance(node.this, exp.Identifier):
return None
cte = _visible_ctes(node).get(node.name.lower())
if cte is None:
return None
node, alias = cte.this, cte.args.get("alias")
while isinstance(node, exp.Subquery):
alias = alias or node.args.get("alias")
node = node.this
if not isinstance(node, (exp.Select, exp.SetOperation)):
return None
return node, ([c.name.lower() for c in alias.columns] if alias is not None and alias.columns else None)
def _merging(select: Any, name: str) -> bool:
"""FROM 里按同名的 name 这一列连起来(USING 里写着它,或者 NATURAL):几个来源的这一列合成一列。"""
return any(str(join.args.get("method") or "").upper() == "NATURAL"
or any(str(getattr(u, "name", "")).lower() == name for u in join.args.get("using") or [])
for join in select.args.get("joins") or [])
def _is_star(item: Any) -> bool:
from sqlglot import exp
return isinstance(item, exp.Star) or (isinstance(item, exp.Column) and isinstance(item.this, exp.Star))
def _branches(root: Any) -> list[list[Any]]:
"""一块查询选出的列:一条 SELECT 一支,UNION 这类的每一支各一支(结果的第 i 列来自每一支的第 i 项)。"""
def _leaves(root: Any) -> list[Any]:
"""一块查询的每一支:UNION 这类连起来的按先后摊开,套在外面的括号去掉。一般是 SELECT,也可能是 VALUES 这类。"""
from sqlglot import exp
out: list[list[Any]] = []
out: list[Any] = []
stack = [root]
while stack:
node = stack.pop()
@@ -847,11 +1000,18 @@ def _branches(root: Any) -> list[list[Any]]:
stack.append(node.this)
elif isinstance(node, exp.SetOperation):
stack.extend((node.expression, node.this))
elif isinstance(node, exp.Select):
out.append(list(node.expressions))
else:
out.append(node)
return out
def _branches(root: Any) -> list[list[Any]]:
"""一块查询选出的列:一条 SELECT 一支,UNION 这类的每一支各一支(结果的第 i 列来自每一支的第 i 项)。"""
from sqlglot import exp
return [list(node.expressions) for node in _leaves(root) if isinstance(node, exp.Select)]
def _output_name(item: Any) -> str | None:
"""一个选出项在结果里的列名(小写):起了别名的是别名,直接选一列的是列名。别的表达式(函数、运算、CAST、括号、
子查询)的列名各数据库起法不同(SQLite 照写法原样记「SUBSTR(buyer_phone, 4)」,Postgres 记函数名或 ?column?),
@@ -1472,10 +1632,11 @@ def _use_of(node: Any, values: dict[int, Any], lifts: dict[int, Any]) -> tuple[s
def _wraps(parent: Any, child: Any) -> bool:
"""parent 只是 child 的外壳,不改变它是哪个值:括号、负号、CAST、字符集前缀(_utf8mb4'x')、COLLATE。"""
"""parent 只是 child 的外壳,不改变它是哪个值:括号、负号、CAST(只算被转换的那个值,类型参数、DEFAULT 后面的值
不算)、字符集前缀(_utf8mb4'x')、COLLATE。"""
from sqlglot import exp
return isinstance(parent, (exp.Paren, exp.Neg, exp.Cast)) or (
return (isinstance(parent, (exp.Paren, exp.Neg, exp.Cast)) and child is parent.this) or (
isinstance(parent, exp.Introducer) and child is parent.expression) or (
isinstance(parent, exp.Collate) and child is parent.this)
@@ -1497,10 +1658,11 @@ def _exact_ops() -> dict[Any, str]:
def _arithmetic() -> tuple[Any, ...]:
"""加减乘除:几个写死的数算出来的整个算式按算出来的那一个值认(13900000000 + 1 就是 13900000001)。"""
"""加减乘除、取模(%、MOD)、整除(MySQL 的 DIV、DuckDB 的 //):几个写死的数算出来的整个算式按算出来的那一个值认
(13900000000 + 1 就是 13900000001,125 % 100 就是 25)。"""
from sqlglot import exp
return exp.Add, exp.Sub, exp.Mul, exp.Div
return exp.Add, exp.Sub, exp.Mul, exp.Div, exp.Mod, exp.IntDiv
#: 数的写法里指数最大多少(1e999999 这样的写法不按数认,免得展开成上百万位)
@@ -1557,19 +1719,27 @@ def _constants(root: Any) -> dict[int, Any]:
def _worked_out(node: Any, a: Any, b: Any) -> Decimal | None:
"""一步加减乘除的结果;不是两个数、除以零、结果太大为 None。"""
"""一步加减乘除、取模、整除的结果;不是两个数、除以零、结果太大为 None。取模、整除照 SQL 往零取整(-7 % 2 是 -1,
-7 DIV 2 是 -3,同 Decimal 的 % 和 //)。/ 在 SQLite、Postgres 这类方言里两个整数相除只留整数部分(解析时记在
typed 上:53 / 2 是 26),写成小数的(53.0 / 2)、别的方言照小数算。以前一律按小数算,SQLite 里 age >= 25 AND
age < 53 / 2 夹住了 25 却认不出。"""
from sqlglot import exp
if not (isinstance(a, Decimal) and isinstance(b, Decimal)):
return None
try:
if isinstance(node, exp.Div):
value = a / b if b else None
if isinstance(node, (exp.Div, exp.IntDiv, exp.Mod)):
if not b:
return None
# 写成整数的数(和它们加减乘出来的)指数正好是 0;53.0、1e1 这样写的在 SQL 里不是整数
whole = isinstance(node, exp.IntDiv) or (
node.args.get("typed") and a.as_tuple().exponent == 0 and b.as_tuple().exponent == 0)
value = a % b if isinstance(node, exp.Mod) else a // b if whole else a / b
else:
value = a + b if isinstance(node, exp.Add) else a - b if isinstance(node, exp.Sub) else a * b
except ArithmeticError:
return None
return value if value is not None and value.is_finite() and abs(value.adjusted()) <= _EXPONENT_MAX else None
return value if value.is_finite() and abs(value.adjusted()) <= _EXPONENT_MAX else None
def _lifts(root: Any, values: dict[int, Any]) -> dict[int, Any]:
@@ -1588,16 +1758,19 @@ def _lifts(root: Any, values: dict[int, Any]) -> dict[int, Any]:
#: 一个比较的一半:(算出边界的那几个字面量, 另一边的键, 写死的值, 「键 运算符 值」的运算符)。边界是几个数算出来的
#: (13900000002 - 1),算式里的字面量都记下:夹住了某一个值,留着哪一个都可能算得出它,都换
#: (13900000002 - 1),算式里的字面量都记下:夹住了某一个值,留着哪一个都可能算得出它,都换。不参与算值的字面量
#: (CAST(30000 AS DECIMAL(10, 2)) 的类型参数 10、2)不记,见 _halves
_Half = tuple[tuple[Any, ...], str, Any, str]
#: 一个比较拆成的几半,加上几半怎么连(False:且,BETWEEN;True:或,IN)、是不是等值一类(不等、IN)
_Leaf = tuple[list[_Half | None], bool, bool]
def _halves(node: Any, values: dict[int, Any]) -> list[_Half | None]:
"""一个比较拆成的几半(values 是 _constants 算好的写死的值):BETWEEN 是两半的「且」(不低于下界、不高于上界),
> >= < <= 是一半,= <> 这类是一半,IN 是一串「=」的「或」。边界不是写死的值(另一列、子查询)或者两边都写死了的,
这一半记 None:它的真假跟哪一列的取值都对不上,当成不知道真假。"""
def _halves(node: Any, values: dict[int, Any], lifts: dict[int, Any]) -> list[_Half | None]:
"""一个比较拆成的几半(values 是 _constants 算好的写死的值,lifts 见 _lifts):BETWEEN 是两半的「且」(不低于下界、
不高于上界),> >= < <= 是一半,= <> 这类是一半,IN 是一串「=」的「或」。边界不是写死的值(另一列、子查询)或者
两边都写死了的,这一半记 None:它的真假跟哪一列的取值都对不上,当成不知道真假。算出边界的字面量是往上走得到这一边
的那几个(_lifts):CAST(30000 AS DECIMAL(10, 2)) 里只有 30000,类型参数 10、2 不算出值,换了只会让封存的 SQL 难读,
SQL 检查结果的片段里别处的 10、2 也会跟着换。"""
from sqlglot import exp
if isinstance(node, exp.Between):
@@ -1615,8 +1788,9 @@ def _halves(node: Any, values: dict[int, Any]) -> list[_Half | None]:
out: list[_Half | None] = []
for operand, other, op in sides:
value = values.get(id(operand)) if operand is not None else None
out.append((tuple(operand.find_all(*_literal_types())), _key_of(other), value, op)
if value is not None else None)
literals = () if value is None else tuple(
literal for literal in operand.find_all(*_literal_types()) if lifts.get(id(literal)) is operand)
out.append((literals, _key_of(other), value, op) if value is not None else None)
return out
@@ -1725,9 +1899,9 @@ def _marks(value: Any, op: str) -> list[tuple[Any, int]]:
return [(value, 0 if op in (">=", "<") else 1), *steps]
def _pinned(root: Any, values: dict[int, Any]) -> dict[int, tuple[str, ...]]:
def _pinned(root: Any, values: dict[int, Any], lifts: dict[int, Any]) -> dict[int, tuple[str, ...]]:
"""把某一个值夹住了的边界字面量:id(字面量节点) → 夹住的值(_point_text 的写法)。values 是 _constants 算好的
写死的值。
写死的值,lifts 见 _lifts。
一个值 v 两边都被隔开(_marks),条件才可能只认 v 这一个值。范围比较在整条 SQL 里配对,只有分档的几种写法各算
各的(_apart:CASE 的不同分支、SELECT 里不筛行的不同项、UNION 的不同支),它们共用的边界是分档的界,不是在找哪
@@ -1754,7 +1928,7 @@ def _pinned(root: Any, values: dict[int, Any]) -> dict[int, tuple[str, ...]]:
nearby: dict[tuple[int, str], set[Decimal]] = {}
for node in root.find_all(exp.Between, *_range_ops(), *exact_types):
is_exact = isinstance(node, exact_types)
parts = _halves(node, values)
parts = _halves(node, values, lifts)
if is_exact and all(h is None for h in parts):
continue # 连接条件(两边都是列)这类:和夹住哪个值无关
top = _condition_root(node)
@@ -2006,7 +2180,7 @@ def _literal_uses(sql: str, kind: str | None) -> dict[int, _Use] | None:
try:
values = _constants(root)
lifts = _lifts(root, values)
pinned = _pinned(root, values)
pinned = _pinned(root, values, lifts)
for node in root.walk():
start = (node.meta or {}).get("start") if isinstance(node, _literal_types()) else None
if not isinstance(start, int):
+51 -7
View File
@@ -14,12 +14,12 @@
别名的、和 * 写在一起没起别名的表达式也算)的格子渲染成「已遮罩」,回答正文、查询清单、事件、质询工件里都不出现
遮罩列的值;换着写法找某一个客户(简单 CASE、IN (VALUES …)、两头相同的范围、行值、选出列里写着同一个号码,两半
拆到 ON 和 WHERE、WHERE 和 HAVING、INTERSECT 两支、CTE 里,边界、等值写成算式,两头不是整数,范围再排除一头,
号码前面补零……)也一样,年龄这类短的值换着写法找(套了 CAST、汇总值两头相同、补零……)SQL 里也遮;遮罩值不误伤
阈值、天数、LIMIT(长的数也一样)、提问人自己写的数、报告口径卡里的指标,分档共用的边界、和日期、天数修饰、标签里
写着的同一个数也不牵连阈值,分档的文字标签不当遮罩值(当分组键的档位列照常显示),改了名字的遮罩列撞上标签照样遮;
SQL 里照原样留着的阈值只在回答正文里不遮,格子照旧按值遮,回答和质询工件对得上;质询工件按值遮只出现在报告快照里
的遮罩值;一格文字里夹着遮罩值时回答整份不交,质询工件里只换掉那一截;质询工件里 SQL 检查结果的片段、说明照整条
SQL 换(短的数也换),给模型的那句改法不交出去
号码前面补零……)也一样,年龄这类短的值换着写法找(套了 CAST、汇总值两头相同、补零、取模和整数相除算出来的边界……)
SQL 里也遮;遮罩值不误伤阈值、天数、LIMIT(长的数也一样)、提问人自己写的数、报告口径卡里的指标,分档共用的边界、
和日期、天数修饰、标签里写着的同一个数也不牵连阈值,分档的文字标签不当遮罩值(当分组键的档位列照常显示),改了
名字的遮罩列、UNION 另一支取的遮罩列撞上标签照样遮;SQL 里照原样留着的阈值只在回答正文里不遮,格子照旧按值遮,
回答和质询工件对得上;质询工件按值遮只出现在报告快照里的遮罩值;一格文字里夹着遮罩值时回答整份不交,质询工件里
只换掉那一截;质询工件里 SQL 检查结果的片段、说明照整条 SQL 换(短的数也换),给模型的那句改法不交出去
- 接线:设置里的上限、运行固定的数据版本交到 agent 手里(上传表格重传之后查的仍是运行固定的那一版);同名重建的
数据源、没固定过版本的上传表格不查
- 触顶、失败照样记一条事件,说法给提问人看得懂、说下一步;轮数用完不算查询次数到顶,查询失败、引用解析不了在末尾注明
@@ -1051,11 +1051,19 @@ async def test_a_threshold_matching_numbers_left_alone_elsewhere_is_sealed_as_wr
pytest.param("SELECT COUNT(*) AS 人数 FROM sales WHERE age BETWEEN 20 + 5 AND 30 - 5",
"SELECT COUNT(*) AS 人数 FROM sales WHERE age BETWEEN 已遮罩 + 已遮罩 AND 已遮罩 - 已遮罩",
id="算出来的边界"),
# 第 3 轮复核:取模、整数相除(SQLite 里只留整数部分)算出来的边界
pytest.param("SELECT COUNT(*) AS 人数 FROM sales WHERE age >= 25 AND age <= 125 % 100",
"SELECT COUNT(*) AS 人数 FROM sales WHERE age >= 已遮罩 AND age <= 已遮罩 % 已遮罩",
id="取模算出来的边界"),
pytest.param("SELECT COUNT(*) AS 人数 FROM sales WHERE age >= 25 AND age < 53 / 2",
"SELECT COUNT(*) AS 人数 FROM sales WHERE age >= 已遮罩 AND age < 已遮罩 / 已遮罩",
id="整数相除算出来的边界"),
])
async def test_a_short_masked_value_looked_up_in_other_shapes_is_sealed_masked(monkeypatch, tmp_path, client, sql,
sealed):
"""年龄设了遮罩,agent 换着写法找某一个年龄(套了 CAST、汇总值两头相同、补零、两头不是整数、范围再排除一头、
算出来的边界):第 2 轮复核时都照原样封存。现在查询清单、事件、质询工件里的 SQL 都换掉这个年龄,三处一字不差。"""
算出来的边界,取模、整数相除算出来的边界):第 2、3 轮复核时都照原样封存。现在查询清单、事件、质询工件里的 SQL
都换掉这个年龄,三处一字不差。"""
name, source_id = await _source(tmp_path, options={"mask_columns": "age"}, script=AGED)
graph = chain(node("start", "input"),
node("fetch", "tool", tool=f"db_query__{name}",
@@ -1124,6 +1132,42 @@ async def test_a_renamed_masked_column_keeps_values_that_match_labels_written_el
await engines.invalidate(source_id)
@pytest.mark.parametrize("mixed", [
pytest.param("SELECT CASE WHEN amount > 1000 THEN 1 ELSE 0 END AS 等级 FROM sales UNION ALL "
"SELECT vip_level FROM sales", id="标签在前、遮罩列在后"),
pytest.param("SELECT CASE WHEN amount > 1000 THEN 1 ELSE 0 END AS 等级 FROM sales UNION ALL "
"SELECT b.vip_level AS 等级 FROM (SELECT vip_level FROM sales) b", id="遮罩列经子查询按原名传出"),
])
async def test_a_masked_column_after_a_label_in_a_union_keeps_its_values(monkeypatch, tmp_path, client, mixed):
"""第 3 轮复核:UNION 的第一支是按条件数大单的标签(THEN 1 ELSE 0),另一支取的是等级(遮罩列原样选出,或者经按
原名选出的子查询、以标签的别名传出)。以前这一列被当成只装着标签,等级 1 撞上标签 1 不收,之后 WHERE vip_level = 1
在查询清单、事件、质询工件里都照原样,= 2 却遮了。现在这一列在一支里取的是遮罩列,就照收,两条都遮。"""
name, source_id = await _source(tmp_path, options={"mask_columns": "vip_level"}, script=LEVELS)
first, second = (f"SELECT COUNT(*) AS 人数 FROM sales WHERE vip_level = {level}" for level in (1, 2))
plays = {"各等级": Play([mixed, first, second],
"问到的两个等级各有 [[v:Q2.r0.人数]] 人、[[v:Q3.r0.人数]] 人。[[see:Q2]]")}
Models(monkeypatch, name, writer="华东首单金额 [[v:Q1.r0.金额]]。[[see:Q1]]", plays=plays)
try:
paused = await pause(name, graph=fetching(name, AMOUNTS))
[approval] = await pending(paused.id)
r = await ask(client, paused.id, report="write", unit=None, question="各等级的客户有几位?",
approval_id=approval.id)
assert r.status_code == 200, r.text
item = r.json()["inquiry"]
assert (item["answer"]["ok"], item["answer"]["text"]) == (True, "问到的两个等级各有 2 人、1 人。")
sealed = [mixed, *["SELECT COUNT(*) AS 人数 FROM sales WHERE vip_level = 已遮罩"] * 2]
assert [q["sql"] for q in item["queries"]] == sealed
[event] = await events(paused.id, "approval.inquiry")
assert [q["sql"] for q in event.data["queries"]] == sealed
for query, sql in zip(item["queries"], sealed):
got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{query['artifact']}")
assert got.status_code == 200 and got.json()["content"]["sql"] == sql, "质询工件的 SQL 和事件里的一字不差"
got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{item['queries'][0]['artifact']}")
assert {cell for [cell] in got.json()["content"]["rows"]} == {"已遮罩"} and got.json()["masked"] == ["等级"]
finally:
await engines.invalidate(source_id)
STAR_TAIL = "SELECT *, SUBSTR(buyer_phone, 4) FROM sales ORDER BY amount DESC LIMIT 1"
+124 -4
View File
@@ -9,8 +9,9 @@
- SQL 里数字照原样留着的只有范围比较的边界(没把某一个值夹住的)和 LIMIT / OFFSET 的行数;在找某一个值的(等值、IN、
LIKE、简单 CASE、IS [NOT] DISTINCT FROM,套了括号、写成行值、VALUES、ARRAY 的也算,还有 BETWEEN v AND v、
>= v AND <= v 这类夹住某一个值的范围)和遮罩列比较时短的数也换,被比较的一边套了 CAST、汇总也算;同一个数换个
写法(25.0、025、2.5e1)、写成几个数的算式(25 + 0、13900000000 + 1)照样认,算式里的数都换;选出列、函数参数、
算式里够长的遮罩值也换;字符串(范围边界也一样)、注释、名字按值换;SQL 解析不了时认不准哪个是阈值,一律不免
写法(25.0、025、2.5e1)、写成几个数的算式(25 + 0、13900000000 + 1、125 % 100、50 DIV 2,整数相除照数据源的算法)
照样认,算式里的数都换(CAST 的类型参数不算);选出列、函数参数、算式里够长的遮罩值也换;字符串(范围边界也一样)、
注释、名字按值换;SQL 解析不了时认不准哪个是阈值,一律不免
- 「夹住某一个值」在整条 SQL 里认,只有分档的几种写法各算各的:CASE 的不同分支、不同的选出列(连同 GROUP BY、
ORDER BY)、UNION 的不同支,共用的边界是阈值;同一行集上的条件(ON 和 WHERE、WHERE 和 HAVING、INTERSECT 两支、
包在 CASE / 函数 / 算式里的一半、子查询、CTE)照样认得出在找哪一个值。同一个条件里按合起来的真假认(旁边的别的
@@ -22,8 +23,9 @@
同一个短的数,没设遮罩的列上的等值,都不牵连阈值;不留的照等值的办法换,别的列上的短阈值、LIMIT 撞上别处换掉的
短的数照原样(已知局限)
- 按遮罩列分档的标签(CASE、IIF 的结果都是写死的常量)是 agent 自己写在 SQL 里的,不当遮罩值:SQL、回答正文里照原样;
当分组键的(每一档一行)不算派生值,格子照常显示,逐行的标签列照旧整列遮。只有只装着标签的列不收(经别名、子查询
传下去、没起别名的也算),改了名字的遮罩列撞上同一条 SQL 里的 THEN 1 ELSE 0 照收
当分组键的(每一档一行)不算派生值,格子照常显示,逐行的标签列照旧整列遮。只有只装着标签的列不收(按作用域认取自
哪个来源:经别名、子查询、CTE 传下去、没起别名的也算),改了名字的遮罩列撞上同一条 SQL 里的 THEN 1 ELSE 0 照收,
UNION 另一支、子查询里的遮罩列流进标签列也照收
- 快照按列遮,另按值遮:别的列里写着遮罩值的格子也换成「已遮罩」;列名里写着遮罩值的,列名也换;质询工件里,
没设遮罩的列里一格文字夹着的遮罩值按子串换(回答一路照旧整份不交),SQL 检查结果的片段、说明照整条 SQL 换(截断的
片段从没闭合的引号、注释起去掉,说明里的码值表和别的字一样按值换)
@@ -278,6 +280,75 @@ def test_labels_passed_on_under_their_own_name_are_still_not_masked_values(sql,
assert plan.secrets.scrub("小单 3 笔、大单 2 笔") == "小单 3 笔、大单 2 笔"
#: 按条件数大单写的 1、0:和会员等级(1、2、3)撞在一起
BIG_ONE = "CASE WHEN amount > 1000 THEN 1 ELSE 0 END"
#: 标签那一支的 1、0 在前,等级 1、2、3 在后
LEVEL_ROWS = [[1], [0], [0], [1], [1], [2], [3], [1]]
@pytest.mark.parametrize("sql, columns, rows", [
# UNION 标签在前、遮罩列在后:遮罩列没起别名,或者起了别的名字
(f"SELECT {BIG_ONE} AS 等级 FROM sales UNION ALL SELECT vip_level FROM sales", ["等级"], LEVEL_ROWS),
(f"SELECT {BIG_ONE} AS 等级 FROM sales UNION ALL SELECT vip_level AS lv FROM sales", ["等级"], LEVEL_ROWS),
# 子查询里的 UNION,外层按名字取;写了列名表的 CTE 里的 UNION
(f"SELECT 等级, COUNT(*) AS 人数 FROM (SELECT {BIG_ONE} AS 等级 FROM sales UNION ALL SELECT vip_level FROM sales) t "
"GROUP BY 等级", ["等级", "人数"], [[0, 2], [1, 4], [2, 1], [3, 1]]),
(f"WITH t(lv) AS (SELECT {BIG_ONE} FROM sales UNION ALL SELECT vip_level FROM sales) SELECT lv AS 等级 FROM t",
["等级"], LEVEL_ROWS),
# 标签写在 CTE 里,结果的 UNION 另一支取遮罩列
(f"WITH b AS (SELECT {BIG_ONE} AS lvl FROM sales) SELECT lvl FROM b UNION ALL SELECT vip_level FROM sales",
["lvl"], LEVEL_ROWS),
# 遮罩列经按原名选出的子查询、以标签的别名传出
(f"SELECT {BIG_ONE} AS 档 FROM sales UNION ALL SELECT b.vip_level AS 档 FROM (SELECT vip_level FROM sales) b",
["档"], LEVEL_ROWS),
# 不用 UNION:两个子查询连起来,一边是标签,一边是改了名字的遮罩列,名字相同
(f"SELECT y.档 AS 档 FROM (SELECT id, {BIG_ONE} AS 档 FROM sales) x "
"JOIN (SELECT id, vip_level AS 档 FROM (SELECT id, vip_level FROM sales) z) y ON x.id = y.id", ["档"],
[[1], [2], [3], [1]]),
# USING 连起来的同名列:右连接时,左边没对上的行取的是右边那张表里的值
(f"WITH b AS (SELECT id, {BIG_ONE} AS vip_level FROM sales) SELECT vip_level AS 等级 FROM b "
"RIGHT JOIN members USING (vip_level)", ["等级"], [[1], [2], [3]]),
])
def test_a_column_that_also_carries_the_masked_column_is_not_a_label_column(sql, columns, rows):
"""第 3 轮复核:只装着标签的列以前按名字认,从「所有名字都只装着标签」起往下剔,遮罩列原样选出(SELECT vip_level)
时名字就是它自己,永远剔不掉;结果的 UNION 按位置那一遍只往里加标签,另一支取的是遮罩列也不剔,派生来源里的
UNION 根本不按位置看。于是标签在前、遮罩列在后时,等级 1 撞上 THEN 1 就不收,之后 WHERE vip_level = 1 照原样
封存。现在按作用域认每一项取自哪个来源,结果的这一列在每一支里都只装着标签才算,取自数据源里的表的一律不算。"""
plan = plan_of({"source": "shop", "sql": sql, "columns": columns, "rows": rows}, {"vip_level"})
assert inquiry_mod._label_columns(sql, "sqlite", columns) == {}
assert {"1", "2", "3"} <= plan.secrets.weak
for level in (1, 2, 3):
assert inquiry_mod._scrub_sql(f"SELECT COUNT(*) AS 人数 FROM sales WHERE vip_level = {level}", plan.secrets,
plan.tainted["a1"], "sqlite") == (
"SELECT COUNT(*) AS 人数 FROM sales WHERE vip_level = 已遮罩")
_EXTRA = "CASE WHEN amount > 60000 THEN '特大单' END"
@pytest.mark.parametrize("sql, columns, texts", [
# CTE 里分档,外层连上数据源里的表、按档位汇总:档位没写表名也认得出取自 CTE(SQL 能执行,别的表就没有同名的列,
# 不然会报列名有歧义)
(f"WITH banded AS (SELECT id, {BANDS} AS 档位 FROM sales) SELECT 档位, SUM(s.visits) AS 访问 FROM banded b "
"JOIN sales s ON s.id = b.id GROUP BY 档位", ["档位", "访问"], {"小单", "大单"}),
# 按别的列连起来(USING (id)):档位没合成一列,照样取自 CTE
(f"WITH banded AS (SELECT id, {BANDS} AS 档位 FROM sales) SELECT 档位, SUM(o.amount) AS 金额 FROM banded "
"JOIN orders o USING (id) GROUP BY 档位", ["档位", "金额"], {"小单", "大单"}),
# 写了表名、经两层子查询传下来
(f"SELECT t.档位 FROM (SELECT b.档位 FROM (SELECT {BANDS} AS 档位 FROM sales) b) t", ["档位"], {"小单", "大单"}),
# UNION 两支都是标签(第二支没写 ELSE、没起别名):两支的标签都算
(f"SELECT {BANDS} AS 档位 FROM sales UNION ALL SELECT {_EXTRA} FROM sales", ["档位"], {"小单", "大单", "特大单"}),
# 派生来源里的 UNION 两支都是标签,外层经 * 带出来
(f"SELECT * FROM (SELECT {BANDS} AS 档位 FROM sales UNION ALL SELECT {_EXTRA} FROM sales) t", ["档位"],
{"小单", "大单", "特大单"}),
])
def test_label_columns_are_traced_to_where_they_are_written(sql, columns, texts):
"""改成按作用域认之后,写在别处、原样传下来的标签照样认得出:外层连上数据源里的表没写表名、按别的列 USING、写了
表名经两层子查询;UNION 两支都是标签的,每一支的标签都算(以前派生来源里的 UNION 按名字认,只算起了这个名字的
第一支)。"""
assert inquiry_mod._label_columns(sql, "sqlite", columns) == {"档位": texts}
# --------------------------------------------------------------------------
# 按值认:门槛、边界
# --------------------------------------------------------------------------
@@ -505,6 +576,55 @@ def test_a_threshold_worked_out_from_several_numbers_is_kept_like_one_number(amo
assert inquiry_mod._shown_literals([(swapped, "sqlite", {"amount"})], amounts) == set()
@pytest.mark.parametrize("kind, sql, sealed", [
# 取模、整除算出来的边界和等值:125 % 100、MOD(125, 100) 是 25,50 DIV 2、51 // 2 也是
("sqlite", "SELECT COUNT(*) FROM sales WHERE age >= 25 AND age <= 125 % 100",
"SELECT COUNT(*) FROM sales WHERE age >= 已遮罩 AND age <= 已遮罩 % 已遮罩"),
("postgresql", "SELECT COUNT(*) FROM sales WHERE age = MOD(125, 100)",
"SELECT COUNT(*) FROM sales WHERE age = MOD(已遮罩, 已遮罩)"),
("mysql", "SELECT COUNT(*) FROM sales WHERE age >= 25 AND age <= 50 DIV 2",
"SELECT COUNT(*) FROM sales WHERE age >= 已遮罩 AND age <= 已遮罩 DIV 已遮罩"),
("duckdb", "SELECT COUNT(*) FROM sales WHERE age >= 25 AND age <= 51 // 2",
"SELECT COUNT(*) FROM sales WHERE age >= 已遮罩 AND age <= 已遮罩 // 已遮罩"),
# SQLite、Postgres 里两个整数相除只留整数部分:53 / 2 是 26,age < 26 和 age >= 25 夹住 25
("sqlite", "SELECT COUNT(*) FROM sales WHERE age >= 25 AND age < 53 / 2",
"SELECT COUNT(*) FROM sales WHERE age >= 已遮罩 AND age < 已遮罩 / 已遮罩"),
("postgresql", "SELECT COUNT(*) FROM sales WHERE buyer_phone >= 13900000001 AND buyer_phone < 27800000005 / 2",
"SELECT COUNT(*) FROM sales WHERE buyer_phone >= 已遮罩 AND buyer_phone < 已遮罩 / 已遮罩"),
])
def test_a_value_worked_out_with_remainders_or_whole_division_is_read_as_that_value(phones, kind, sql, sealed):
"""第 3 轮复核:取模(%、MOD)、整除(MySQL 的 DIV、DuckDB 的 //)写成的算式以前不按算出来的值认,age >= 25 AND
age <= 125 % 100 照原样封存;整数相除在 SQLite、Postgres 里只留整数部分,以前按小数算(53 / 2 算成 26.5),
夹住的值也认不出。现在都按数据源算出来的那一个值认,算式里的数都换,正文里也不免遮。"""
assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, kind) == sealed
assert not inquiry_mod._shown_literals([(sql, kind, {"buyer_phone", "age"})], phones) & {"25", "13900000001"}
@pytest.mark.parametrize("kind, divided", [("mysql", "53 / 2"), ("duckdb", "53 / 2"), ("sqlite", "53.0 / 2")])
def test_division_that_keeps_the_fraction_is_read_as_written(phones, kind, divided):
"""MySQL、DuckDB 的 / 照小数算,SQLite 里写成小数的也是:53 / 2、53.0 / 2 是 26.5,age >= 25 AND age < 26.5 里
25、26 都成立,没夹住,照原样。"""
sql = f"SELECT COUNT(*) FROM sales WHERE age >= 25 AND age < {divided}"
assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, kind) == sql
def test_type_parameters_of_a_cast_bound_are_left_alone(amounts):
"""第 3 轮复核:夹住某一个值的边界写成 CAST(v AS DECIMAL(10, 2)) 时,类型参数 10、2 以前也当成算出边界的数换掉,
封存的 SQL 成了 DECIMAL(已遮罩, 已遮罩),SQL 检查结果的片段里别处的 10、2 也跟着换。只换算出边界的那个数;Oracle
的 CAST … DEFAULT 0 ON CONVERSION ERROR 里的 0 也不算出值,照原样。"""
sql = ("SELECT COUNT(*) FROM sales WHERE amount >= CAST(30000 AS DECIMAL(10, 2)) "
"AND amount <= CAST(30000 AS DECIMAL(10, 2))")
assert inquiry_mod._scrub_sql(sql, amounts, {"amount"}, "sqlite") == sql.replace("30000", MASKED)
assert inquiry_mod._literal_swaps(sql, amounts, {"amount"}, "sqlite") == {"30000"}
checks = [{"code": "stock_summed", "level": "warning", "table": "sales", "message": "存量跨日期求和",
"sql_excerpt": "ROUND(SUM(amount), 2) >= 10"}]
[check] = inquiry_mod._masked_checks(checks, sql, amounts, {"amount"}, "sqlite")
assert check["sql_excerpt"] == "ROUND(SUM(amount), 2) >= 10"
fallback = ("SELECT COUNT(*) FROM sales WHERE amount >= CAST('30000' AS NUMBER DEFAULT 0 ON CONVERSION ERROR) "
"AND amount <= CAST('30000' AS NUMBER DEFAULT 0 ON CONVERSION ERROR)")
assert inquiry_mod._scrub_sql(fallback, amounts, {"amount"}, "oracle") == fallback.replace("30000", MASKED)
@pytest.mark.parametrize("sql, sealed", [
# 遮罩列套了 CAST、汇总:被比较的一边取的还是它的值,短的数照样换
("SELECT COUNT(*) FROM sales WHERE CAST(age AS INTEGER) = 25",