审批质询遮罩收口(第 2 轮复核):算式边界、套了 CAST / 汇总的列、换个写法的数、两头不是整数、范围再排除一头都认得出;只装着标签的列才不收标签;截断片段整段去掉

复核留下的三条 major 和两条次要问题:

- 范围的一头是算式(age <= 25 + 0、buyer_phone BETWEEN 13900000000 + 1 AND 13900000009 - 8):夹住的值认得出,算式里的数
  却照原样留着,一算就是那个号码。现在几个写死的数的加减乘除按算出来的那一个值认(_lifts):夹住某一个值的边界、等值、
  IN 里的算式,里面的数都换;选出列里算出来的号码按值换;没夹住的算式阈值和写成一个数的阈值一样照原样,正文里复述它
  (连同算出来的值)不遮,算出来的值在别处换掉了也不留。原有两条用例(BETWEEN 13900000001 AND 13900000002 - 1、
  … AND 13900000001 + 0)的期望随之改成算式里的数都换(多换一个 1、0,号码照样不露)
- 几种找值写法以前认不出:被比较的一边套了 CAST、汇总(CAST(age AS INTEGER) = 25、HAVING MAX(age) BETWEEN 25 AND 25)
  改按它的值取自哪些列认(_columns_of);同一个数换个写法(25.0、025、2.5e1、013900000001)按规范写法和遮罩值比
  (_canonical);两头不是整数的范围(age > 24.5 AND age < 25.5、BETWEEN 25 AND 25.9)在整数上隔开紧挨着的两个整数;
  范围再用不等、NOT IN 去掉一头(buyer_phone >= 13900000001 AND buyer_phone <= 13900000002 AND buyer_phone <> 13900000002,
  以前号码被当成阈值、回答正文跟着不遮):等值一类只在同一个条件里和范围合起来看,整数上紧挨着的被排除的数越过去接着
  看(age <= 30 时 27 照样成立,不算夹住),跨条件配对仍只看范围比较,不等、IN 单拿出来不当「写出了这个值」
- 算式求值改成不递归(_constants):visits <= 1+1+…写到 1500 项时以前超过递归深度,整条 SQL 退回按词认,同一句里夹住的
  年龄照原样封存
- 分档标签不收进遮罩值的规则太宽(第 1 轮引入):改了名字的遮罩列(vip_level AS 等级)的 1、2、3 撞上同一条 SQL 里按
  条件数个数的 THEN 1 ELSE 0,1 就不算遮罩值,WHERE vip_level = 1 照原样封存。现在只跳过只装着标签的列的格子
  (_label_columns:选出项本身是结果全为常量的 CASE / IIF,或者经别名、子查询、CTE 原样传下去的,没起别名的按位置认;
  同一个名字在哪一处装着别的就照收)
- SQL 检查结果的说明:码值表里的码值和别的字一样按值换,说明照实写(多遮,不漏),补测试钉住
- 截断的片段停在带空格的遮罩文字值中间(地址):以前只去掉最后一段,前半截还留着;现在从没闭合的引号、注释起整个去掉
- 模块说明的已知局限照实更新:两个产品取舍保持;夹住的认法补上不等、NOT IN、两头不是整数和越过排除数的规则;新增一条
  列出认得出的找值写法和仍认不出的(值藏进和列一起算的算式、拼接、函数拼出来的值)

测试:单元补算式边界、套了 CAST / 汇总、换个写法、两头不是整数、排除一头、不该夹住的几种(范围加不等仍不只一个值、
IN 列表旁的阈值、选出列里的等值和 WHERE 的范围)、上千项连加、改了名字的遮罩列撞上标签、标签列经子查询 / CTE / 没起
别名、截断片段、码值表;端到端补长号码的五种写法(算出来的边界、算出来的等值、两头不是整数、范围再排除一头、补零)、
年龄的六种写法和改了名字的等级列。每处实现都逐个去掉验证过测试变红
This commit is contained in:
ThearchyHelios
2026-10-06 15:08:56 +08:00
parent dde0927460
commit 5d4d1fdc15
3 changed files with 800 additions and 222 deletions
+497 -206
View File
@@ -16,11 +16,12 @@
(起别名、套函数、经子查询转一道、和 * 写在同一支里,_derived_hidden;认不出落在哪一列的,整份快照遮掉;按遮罩列
分档、当分组键或套在汇总里数个数的不算,_band_only);快照按列遮,再按值遮(别的列里写着遮罩值的格子,_masked_view;质询
工件里,文字格里夹着的遮罩值另按子串换)。回答正文、查询清单里的遮罩值换成「已遮罩」(_Secrets、_scrub_sql):
按值认有门槛,agent 写在 SQL 里的分档标签不算遮罩值(_written_labels);SQL 里的字面量按语法树认用在哪
(_literal_uses),数字照原样留着的只有范围比较的边界(没把某一个值夹住的,_pinned)和 LIMIT 的行数,而且同一个数
在这条 SQL 别处没被换掉,封存的是实际执行的语句;在找某一个值的(等值、IN、简单 CASE、夹住某一个值的范围……)和
别处够长的遮罩值都换。已经摆在读者眼前的值不换:问题和选中原文里的、报告列出的指标和运行输入的值;SQL 里照原样
留着的阈值只在回答正文里不换,格子照旧换。组装之后还认得出遮罩值的,这份回答不交出去
按值认有门槛,只装着分档标签的列(agent 写在 SQL 里的标签)不收进遮罩值(_label_columns);SQL 里的字面量按语法树
认用在哪(_literal_uses),数字照原样留着的只有范围比较的边界(没把某一个值夹住的,_pinned)和 LIMIT 的行数,而且
同一个数在这条 SQL 别处没被换掉,封存的是实际执行的语句;在找某一个值的(等值、IN、简单 CASE、夹住某一个值的
范围……,值换个写法、写成几个数的算式也认)和别处够长的遮罩值都换。已经摆在读者眼前的值不换:问题和选中原文里的、
报告列出的指标和运行输入的值;SQL 里照原样留着的阈值只在回答正文里不换,格子照旧换。组装之后还认得出遮罩值的,
这份回答不交出去
4. 写入(_record):一条 approval.inquiry 事件,node_id 为空,用量写在数据的 usage 里(计进运行用量,见 runner 的
_usage_of_events)。质询的查询快照只放在 inquiry_artifacts / queries 里,绝不放在 query_artifact 下:证据接口按那个键
收集封存的查询,会把质询的查询当成报告的证据。回答期间运行状态变了(被批准、被放弃、继续运行之后重新封存),
@@ -42,16 +43,27 @@
汇总里套着结果全是写死常量的 CASE(SUM(CASE WHEN amount <= 20000 THEN 1 ELSE 0 END))只是按条件数个数,同 COUNT
- 按遮罩列分档的标签(CASE、IIF 的结果全是写死的常量):标签文字是 agent 写在 SQL 里的,不当遮罩值,SQL、回答正文里
照原样;标签列当分组键(每一档一行)照常显示,不分组的逐行标签列整列遮(格子里看不出某一笔落在哪一档),可 agent
在正文里写出「这一笔是大单」遮不住,按 id 分组再按档数个数也看得出(同按阈值查出某几行)
- 「夹住某一个值」(_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) 再自己相减)认不出,边界照原样留着,正文里复述也不遮;反过来,
不在同一个条件里的两边只要不是这几处就按夹住算,分档写成两个 CTE、FROM 里的两个子查询再连起来,或者同一个选出列
里把两档的汇总值算在一起(小单数 / 大单数)时,共用的边界会多遮。同一个条件里细看合起来的真假,整数上的半开、开区间
(col >= v AND col < v + 1)只在整个条件只认 v 时算,被更宽的范围吸收、和相邻的分档连成一段的不算;细看的步数有
上限(PIN_WORK),超出之后两边都隔开的一律按夹住算,分档很多时会成批多遮
在正文里写出「这一笔是大单」遮不住,按 id 分组再按档数个数也看得出(同按阈值查出某几行)。只有只装着标签的列不收
(选出项本身就是这样的 CASE / IIF,或者经别名、子查询原样传下去的),同一个名字在哪一处装着别的就照收;标签恰好和
别的列里的遮罩值相同时,正文里的档名、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) 再自己相减)认不出,边界照原样留着,正文里复述也不遮;
反过来,不在同一个条件里的两边只要不是这几处就按夹住算,分档写成两个 CTE、FROM 里的两个子查询再连起来,或者同一个
选出列里把两档的汇总值算在一起(小单数 / 大单数)时,共用的边界会多遮。同一个条件里细看合起来的真假,整数上的半开、
开区间(col >= v AND col < v + 1)、两头不是整数的(col > v - 0.5 AND col < v + 0.5)只在整个条件只认 v 时算,被
更宽的范围吸收、和相邻的分档连成一段的不算。不等、NOT IN 只在同一个条件里和范围合起来看(age >= 25 AND age <= 26
AND age <> 26 只认 25;age <= 30 时 27 也成立,不算),拆进别的条件(子查询、HAVING)认不出;整数上紧挨着 v 的一串
被排除的数最多越过 PAIR_MAX 个,再多就按夹住算(多遮)。细看的步数有上限(PIN_WORK),超出之后两边都有范围比较
隔开的一律按夹住算,分档很多时会成批多遮
- 找某一个值的写法按语法树认常见的几种:等值、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),
短的值照原样封存,长号码只在整个写出来时按值换
- 质询工件里 SQL 检查结果的片段是检查器按语法树重新写出来的,按整条 SQL 里整个换掉了的字面量照值换:同一个值在
片段里别处出现也换(多遮);截断处半截的值去掉
片段里别处出现也换(多遮,算式里换掉的 0、1 这类也是);截断处半截的值去掉(停在字符串、带引号的名字、注释里的,
从开头的引号、注释起整个去掉)。说明里码值表列出的码值和别的字一样按值换,恰好是遮罩值的码值也换(多遮)
- 运行详情的「工件」页签、通用的工件接口按工件原样给:质询的查询快照记在这次运行名下(同报告自己的查询快照),从
那里打开不经这里的遮罩
- 遮罩值最多收 SECRETS_MAX 种写法,再多的按值认不出(按列遮照旧)
@@ -70,7 +82,7 @@ from collections import OrderedDict
from collections.abc import Callable, Iterable, Iterator
from dataclasses import dataclass, field
from datetime import datetime, timezone
from decimal import Decimal
from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal
from typing import Any
from sqlalchemy import func, select
@@ -735,10 +747,14 @@ def _band_only(node: Any) -> bool:
return False
def _written_labels(sql: Any, kind: str | None) -> set[str]:
"""SQL 里 CASE、IIF 写死的结果(分档的标签、档位编号)在格子里的写法。它们是 agent 自己写在 SQL 里的字,不是数据源
里的值:派生列里出现了也不当遮罩值(_Secrets.add),不然 SQL 里的标签、回答正文里的档名都会被换成「已遮罩」。
解析不了的一个都不算。"""
def _label_columns(sql: Any, kind: str | None, columns: list[str]) -> dict[str, set[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
from app.data.sql_semantics import _parse, _read
@@ -748,16 +764,69 @@ def _written_labels(sql: Any, kind: str | None) -> set[str]:
except Exception: # noqa: BLE001 - 解析器的意外按解析不了处理(标签照旧当遮罩值,宁可多遮)
root = None
if root is None:
return set()
out: set[str] = set()
for node in root.find_all(exp.Case, exp.If):
for result in _branch_results(node) or []:
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
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。"""
from sqlglot import exp
node = item
while isinstance(node, (exp.Alias, exp.Paren)):
node = node.this
results = _branch_results(node)
if results is not None:
if not all(_fixed(r) for r in results):
return None
out: set[str] = set()
for result in results:
value = _constant(result) if result is not None else None
if isinstance(value, Decimal):
out |= {_point_text(value), format(value, "f")}
elif value is not None:
out.add(_plain_text(value))
return out
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()]
return None
def _is_star(item: Any) -> bool:
@@ -1029,13 +1098,14 @@ class _Secrets:
_index: Any = field(default=None, repr=False, compare=False)
_compiled: dict[frozenset[str], re.Pattern[str]] = field(default_factory=dict, repr=False, compare=False)
def add(self, snapshot: Any, hidden: set[str], *, labels: Iterable[str] = (), base: Iterable[str] = ()) -> None:
"""收一份快照里要遮的列(hidden,小写)的值。labels 是这份快照的 SQL 里写死的分档标签(_written_labels):派生
列(hidden 里不在 base 的,base 是数据源设了遮罩的列)里的标签不收,遮罩列自己选出来的值照收。"""
def add(self, snapshot: Any, hidden: set[str], *, labels: dict[str, set[str]] | None = None,
base: Iterable[str] = ()) -> None:
"""收一份快照里要遮的列(hidden,小写)的值。labels 是这份快照里只装着分档标签的列(_label_columns):这几列的
格子等于标签的不收;改了名字的遮罩列、别的派生列照收,base(数据源设了遮罩的列,小写)自己选出来的值也照收。"""
if not _query_shaped(snapshot) or not hidden:
return
written = set(labels)
derived = set(hidden) - set(base) if written else set()
own = set(base)
written = {name: texts for name, texts in (labels or {}).items() if name not in own}
columns = [str(c) for c in snapshot.get("columns") or []]
index = [i for i, c in enumerate(columns) if c.lower() in hidden]
for row in snapshot["rows"]:
@@ -1051,7 +1121,7 @@ class _Secrets:
if value is None or isinstance(value, bool):
continue
forms = _forms(value)
if name in derived and forms & written:
if forms & written.get(name, set()):
continue
for form in forms:
self.weak.add(form)
@@ -1259,8 +1329,8 @@ _LEADS = frozenset({"DASH", "PLUS", "INTRODUCER"})
_VALUE_ENDS = frozenset({"NUMBER", "VAR", "IDENTIFIER", "R_PAREN", "R_BRACKET", "NULL", "TRUE", "FALSE"})
#: 数字照原样留在 SQL 里的两种用法(_literal_uses):范围比较的边界、LIMIT 这类的行数
_KEPT = frozenset({"range", "limit"})
#: 字面量写在左边时运算符翻过来:25 < age 就是 age > 25
_FLIP = {">": "<", ">=": "<=", "<": ">", "<=": ">="}
#: 字面量写在左边时运算符翻过来:25 < age 就是 age > 25(等值一类翻过来还是它自己)
_FLIP = {">": "<", ">=": "<=", "<": ">", "<=": ">=", "=": "=", "<>": "<>"}
#: 一个纯粹的数(可带正负号、小数):范围边界按数比大小,写成字符串的也一样
_PLAIN_NUMBER = re.compile(r"[-+]?\d+(?:\.\d+)?")
@@ -1321,25 +1391,51 @@ def _column_of(node: Any, index: int | None) -> str | None:
"""比较另一边的列名(小写、不带表名);另一边是行值时取第 index 项。不是单独一列(函数、算式、子查询)时为 None。"""
from sqlglot import exp
if index is not None and isinstance(node, exp.Tuple):
items = node.expressions
node = items[index] if index < len(items) else None
while isinstance(node, exp.Paren):
node = node.this
node = _item_of(node, index)
if isinstance(node, exp.Column) and not isinstance(node.this, exp.Star):
return node.name.lower() or None
return None
def _use_of(node: Any) -> tuple[str, str | None]:
"""语法树里一个字面量用在哪:(用法, 列名),用法见 _literal_uses。
先往上越过不改变「找的是哪个值」的外壳:括号、负号、CAST、字符集前缀、COLLATE;行值、VALUES、ARRAY、ANY / ALL
也越过,记下「在一组值里」(行值另记下是第几项)。再看外壳的上一层:等值一类、IN、简单 CASE 的 WHEN 是在找这个值;
范围比较、LIMIT 一类只认单独一个值(一组值里的、算式里的都不算)。"""
def _item_of(node: Any, index: int | None) -> Any:
"""比较的另一边去掉括号;是行值时取第 index 项(没有这一项为 None)。"""
from sqlglot import exp
child, parent = node, node.parent
if index is not None and isinstance(node, exp.Tuple):
items = node.expressions
node = items[index] if index < len(items) else None
while isinstance(node, exp.Paren):
node = node.this
return node
def _columns_of(node: Any, index: int | None) -> frozenset[str]:
"""比较另一边的值取自哪些列(小写、不带表名):单独一列就是它;套了 CAST、函数、汇总、算式的,按值取自哪些列算
(_value_columns:CAST(age AS INTEGER)、MAX(age) 取的还是年龄;COUNT 只数个数,不算)。和遮罩列比较的字面量,
短的数也按遮罩列里的值认(_swap_literal)。"""
from sqlglot import exp
node = _item_of(node, index)
if node is None:
return frozenset()
if isinstance(node, exp.Column):
name = None if isinstance(node.this, exp.Star) else node.name.lower()
return frozenset({name} if name else ())
return frozenset(_value_columns(node))
def _use_of(node: Any, values: dict[int, Any], lifts: dict[int, Any]) -> tuple[str, frozenset[str], Any]:
"""语法树里一个字面量用在哪:(用法, 被比较的一边取自哪些列, 算出来的值),用法见 _literal_uses。
先往上越过不改变「找的是哪个值」的外壳:括号、负号、CAST、字符集前缀、COLLATE,以及几个写死的数的加减乘除——
整个算式按算出来的那一个值认(_lifts,第三项交回走到的那一层的值,什么都没越过为 None);行值、VALUES、ARRAY、ANY / ALL 也
越过,记下「在一组值里」(行值另记下是第几项)。再看外壳的上一层:等值一类、IN、简单 CASE 的 WHEN 是在找这个值;
范围比较、LIMIT 一类只认单独一个值(一组值里的、和列一起算的算式里的都不算)。"""
from sqlglot import exp
top = lifts.get(id(node), node)
computed = values.get(id(top)) if top is not node else None
child, parent = top, top.parent
index, grouped = None, False
while parent is not None:
if isinstance(parent, exp.Tuple):
@@ -1356,23 +1452,23 @@ def _use_of(node: Any) -> tuple[str, str | None]:
child, parent = parent, parent.parent
if isinstance(parent, (exp.EQ, exp.NEQ, exp.NullSafeEQ, exp.NullSafeNEQ, exp.Is, exp.Like, exp.ILike, exp.Glob,
exp.RegexpLike, exp.SimilarTo)):
return "equal", _column_of(parent.expression if child is parent.this else parent.this, index)
return "equal", _columns_of(parent.expression if child is parent.this else parent.this, index), computed
if isinstance(parent, exp.In):
return "equal", None if child is parent.this else _column_of(parent.this, index)
return "equal", frozenset() if child is parent.this else _columns_of(parent.this, index), computed
if isinstance(parent, exp.If) and child is parent.this and isinstance(parent.parent, exp.Case) \
and parent.parent.this is not None:
# 简单 CASE:CASE buyer_phone WHEN 13900000001 THEN …(CASE WHEN col = v 的写法走上面的等值)
return "equal", _column_of(parent.parent.this, index)
return "equal", _columns_of(parent.parent.this, index), computed
if grouped:
return "other", None
return "other", frozenset(), computed
if type(parent) in _range_ops() and (child is parent.this or child is parent.expression):
return "range", _column_of(parent.expression if child is parent.this else parent.this, None)
return "range", _columns_of(parent.expression if child is parent.this else parent.this, None), computed
if isinstance(parent, exp.Between) and child is not parent.this:
return "range", _column_of(parent.this, None)
return "range", _columns_of(parent.this, None), computed
if (isinstance(parent, (exp.Limit, exp.Offset)) and child is parent.expression) or (
isinstance(parent, exp.Fetch) and child is parent.args.get("count")):
return "limit", None
return "other", None
return "limit", frozenset(), computed
return "other", frozenset(), computed
def _wraps(parent: Any, child: Any) -> bool:
@@ -1391,71 +1487,149 @@ def _range_ops() -> dict[Any, str]:
return {exp.GT: ">", exp.GTE: ">=", exp.LT: "<", exp.LTE: "<="}
def _exact_ops() -> dict[Any, str]:
"""等值一类的语法树节点 → 运算符:=、IS NOT DISTINCT FROM、SQLite 的 IS 是「=」,<>、IS DISTINCT FROM 是「<>」。
IN 另外认(一串「=」的「或」)。它们和范围比较合起来也能夹住一个值(age >= 25 AND age <= 26 AND age <> 26),
只在同一个条件里算(_pinned)。"""
from sqlglot import exp
return {exp.EQ: "=", exp.NullSafeEQ: "=", exp.Is: "=", exp.NEQ: "<>", exp.NullSafeNEQ: "<>"}
def _arithmetic() -> tuple[Any, ...]:
"""加减乘除:几个写死的数算出来的整个算式按算出来的那一个值认(13900000000 + 1 就是 13900000001)。"""
from sqlglot import exp
return exp.Add, exp.Sub, exp.Mul, exp.Div
#: 数的写法里指数最大多少(1e999999 这样的写法不按数认,免得展开成上百万位)
_EXPONENT_MAX = 40
def _decimal(text: str) -> Decimal | None:
"""一段字按数读(025、2.5e1、13900000001.0 都认);读不了、不是有限的数、指数太大为 None。"""
try:
value = Decimal(text.strip())
except (ArithmeticError, ValueError):
return None
return value if value.is_finite() and abs(value.adjusted()) <= _EXPONENT_MAX else None
def _canonical(text: str) -> str | None:
"""一个数的规范写法(_point_text):25.0、025、2.5e1 都是 25,13900000001.0 是 13900000001。SQL 里换个写法写同一个
数,照样认得出是不是遮罩值(_swap_literal)。不是数为 None。"""
value = _decimal(text)
return _point_text(value) if value is not None else None
def _constant(node: Any) -> Any:
"""写死的值:数按数(几个数加减乘除算出来的也算,13900000002 - 1),字符串按原文(纯粹的数照样按数);别的为 None。"""
"""写死的值:数按数(几个数加减乘除算出来的也算,13900000002 - 1;025、2.5e1 这样的写法也按数),字符串按原文
(纯粹的数照样按数);别的为 None。"""
return _constants(node).get(id(node)) if node is not None else None
def _constants(root: Any) -> dict[int, Any]:
"""一棵语法树里每个写死的值(_constant):id(节点) → 值。从下往上一层层算(广度优先的倒序,孩子总在父亲前面),
不递归:上千项的连加(1+1+…)会超过递归深度,以前整条 SQL 因此退回按词认,同一句里夹住的值就认不出了。"""
from sqlglot import exp
if isinstance(node, exp.Neg):
value = _constant(node.this)
return -value if isinstance(value, Decimal) else None
if isinstance(node, (exp.Paren, exp.Cast, exp.Collate)):
return _constant(node.this)
if isinstance(node, exp.Introducer):
return _constant(node.expression)
if isinstance(node, _literal_types()):
text = str(node.this).strip()
return Decimal(text) if _PLAIN_NUMBER.fullmatch(text) else text
if isinstance(node, (exp.Add, exp.Sub, exp.Mul, exp.Div)):
a, b = _constant(node.this), _constant(node.expression)
if not (isinstance(a, Decimal) and isinstance(b, Decimal)):
return None
if isinstance(node, exp.Div):
return a / b if b else None
return a + b if isinstance(node, exp.Add) else a - b if isinstance(node, exp.Sub) else a * b
return None
def _bare_literal(node: Any) -> Any:
"""边界本身就是一个字面量(套了括号、负号、CAST 的也算)时交回那个字面量,否则 None。"""
from sqlglot import exp
while node is not None and not isinstance(node, _literal_types()):
if isinstance(node, (exp.Paren, exp.Neg, exp.Cast, exp.Collate)):
node = node.this
out: dict[int, Any] = {}
for node in reversed(list(root.walk())):
value: Any = None
if isinstance(node, exp.Neg):
inner = out.get(id(node.this))
value = -inner if isinstance(inner, Decimal) else None
elif isinstance(node, (exp.Paren, exp.Cast, exp.Collate)):
value = out.get(id(node.this))
elif isinstance(node, exp.Introducer):
node = node.expression
value = out.get(id(node.expression))
elif isinstance(node, _literal_types()):
text = str(node.this).strip()
number = Decimal(text) if _PLAIN_NUMBER.fullmatch(text) else (
_decimal(text) if isinstance(node, exp.Literal) and not node.is_string else None)
value = number if number is not None else text
elif isinstance(node, _arithmetic()):
value = _worked_out(node, out.get(id(node.this)), out.get(id(node.expression)))
if value is not None:
out[id(node)] = value
return out
def _worked_out(node: Any, a: Any, b: Any) -> Decimal | None:
"""一步加减乘除的结果;不是两个数、除以零、结果太大为 None。"""
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
else:
return None
return node
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
#: 范围比较的一半:(字面量节点, 另一边的键, 写死的值, 「键 运算符 值」的运算符)。边界是几个数算出来的
#: (13900000002 - 1),字面量节点记 None:它照样能夹住另一头的值,换的是另一头的字面量
_Half = tuple[Any, str, Any, str]
def _lifts(root: Any, values: dict[int, Any]) -> dict[int, Any]:
"""写死的值往上能越过的外壳和算式:id(节点) → 越过之后走到的那一层(13900000000 + 1 里的两个数都走到整个算式,
(25) 里的 25 走到括号)。先父亲后孩子(广度优先)地记,每个字面量直接跳到那一层,上千项的连加不必每个数都一层层
往上走。"""
out: dict[int, Any] = {}
for node in root.walk():
if id(node) not in values:
continue
parent = node.parent
lifted = parent is not None and id(parent) in values and (
isinstance(parent, _arithmetic()) or _wraps(parent, node))
out[id(node)] = out[id(parent)] if lifted else node
return out
def _halves(node: Any) -> list[_Half | None]:
"""一个范围比较拆成的几半:BETWEEN 是两半的「且」(不低于下界、不高于上界),> >= < <= 是一半。边界不是写死的值
(另一列、子查询)或者两边都写死了的,这一半记 None:它的真假跟哪一列的取值都对不上,当成不知道真假。"""
#: 一个比较的一半:(算出边界的那几个字面量, 另一边的键, 写死的值, 「键 运算符 值」的运算符)。边界是几个数算出来的
#: (13900000002 - 1),算式里的字面量都记下:夹住了某一个值,留着哪一个都可能算得出它,都换
_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:它的真假跟哪一列的取值都对不上,当成不知道真假。"""
from sqlglot import exp
if isinstance(node, exp.Between):
sides = [(node.args.get("low"), node.this, ">="), (node.args.get("high"), node.this, "<=")]
else:
op = _range_ops()[type(node)]
right, left = _constant(node.expression), _constant(node.this)
if (right is None) == (left is None):
elif isinstance(node, exp.In):
if node.args.get("query") or node.args.get("unnest") or node.args.get("field") or id(node.this) in values:
return [None]
sides = [(node.expression, node.this, op)] if right is not None else [(node.this, node.expression, _FLIP[op])]
sides = [(item, node.this, "=") for item in node.expressions]
else:
op = _op_of(node)
right, left = id(node.expression) in values, id(node.this) in values
if op is None or right == left:
return [None]
sides = [(node.expression, node.this, op)] if right else [(node.this, node.expression, _FLIP[op])]
out: list[_Half | None] = []
for operand, other, op in sides:
value = _constant(operand) if operand is not None else None
out.append((_bare_literal(operand), _key_of(other), value, op) if value is not None else None)
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)
return out
def _op_of(node: Any) -> str | None:
"""范围比较、等值一类的运算符(「列 运算符 值」方向);别的为 None。"""
for kind, op in (*_range_ops().items(), *_exact_ops().items()):
if isinstance(node, kind):
return op
return None
def _key_of(node: Any) -> str:
"""范围比较另一边的键:单独一列取列名(小写、不带表名,同 _column_of);别的表达式(SUM(amount)、函数、算式)取
"""比较另一边的键:单独一列取列名(小写、不带表名,同 _column_of);别的表达式(SUM(amount)、函数、算式)取
整个写法,只和写得一样的表达式算同一个——SUM(amount) <= v 和 amount >= v 说的不是一回事。前面垫一个列名里不会
有的字,免得和列名撞上。"""
from sqlglot import exp
@@ -1532,51 +1706,75 @@ def _apart(a: Any, b: Any, paths: dict[int, dict[int, Any]]) -> tuple[bool, int]
def _marks(value: Any, op: str) -> list[tuple[Any, int]]:
"""一个边界从哪边隔开了哪个值:(值, 0) 是从下面隔开(它和比它小一点的值结果不同:>= v、< v),(值, 1) 是从上面
隔开(和比它大一点的值结果不同:<= v、> v)。整数上还隔开了相邻的那一个:> v、<= v 从下面隔开 v + 1(v 不成立、
v + 1 成立,或者反过来),< v、>= v 从上面隔开 v - 1——age <= 24 OR age >= 26 就是 age <> 25。"""
whole = isinstance(value, Decimal) and value == value.to_integral_value()
above = [(value + 1, 0)] if whole else []
below = [(value - 1, 1)] if whole else []
return {">=": [(value, 0), *below], "<=": [(value, 1), *above],
">": [(value, 1), *above], "<": [(value, 0), *below]}[op]
隔开(和比它大一点的值结果不同:<= v、> v);= v、<> v 从两边隔开 v。整数上还隔开了相邻的那一个:> v、<= v 从下面
隔开 v + 1(v 不成立、v + 1 成立,或者反过来),< v、>= v 从上面隔开 v - 1——age <= 24 OR age >= 26 就是
age <> 25;= v、<> v 从上面隔开 v - 1、从下面隔开 v + 1。边界不是整数的,整数上隔开的是它两边紧挨着的两个整数
(age > 24.5 就是 age >= 25)。"""
if not isinstance(value, Decimal):
return {">=": [(value, 0)], "<=": [(value, 1)], ">": [(value, 1)], "<": [(value, 0)],
"=": [(value, 0), (value, 1)], "<>": [(value, 0), (value, 1)]}[op]
if value == value.to_integral_value():
above, below = [(value + 1, 0)], [(value - 1, 1)]
return {">=": [(value, 0), *below], "<=": [(value, 1), *above], ">": [(value, 1), *above],
"<": [(value, 0), *below], "=": [(value, 0), (value, 1), *below, *above],
"<>": [(value, 0), (value, 1), *below, *above]}[op]
# 不是整数:整数上 = v 永远不成立、<> v 永远成立,隔不开谁
if op in ("=", "<>"):
return [(value, 0), (value, 1)]
steps = [(value.to_integral_value(rounding=ROUND_CEILING), 0), (value.to_integral_value(rounding=ROUND_FLOOR), 1)]
return [(value, 0 if op in (">=", "<") else 1), *steps]
def _pinned(root: Any) -> dict[int, tuple[str, ...]]:
"""范围比较里把某一个值夹住了的边界字面量:id(字面量节点) → 夹住的值(_point_text 的写法)。
def _pinned(root: Any, values: dict[int, Any]) -> dict[int, tuple[str, ...]]:
"""把某一个值夹住了的边界字面量:id(字面量节点) → 夹住的值(_point_text 的写法)。values 是 _constants 算好的
写死的值。
一个值 v 两边都被隔开(_marks),条件才可能只认 v 这一个值。在整条 SQL 里配对,只有分档的几种写法各算各的
(_apart:CASE 的不同分支、SELECT 里不筛行的不同项、UNION 的不同支),它们共用的边界是分档的界,不是在找哪一个
值;别处筛的是同一批行(ON 和 WHERE、WHERE 和 HAVING、INTERSECT 两支、包在 CASE / 函数 / 算式里的一半、子查询、
CTE),两边一隔开就按夹住算——只在同一个条件里配对的话,把找值拆到这些地方,号码就照原样留在 SQL 里,回答正文
也跟着不遮。同一个条件里的再细看合起来的真假(_isolated):col >= v AND col <= v、BETWEEN v AND v、
col > v OR col < v(就是 col <> v)、整数的 col >= v AND col < v + 1、col > v - 1 AND col < v + 1 是在找 v,
和等值比较一样换;col > 31 AND col <= 25 AND col NOT BETWEEN 25 AND 31 这样谁也找不着的、col <= v OR col >= v
这样全都要的不是。另一边不是单独一列的(SUM(amount) >= 900)只和写得一样的表达式配(_key_of)。夹住了就把隔开 v
的边界都记下:两头都不是 v 的(col > 24 AND col < 26)也换,留着就等于写出了 v。
细看、找公共祖先走的步数有上限(PIN_WORK),超出之后两边都隔开的一律按夹住算。"""
一个值 v 两边都被隔开(_marks),条件才可能只认 v 这一个值。范围比较在整条 SQL 里配对,只有分档的几种写法各算
各的(_apart:CASE 的不同分支、SELECT 里不筛行的不同项、UNION 的不同支),它们共用的边界是分档的界,不是在找哪
一个值;别处筛的是同一批行(ON 和 WHERE、WHERE 和 HAVING、INTERSECT 两支、包在 CASE / 函数 / 算式里的一半、
子查询、CTE),两边一隔开就按夹住算——只在同一个条件里配对的话,把找值拆到这些地方,号码就照原样留在 SQL 里,
回答正文也跟着不遮。同一个条件里的再细看合起来的真假(_isolated):col >= v AND col <= v、BETWEEN v AND v、
col > v OR col < v(就是 col <> v)、整数的 col >= v AND col < v + 1、col > v - 1 AND col < v + 1、两头不是整数的
col > v - 0.5 AND col < v + 0.5 是在找 v,和等值比较一样换;范围再用不等、NOT IN 去掉一头的(age >= 25 AND
age <= 26 AND age <> 26)也是,不等、IN 只在同一个条件里和范围合起来看(跨条件配对只看范围比较:选出列里
CASE WHEN age = 26 和 WHERE age >= 25 说不上在找谁);col > 31 AND col <= 25 AND col NOT BETWEEN 25 AND 31 这样
谁也找不着的、col <= v OR col >= v 这样全都要的不是。另一边不是单独一列的(SUM(amount) >= 900)只和写得一样的
表达式配(_key_of)。夹住了就把隔开 v 的边界都记下:两头都不是 v 的(col > 24 AND col < 26)、边界是算式的
(col <= 25 + 0),算式里的数都换,留着就等于写出了 v。
细看、找公共祖先走的步数有上限(PIN_WORK),超出之后两边都有范围比较隔开的一律按夹住算。"""
from sqlglot import exp
exact_types = (*_exact_ops(), exp.In)
tops: dict[int, Any] = {}
within: dict[int, dict[int, list[_Half | None]]] = {}
sides: dict[tuple[str, Any], tuple[dict[int, list[Any]], dict[int, list[Any]]]] = {}
for node in root.find_all(exp.Between, *_range_ops()):
within: dict[int, dict[int, _Leaf]] = {}
# 范围比较隔开的、等值一类(不等、IN)隔开的分开记:(键, 值) → (从下面隔开的, 从上面隔开的),各按条件分组
ranged: dict[tuple[str, Any], tuple[dict[int, list[Any]], dict[int, list[Any]]]] = {}
exact: dict[tuple[str, Any], tuple[dict[int, list[Any]], dict[int, list[Any]]]] = {}
# 同一个条件里等值一类写着的数(按条件、键):整数上细看时,紧挨着的这些数要越过去再看(_window)
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)
if is_exact and all(h is None for h in parts):
continue # 连接条件(两边都是列)这类:和夹住哪个值无关
top = _condition_root(node)
tops[id(top)] = top
parts = _halves(node)
within.setdefault(id(top), {})[id(node)] = parts
for literal, key, value, op in (h for h in parts if h is not None):
within.setdefault(id(top), {})[id(node)] = (parts, isinstance(node, exp.In), is_exact)
marks = exact if is_exact else ranged
for literals, key, value, op in (h for h in parts if h is not None):
if is_exact and isinstance(value, Decimal):
nearby.setdefault((id(top), key), set()).add(value)
for point, side in _marks(value, op):
sides.setdefault((key, point), ({}, {}))[side].setdefault(id(top), []).append(literal)
marks.setdefault((key, point), ({}, {}))[side].setdefault(id(top), []).extend(literals)
budget = PIN_WORK
orders: dict[int, list[tuple[Any, list[Any]]]] = {}
apart: dict[tuple[int, int], bool] = {}
paths: dict[int, dict[int, Any]] = {}
out: dict[int, set[str]] = {}
for (key, point), (below, above) in sides.items():
if not (below and above):
continue
for (key, point), (below, above) in ranged.items():
low_exact, high_exact = exact.get((key, point), ({}, {}))
marked: list[Any] = []
# 不在同一个条件里的两边:不是各算各的,就按夹住算
# 不在同一个条件里的两边(只看范围比较):不是各算各的,就按夹住算
for low, lows in below.items():
for high, highs in above.items():
if low == high:
@@ -1587,18 +1785,22 @@ def _pinned(root: Any) -> dict[int, tuple[str, ...]]:
budget -= steps
if not apart[pair]:
marked += lows + highs
# 同一个条件里的两边:细看合起来的真假
for top in below.keys() & above.keys():
# 同一个条件里的两边(至少一边是范围比较):细看合起来的真假
for top in below.keys() | above.keys():
lows = below.get(top, []) + low_exact.get(top, [])
highs = above.get(top, []) + high_exact.get(top, [])
if not (lows and highs):
continue
if top not in orders:
orders[top] = _logic_order(tops[top])
if len(orders[top]) <= budget:
budget -= len(orders[top])
if not _isolated(orders[top], within[top], key, point):
continue
marked += below[top] + above[top]
if _isolated(orders[top], within[top], key, point, nearby.get((top, key), set())):
marked += lows + highs
elif top in below and top in above:
marked += below[top] + above[top]
for literal in marked:
if literal is not None:
out.setdefault(id(literal), set()).add(_point_text(point))
out.setdefault(id(literal), set()).add(_point_text(point))
return {literal: tuple(sorted(points)) for literal, points in out.items()}
@@ -1607,25 +1809,50 @@ _EITHER = frozenset({(False, False, False), (True, True, True)})
#: 只认中间那一个值(或者只排除它):(比它小一点, 它, 比它大一点) 的真假
_ISOLATED = frozenset({(False, True, False), (True, False, True)})
_HOLDS: dict[str, Callable[[int], bool]] = {
">=": lambda s: s >= 0, ">": lambda s: s > 0, "<=": lambda s: s <= 0, "<": lambda s: s < 0}
#: 且、或连起来的一串里,两两细看的项最多这么多(随这一列取值变真假的项):再多就按夹住算
">=": lambda s: s >= 0, ">": lambda s: s > 0, "<=": lambda s: s <= 0, "<": lambda s: s < 0,
"=": lambda s: s == 0, "<>": lambda s: s != 0}
#: 且、或连起来的一串里,两两细看的项最多这么多(随这一列取值变真假的项):再多就按夹住算。整数上细看时,紧挨着
#: 的一串不等、IN 写着的数最多越过这么多个(_window)
PAIR_MAX = 64
def _isolated(order: list[tuple[Any, list[Any]]], halves: dict[int, list[_Half | None]], key: str, point: Any
) -> bool:
def _isolated(order: list[tuple[Any, list[Any]]], halves: dict[int, _Leaf], key: str, point: Any,
nearby: set[Decimal] | frozenset[Decimal] = frozenset()) -> bool:
"""这个条件(order 是它的各层,_logic_order)里 key 取 point 时夹没夹住,两种都算:
- 整个条件只认 point 这一个值,或者只排除它(真假和它两边都不一样)。数按两种看:连续的(紧挨着 point 两边的值)
和整数的(point - 1、point + 1,col >= v AND col < v + 1 只在整数上夹住 v);
和整数的(point - 1、point + 1,col >= v AND col < v + 1 只在整数上夹住 v)。整数上紧挨着的是同一个条件里
不等、IN 写着的数(nearby)时越过去接着看(_window):age >= 25 AND age <= 30 AND age <> 26 里 26 被去掉了,
27 照样成立,不只认 25;age <= 26 时 27 不成立,就只认 25;
- 条件里哪一层(一个比较、括号、非、异或,且 / 或连起来的一串里的任意一项、任意两项)不论整数都只认 point:
BETWEEN v AND v、>= v AND <= v、> v OR < v 本身就写出了 v,旁边的条件把它吸收了(OR 一个更宽的范围)、和它
矛盾了也算,隔着别的条件写也一样。整数上的半开、开区间不这样一层层看:一长串相邻的分档(age >= 0 AND age < 1
OR age >= 1 AND age < 2 …)每一档单拿出来都只认一个整数,连起来谁也没找。
矛盾了也算,隔着别的条件写也一样。不等、IN 不这样一层层看(单拿出来本来就只认、只排除写着的值,它们自己的
字面量照等值比较换),整数上的半开、开区间也不:一长串相邻的分档(age >= 0 AND age < 1 OR age >= 1 AND
age < 2 …)每一档单拿出来都只认一个整数,连起来谁也没找。
条件里和这一列无关的部分当成不知道真假,只要有一种取法夹住了就算(宁可多换)。"""
if _outcomes(order, halves, key, point, False, signals=True)[1]:
if _outcomes(order, halves, key, point, None, signals=True)[1]:
return True
whole = isinstance(point, Decimal) and point == point.to_integral_value()
return whole and bool(_ISOLATED & _outcomes(order, halves, key, point, True)[0])
if not (isinstance(point, Decimal) and point == point.to_integral_value()):
return False
offsets = _window(point, nearby)
if offsets is None:
return True
lone = tuple(o == 0 for o in offsets)
return bool({lone, tuple(not x for x in lone)} & _outcomes(order, halves, key, point, offsets)[0])
def _window(point: Decimal, nearby: set[Decimal] | frozenset[Decimal]) -> tuple[int, ...] | None:
"""整数上细看 point 时看哪几个整数(相对 point 的偏移):两边各看一个;紧挨着的是不等、IN 写着的数,越过它们再
多看一个。一边要越过的超过 PAIR_MAX 个时为 None(看不过来,按夹住算)。"""
below = above = 0
while point - below - 1 in nearby:
below += 1
if below > PAIR_MAX:
return None
while point + above + 1 in nearby:
above += 1
if above > PAIR_MAX:
return None
return tuple(range(-below - 1, above + 2))
def _logic_children(node: Any) -> list[Any]:
@@ -1667,13 +1894,14 @@ def _logic_order(top: Any) -> list[tuple[Any, list[Any]]]:
return order
def _outcomes(order: list[tuple[Any, list[Any]]], halves: dict[int, list[_Half | None]], key: str, point: Any,
steps: bool, *, signals: bool = False) -> tuple[set[tuple[bool, ...]], bool]:
"""条件(各层见 _logic_order)在 key 取 point 和它两边时可能的真假组合:(比它小一点, point, 比它大一点)。steps
为真按整数看两边(point - 1、point + 1),否则按紧挨着的值。signals 为真时另交回条件里有没有哪一层、且 / 或连起来
的哪一两项单拿出来就只认 point(_isolated)。"""
def _outcomes(order: list[tuple[Any, list[Any]]], halves: dict[int, _Leaf], key: str, point: Any,
offsets: tuple[int, ...] | None, *, signals: bool = False) -> tuple[set[tuple[bool, ...]], bool]:
"""条件(各层见 _logic_order)在 key 取 point 和它两边时可能的真假组合。offsets 是整数上看的那几个偏移
(_window),为 None 时按紧挨着的值看 (比它小一点, point, 比它大一点)。signals 为真时另交回条件里有没有哪一层、
且 / 或连起来的哪一两项单拿出来就只认 point(_isolated,只按紧挨着的值看)。"""
from sqlglot import exp
either = _either(3 if offsets is None else len(offsets))
done: dict[int, set[tuple[bool, ...]]] = {}
found = False
for node, kids in order:
@@ -1690,38 +1918,48 @@ def _outcomes(order: list[tuple[Any, list[Any]]], halves: dict[int, list[_Half |
out = _joined(join, out, part)
if signals and not found and len(parts) > 2:
# 一串里随这一列取值变真假的项两两细看(两项的串就是它自己,下面照常看)
varying = [part for part in parts if not part <= _EITHER]
varying = [part for part in parts if not part <= either]
found = len(varying) > PAIR_MAX or any(
_ISOLATED & _joined(join, s, t) for s, t in itertools.combinations(varying, 2))
else:
out = _leaf_outcomes(halves.get(id(node)), key, point, steps)
out = _leaf_outcomes(halves.get(id(node)), key, point, offsets)
found = found or (signals and bool(_ISOLATED & out))
done[id(node)] = out
return out, found
def _either(width: int) -> frozenset[tuple[bool, ...]]:
"""和这一列无关的部分在 width 个取值上的真假:全真或全假(_EITHER 是三个取值的)。"""
return _EITHER if width == 3 else frozenset({(False,) * width, (True,) * width})
def _joined(join: Callable[[bool, bool], bool], left: set[tuple[bool, ...]], right: set[tuple[bool, ...]]
) -> set[tuple[bool, ...]]:
return {tuple(join(x, y) for x, y in zip(s, t)) for s in left for t in right}
def _leaf_outcomes(parts: list[_Half | None] | None, key: str, point: Any, steps: bool) -> set[tuple[bool, ...]]:
"""一个比较(几半的「且」)可能的真假组合;不是范围比较的(别的条件)当成不知道真假。"""
if parts is None:
return set(_EITHER)
out: set[tuple[bool, ...]] = {(True, True, True)}
def _leaf_outcomes(leaf: _Leaf | None, key: str, point: Any, offsets: tuple[int, ...] | None
) -> set[tuple[bool, ...]]:
"""一个比较(几半的「且」,IN 是几半的「或」)可能的真假组合;别的条件当成不知道真假。不等、IN 只在整数上细看
时算(offsets 不为 None),按紧挨着的值看时也当成不知道真假,见 _isolated。"""
either = _either(3 if offsets is None else len(offsets))
if leaf is None or (leaf[2] and offsets is None):
return set(either)
parts, any_, _ = leaf
out: set[tuple[bool, ...]] = {(not any_,) * len(next(iter(either)))}
join = operator.or_ if any_ else operator.and_
for half in parts:
cell = {_truth(half, point, steps)} if half is not None and half[1] == key and (
isinstance(half[2], Decimal) == isinstance(point, Decimal)) else _EITHER
out = {tuple(x and y for x, y in zip(s, t)) for s in out for t in cell}
cell = {_truth(half, point, offsets)} if half is not None and half[1] == key and (
isinstance(half[2], Decimal) == isinstance(point, Decimal)) else either
out = _joined(join, out, cell)
return out
def _truth(half: _Half, point: Any, steps: bool) -> tuple[bool, ...]:
"""一半范围比较在 point 和它两边的真假(steps 同 _outcomes)。"""
def _truth(half: _Half, point: Any, offsets: tuple[int, ...] | None) -> tuple[bool, ...]:
"""一半比较在 point 和它两边的真假(offsets 同 _outcomes)。"""
_, _, bound, op = half
if steps:
signs = [_sign(point + step, bound) for step in (-1, 0, 1)]
if offsets is not None:
signs = [_sign(point + step, bound) for step in offsets]
else:
at = _sign(point, bound)
signs = [at or -1, at, at or 1]
@@ -1739,20 +1977,21 @@ def _point_text(point: Any) -> str:
return str(point)
#: SQL 里一个字面量用在哪:(用法, 被比较的列名, 夹住的值),见 _literal_uses
_Use = tuple[str, str | None, tuple[str, ...]]
#: SQL 里一个字面量用在哪:(用法, 被比较的一边取自哪些列, 认它时一并看的值),见 _literal_uses
_Use = tuple[str, frozenset[str], tuple[str, ...]]
def _literal_uses(sql: str, kind: str | None) -> dict[int, _Use] | None:
"""SQL 里每个字面量(按它在原文里的起始位置)用在哪,按语法树认(写法怎么变都认得出):
- ("equal", 列名, 夹住的值):在找某一个值——等值、不等、IN(含 VALUES、行值)、LIKE 一类、IS [NOT] DISTINCT FROM、
简单 CASE 的 WHEN、= ANY(ARRAY[…]),套了括号、负号、CAST 的照认;同一个条件里把某一个值夹住的范围(_pinned)
也算,夹住的值记在第三项(换不换按它认:整数的 age > 24 AND age < 26 找的是 25),别的写法第三项为空。列名是
被比较的那一列(小写、不带表名),另一边不是单独一列时为 None;
- ("range", 列名, ()):范围比较(> >= < <=、BETWEEN)的边界,是 agent 定的阈值;
- ("limit", None, ()):LIMIT / OFFSET / FETCH / TOP 的行数;
- ("other", None, ()):别的——选出列、函数参数、算式、THEN 后面的值、子查询里选出的值。
解析不了返回 None。"""
- ("equal", 列, 值):在找某一个值——等值、不等、IN(含 VALUES、行值)、LIKE 一类、IS [NOT] DISTINCT FROM、
简单 CASE 的 WHEN、= ANY(ARRAY[…]),套了括号、负号、CAST 的照认;把某一个值夹住的范围(_pinned)也算,夹住的
值记在第三项(换不换按它认:整数的 age > 24 AND age < 26 找的是 25);
- ("range", 列, 值):范围比较(> >= < <=、BETWEEN)的边界,是 agent 定的阈值;
- ("limit", 空, 值):LIMIT / OFFSET / FETCH / TOP 的行数;
- ("other", 空, 值):别的——选出列、函数参数、和列一起算的算式、THEN 后面的值、子查询里选出的值。
列是被比较的一边取自哪些列(小写、不带表名,_columns_of:age、CAST(age AS INTEGER)、MAX(age) 都是 age)。
字面量是几个写死的数算出来的算式的一截(13900000000 + 1)时,算出来的值也记在第三项:整个算式按它认,用法看
算式摆在哪。解析不了返回 None。"""
from app.data.sql_semantics import _parse, _read
try:
@@ -1765,14 +2004,18 @@ def _literal_uses(sql: str, kind: str | None) -> dict[int, _Use] | None:
offset = len(sql) - len(sql.lstrip())
uses: dict[int, _Use] = {}
try:
literals = {id(n): n for n in root.walk() if isinstance(n, _literal_types())}
pinned = _pinned(root)
for key, node in literals.items():
start = (node.meta or {}).get("start")
if isinstance(start, int):
use, column = _use_of(node)
points = pinned.get(key) if use == "range" else None
uses[start + offset] = ("equal", column, points) if points else (use, column, ())
values = _constants(root)
lifts = _lifts(root, values)
pinned = _pinned(root, values)
for node in root.walk():
start = (node.meta or {}).get("start") if isinstance(node, _literal_types()) else None
if not isinstance(start, int):
continue
use, columns, computed = _use_of(node, values, lifts)
extra = (_point_text(computed),) if computed is not None else ()
points = pinned.get(id(node))
uses[start + offset] = ("equal", columns, tuple(dict.fromkeys((*points, *extra)))) if points \
else (use, columns, extra)
except Exception: # noqa: BLE001 - 分析本身的缺陷不能让回答出错:按解析不了处理(认不准的地方不免)
logger.warning("质询的 SQL 字面量分析失败", exc_info=True)
return None
@@ -1808,8 +2051,9 @@ def _literals(sql: str, kind: str | None, secrets: _Secrets, compared: set[str]
不了时只按词认简单的等值比较(_context),别的一律 "other"——认不准的地方不免。分词出错返回 None。
照原样留着的数还得在这条 SQL 里没有别处被换掉:同一个数在别的字面量里(等值比较、算式、字符串、夹住的范围)
确实被换掉了,这里留着就把它露了(BETWEEN 13900000001 AND 13900000001 + 0、age >= 30 AND age = 30),所以这里改记
成在找这个值,再照等值的办法换:阈值也和遮罩列比较的(短的数也换)、数够长的换;别的列上的短阈值、LIMIT 的行数
确实被换掉了,这里留着就把它露了(buyer_phone >= 13900000001 AND buyer_phone - 13900000001 < 5、age >= 30 AND
age = 30;边界是算式的,算出来的值被换掉了也算:amount >= 15000 + 15000 OR amount = 30000),所以这里改记成在找
这个值,再照等值的办法换:阈值也和遮罩列比较的(短的数也换)、数够长的换;别的列上的短阈值、LIMIT 的行数
(age = 30 AND visits >= 30、LIMIT 30)换不掉,照原样——短的数本来就不按值遮(SECRET_DIGITS),撞上同一个数只是
巧合,一律换会把阈值、行数改得面目全非(见模块说明的已知局限)。
换没换照 _swap_literal 认,和真去换的是同一套判断:只是写在日期、天数修饰、标签里('2026-01-31'、'-30 days'、
@@ -1825,15 +2069,17 @@ def _literals(sql: str, kind: str | None, secrets: _Secrets, compared: set[str]
if not _is_literal(kinds[i]):
continue
if uses is not None:
found[i] = uses.get(token.start, ("other", None, ()))
found[i] = uses.get(token.start, ("other", frozenset(), ()))
else:
use, column = _context(tokens, kinds, i)
found[i] = (use, column, ()) if use == "equal" else ("other", None, ())
found[i] = (use, frozenset({column} if column else ()), ()) if use == "equal" \
else ("other", frozenset(), ())
kept = {i for i, (use, _, _) in found.items() if kinds[i] == "NUMBER" and use in _KEPT}
swapped = _swapped(sql, tokens, kinds, found, kept, secrets, compared)
for i in kept:
if _digits_of(tokens[i].text) in swapped:
found[i] = ("equal", found[i][1], ())
_, columns, points = found[i]
if any(_digits_of(v) in swapped for v in (tokens[i].text, *points)):
found[i] = ("equal", columns, points)
return tokens, kinds, found
@@ -1853,17 +2099,20 @@ def _swap_literal(raw: str, text: str, kind: str, use: _Use, secrets: _Secrets,
"""SQL 里一个字面量(原文 raw、分出来的字 text、词的种类 kind)换成什么样,和换掉了哪些遮罩数(去掉千分位、正负号)。
_scrub_sql 照它换,_literals 照它认别处确实换掉的数,两处是同一套判断:
- 在找某一个值的(equal):和遮罩列(或它的别名,compared)比较的,是遮罩列里出现过的值就整个换,短的数也换
(age = 25);和别的列比较的,够长的遮罩值也换(另一张表里没设遮罩的同一个手机号)。夹住某一个值的范围按夹住
的值认(age > 24 AND age < 26 的两头都按 25 认),同一个数在别处换掉了的阈值、行数也按等值认(见 _literals);
- 在找某一个值的(equal):和遮罩列(或它的别名,compared;套了 CAST、汇总的也算,见 _columns_of)比较的,是遮罩
列里出现过的值就整个换,短的数也换(age = 25);和别的列比较的,够长的遮罩值也换(另一张表里没设遮罩的同一个
手机号)。数换个写法照样认(25.0、025、2.5e1 按 25 认,_canonical);夹住某一个值的范围按夹住的值认(age > 24
AND age < 26 的两头都按 25 认),算式里的数按算出来的值认,同一个数在别处换掉了的阈值、行数也按等值认(见
_literals);
- 字符串在哪都按值换里面的遮罩值(范围边界也一样:写成字符串的长号码做边界,多半是在找那一个人);
- 别的数:范围比较的边界、LIMIT / OFFSET 的行数照原样(agent 自己定的阈值,换了封存的就不是实际执行的语句);
别处(选出列、函数参数、算式)认不准是不是在找某一个值,够长的遮罩值整个换。"""
how, column, points = use
别处(选出列、函数参数、算式)认不准是不是在找某一个值,够长的遮罩值整个换(算出来的值是也换)。"""
how, columns, points = use
word = _masked_word()
canonical = _canonical(text)
looked = [text, *([canonical] if canonical not in (None, text) else []), *points]
if how == "equal":
looked = [text, *points]
if any((column in compared and secrets.weak_hit(v)) or secrets.hit(v) for v in looked):
if any((columns & compared and secrets.weak_hit(v)) or secrets.hit(v) for v in looked):
return _requote(raw, lambda _: word), _number_forms(looked)
if kind != "NUMBER":
found: list[str] = []
@@ -1876,8 +2125,8 @@ def _swap_literal(raw: str, text: str, kind: str, use: _Use, secrets: _Secrets,
return pattern.sub(word, inner)
return _requote(raw, scrub), _number_forms(found)
if how not in _KEPT and secrets.hit(text):
return word, _number_forms([text])
if how not in _KEPT and any(secrets.hit(v) for v in looked):
return word, _number_forms(looked)
return raw, set()
@@ -1950,9 +2199,9 @@ def _scrub_excerpt(text: str, whole: set[str] | None, secrets: _Secrets, kind: s
"""SQL 检查结果的片段:检查器按语法树重新写出的一段表达式(最长 160 字,再长截断加「…」),单独拿出来认不出在找
哪一个值、哪个数是阈值,照整条 SQL 的认法换:整条 SQL 里整个换掉了的字面量(whole),片段里写着同样的字面量也换
(同一个值在片段里别处出现也换,宁可多换);整条 SQL 里照原样留着的阈值,片段里也照原样;字符串、名字、注释按值
换(同 _scrub_sql)。截断处可能正好截在一个值中间('1390000…),那半截按值认不出,去掉。"""
换(同 _scrub_sql)。截断处可能正好截在一个值中间('1390000…),那半截按值认不出,去掉(_cut_tail)。"""
cut = text.endswith("…")
body = re.sub(r"\S*$", "", text[:-1]) if cut else text
body = _cut_tail(text[:-1]) if cut else text
split = _sql_tokens(body, kind) if whole is not None and body.strip() else None
word = _masked_word()
if split is None:
@@ -1972,14 +2221,47 @@ def _scrub_excerpt(text: str, whole: set[str] | None, secrets: _Secrets, kind: s
return out + "…" if cut else out
def _cut_tail(body: str) -> str:
"""截断的片段去掉截断处半截的值:停在没闭合的字符串、带引号的名字、注释里的,从开头的引号、注释起整个去掉(地址
这类遮罩值中间有空格,只去掉最后一段还会留下前半截),紧贴在引号前的前缀(N'、E'、U&')一起去掉;停在不带引号
的词中间的,去掉那半个词。"""
opened = _unclosed(body)
if opened is not None:
return re.sub(r"[0-9A-Za-z_&]*$", "", body[:opened])
return re.sub(r"\S*$", "", body)
def _unclosed(text: str) -> int | None:
"""text 停在没闭合的字符串、带引号的名字('…、"…、`…)或注释(/* …、-- …)里时,交回它开头的位置;不然为 None。
引号里两个引号连写是引号本身。"""
i = 0
while i < len(text):
if text.startswith(("/*", "--"), i):
end = text.find("*/" if text[i] == "/" else "\n", i + 2)
if end < 0:
return i
i = end + 2 if text[i] == "/" else end + 1
elif (quote := text[i]) in "'\"`":
end = i + 1
while (end := text.find(quote, end)) >= 0 and text.startswith(quote * 2, end):
end += 2
if end < 0:
return i
i = end + 1
else:
i += 1
return None
#: SQL 检查结果的说明里照引的取值:「…」括起来
_QUOTED = re.compile(r"「([^「」]*)」")
def _scrub_message(text: str, whole: set[str] | None, secrets: _Secrets) -> str:
"""SQL 检查结果的说明:照引 SQL 里写的取值(码值表已列全时「没有码值「3」」),整条 SQL 里整个换掉了的(whole),
这里一样换成「已遮罩」,负数照留负号(同 SQL 里);别的字按值换。码值表里列出的码值是数据目录写明的,不在引号
里,照原样。"""
这里一样换成「已遮罩」,负数照留负号(同 SQL 里);别的字按值换。码值表里列出的码值(「已列出的全部码值:
线上直销=…」)是数据目录写明的,不从 SQL 摘,也不另外免遮:和别的字一样按值换,恰好是按值认得出的遮罩值的码值
(两个字以上的文字、够长的数)换成「已遮罩」,多遮,不漏;短的码值照原样。"""
word = _masked_word()
def quoted(m: re.Match[str]) -> str:
@@ -1995,8 +2277,9 @@ def _scrub_message(text: str, whole: set[str] | None, secrets: _Secrets) -> str:
def _shown_literals(sqls: Iterable[tuple[str, str | None, set[str]]], secrets: _Secrets) -> set[str]:
"""几条 SQL(各自数据源的类型、SQL 里认哪些名字是遮罩列,见 _compared)里照原样留着的数,去掉千分位、正负号:
范围比较的边界、LIMIT / OFFSET 的行数(_scrub_sql 不换的那两种)。它们随 SQL 摆在读者眼前,回答正文里复述(「金额
不低于 30000 元」)再遮也遮不住什么,只会让回答和 SQL 对不上(_Secrets.show)。同一个数在别的查询里确实被换掉了
范围比较的边界、LIMIT / OFFSET 的行数(_scrub_sql 不换的那两种;边界是算式的,算出来的值也算:20000 * 1.5 就是
30000)。它们随 SQL 摆在读者眼前,回答正文里复述(「金额不低于 30000 元」)再遮也遮不住什么,只会让回答和 SQL
对不上(_Secrets.show)。同一个数在别的查询里确实被换掉了
也不算(同一条里的 _literals 已经不留):正文写出来就把那里换掉的值露了;只是写在日期、批次号、标签里没换的不牵连。
分词都分不了的整条按值换(同 _scrub_sql),换掉的数也算。"""
kept: set[str] = set()
@@ -2011,7 +2294,7 @@ def _shown_literals(sqls: Iterable[tuple[str, str | None, set[str]]], secrets: _
continue
tokens, kinds, uses = split
keep = {i for i, (use, _, _) in uses.items() if kinds[i] == "NUMBER" and use in _KEPT}
kept |= {_digits_of(tokens[i].text) for i in keep}
kept |= {_digits_of(v) for i in keep for v in (tokens[i].text, *uses[i][2])}
swapped |= _swapped(sql, tokens, kinds, uses, keep, secrets, compared)
return kept - swapped
@@ -2073,11 +2356,11 @@ def _settle_plan(own: dict[str, dict[str, Any]], named: dict[str, tuple[set[str]
for artifact, snapshot in own.items():
kind, base = kinds.get(str(snapshot.get("source") or "")), named[artifact][0]
hidden[artifact], tainted[artifact] = _derived_hidden(snapshot, base, kind)
secrets.add(snapshot, hidden[artifact], labels=_written_labels(snapshot.get("sql"), kind), base=base)
secrets.add(snapshot, hidden[artifact], labels=_snapshot_labels(snapshot, kind), base=base)
for snapshot, base in based:
kind = kinds.get(str(snapshot.get("source") or ""))
secrets.add(snapshot, _derived_hidden(snapshot, base, kind)[0],
labels=_written_labels(snapshot.get("sql"), kind), base=base)
secrets.add(snapshot, _derived_hidden(snapshot, base, kind)[0], labels=_snapshot_labels(snapshot, kind),
base=base)
secrets.protect(given)
secrets.show(_shown_literals([(sql, kinds.get(name), _compared(tainted, artifact, live, name))
for sql, name, artifact in shown], secrets))
@@ -2091,6 +2374,14 @@ def _settle_plan(own: dict[str, dict[str, Any]], named: dict[str, tuple[set[str]
gone={artifact: named[artifact][1] for artifact in own})
def _snapshot_labels(snapshot: dict[str, Any], kind: str | None) -> dict[str, set[str]]:
"""一份快照里只装着分档标签的列(_label_columns)。没有 SQL 的(合并查询)一个都不算。"""
sql = snapshot.get("sql")
if not isinstance(sql, str) or not sql.strip():
return {}
return _label_columns(sql, kind, [str(c) for c in snapshot.get("columns") or []])
def _compared(tainted: dict[str, set[str]], artifact: str, live: dict[str, list[str]], name: str) -> set[str]:
"""一条查询的 SQL 里认哪些名字是遮罩列(和它们做等值比较的,短的数也换):快照认出的(_derived_hidden 的名字,
含改了名字的);快照没取到的(存不下、被删了),按这个源现在设的遮罩列认。"""
+108 -5
View File
@@ -13,11 +13,13 @@
- 回答:先给结论,数字只能引用自己查询的格子或报告已有的指标,裸数字被标出(提问人自己写的数不标);遮罩列(起了
别名的、和 * 写在一起没起别名的表达式也算)的格子渲染成「已遮罩」,回答正文、查询清单、事件、质询工件里都不出现
遮罩列的值;换着写法找某一个客户(简单 CASE、IN (VALUES …)、两头相同的范围、行值、选出列里写着同一个号码,两半
拆到 ON 和 WHERE、WHERE 和 HAVING、INTERSECT 两支、CTE 里……)也一样;遮罩值不误伤阈值、天数、LIMIT(长的数也
一样)、提问人自己写的数、报告口径卡里的指标,分档共用的边界、和日期、天数修饰、标签里写着的同一个数也不牵连阈值,
分档的文字标签不当遮罩值(当分组键的档位列照常显示);SQL 里照原样留着的阈值只在回答正文里不遮,格子照旧按值遮,
回答和质询工件对得上;质询工件按值遮只出现在报告快照里的遮罩值;一格文字里夹着遮罩值时回答整份不交,质询工件里
只换掉那一截;质询工件里 SQL 检查结果的片段、说明照整条 SQL 换(短的数也换),给模型的那句改法不交出去
拆到 ON 和 WHERE、WHERE 和 HAVING、INTERSECT 两支、CTE 里,边界、等值写成算式,两头不是整数,范围再排除一头,
号码前面补零……)也一样,年龄这类短的值换着写法找(套了 CAST、汇总值两头相同、补零……)SQL 里也遮;遮罩值不误伤
阈值、天数、LIMIT(长的数也一样)、提问人自己写的数、报告口径卡里的指标,分档共用的边界、和日期、天数修饰、标签里
写着的同一个数也不牵连阈值,分档的文字标签不当遮罩值(当分组键的档位列照常显示),改了名字的遮罩列撞上标签照样遮;
SQL 里照原样留着的阈值只在回答正文里不遮,格子照旧按值遮,回答和质询工件对得上;质询工件按值遮只出现在报告快照里
的遮罩值;一格文字里夹着遮罩值时回答整份不交,质询工件里只换掉那一截;质询工件里 SQL 检查结果的片段、说明照整条
SQL 换(短的数也换),给模型的那句改法不交出去
- 接线:设置里的上限、运行固定的数据版本交到 agent 手里(上传表格重传之后查的仍是运行固定的那一版);同名重建的
数据源、没固定过版本的上传表格不查
- 触顶、失败照样记一条事件,说法给提问人看得懂、说下一步;轮数用完不算查询次数到顶,查询失败、引用解析不了在末尾注明
@@ -1033,6 +1035,95 @@ async def test_a_threshold_matching_numbers_left_alone_elsewhere_is_sealed_as_wr
await engines.invalidate(source_id)
@pytest.mark.parametrize("sql, sealed", [
pytest.param("SELECT COUNT(*) AS 人数 FROM sales WHERE CAST(age AS INTEGER) = 25",
"SELECT COUNT(*) AS 人数 FROM sales WHERE CAST(age AS INTEGER) = 已遮罩", id="套了 CAST"),
pytest.param("SELECT COUNT(*) AS 人数 FROM sales GROUP BY age HAVING MAX(age) BETWEEN 25 AND 25",
"SELECT COUNT(*) AS 人数 FROM sales GROUP BY age HAVING MAX(age) BETWEEN 已遮罩 AND 已遮罩",
id="汇总值两头相同"),
pytest.param("SELECT COUNT(*) AS 人数 FROM sales WHERE age = 25.0",
"SELECT COUNT(*) AS 人数 FROM sales WHERE age = 已遮罩", id="补零的写法"),
pytest.param("SELECT COUNT(*) AS 人数 FROM sales WHERE age > 24.5 AND age < 25.5",
"SELECT COUNT(*) AS 人数 FROM sales WHERE age > 已遮罩 AND age < 已遮罩", id="两头不是整数"),
pytest.param("SELECT COUNT(*) AS 人数 FROM sales WHERE age >= 25 AND age <= 26 AND age <> 26",
"SELECT COUNT(*) AS 人数 FROM sales WHERE age >= 已遮罩 AND age <= 26 AND age <> 已遮罩",
id="范围再排除一头"),
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="算出来的边界"),
])
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 都换掉这个年龄,三处一字不差。"""
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}",
args={"sql": "SELECT region AS 区域, age, amount AS 金额 FROM sales ORDER BY id"}),
node("write", "report", "写周报", instructions="写周报"),
node("gate", "human", mode="approve", title="周报可以出具吗"),
node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]))
plays = {"这个年龄": Play([sql], "这个年龄的有 [[v:Q1.r0.人数]] 人。[[see:Q1]]")}
Models(monkeypatch, name, writer="华东首单金额 [[v:Q1.r0.金额]]。[[see:Q1]]", plays=plays)
try:
paused = await pause(name, graph=graph)
[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, "这个年龄的有 1 人。")
[query] = item["queries"]
assert query["sql"] == sealed
[event] = await events(paused.id, "approval.inquiry")
assert event.data["queries"][0]["sql"] == sealed
got = await client.get(f"/api/runs/{paused.id}/inquiries/artifacts/{query['artifact']}")
assert got.status_code == 200 and got.json()["content"]["sql"] == sealed, "质询工件的 SQL 和事件里的一字不差"
finally:
await engines.invalidate(source_id)
#: 会员等级设了遮罩:等级是 1、2、3 这样的短码值,正好撞上按条件数个数写的 THEN 1 ELSE 0
LEVELS = ("CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, vip_level INTEGER, amount INTEGER);"
"INSERT INTO sales VALUES (1, '华东', 1, 1200), (2, '华东', 2, 300), (3, '华南', 3, 900),"
" (4, '华北', 1, 1500);")
async def test_a_renamed_masked_column_keeps_values_that_match_labels_written_elsewhere(monkeypatch, tmp_path, client):
"""第 2 轮复核:先按等级数大单(vip_level AS 等级,SUM(CASE … THEN 1 ELSE 0 END)),再找等级为 1、2 的。以前分档标签
1、0 不收进遮罩值,改了名字的等级列也跟着不收,WHERE vip_level = 1 照原样封存,= 2 却遮了。现在只有只装着标签的
列不收,两条都遮;按等级数大单的那份快照里,等级整列遮,大单数照常显示。"""
name, source_id = await _source(tmp_path, options={"mask_columns": "vip_level"}, script=LEVELS)
by_level = ("SELECT vip_level AS 等级, SUM(CASE WHEN amount > 1000 THEN 1 ELSE 0 END) AS 大单数 FROM sales "
"GROUP BY vip_level ORDER BY vip_level")
first, second = (f"SELECT COUNT(*) AS 人数 FROM sales WHERE vip_level = {level}" for level in (1, 2))
plays = {"各等级": Play([by_level, first, second],
"大单最多的等级有 [[v:Q1.r0.大单数]] 笔大单;问到的两个等级各有 [[v:Q2.r0.人数]] 人、"
"[[v:Q3.r0.人数]] 人。[[see:Q1]]")}
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 笔大单;问到的两个等级各有 2 人、1 人。")
sealed = [by_level, *["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 got.json()["content"]["rows"] == [["已遮罩", 2], ["已遮罩", 0], ["已遮罩", 0]]
assert got.json()["masked"] == ["等级"]
finally:
await engines.invalidate(source_id)
STAR_TAIL = "SELECT *, SUBSTR(buyer_phone, 4) FROM sales ORDER BY amount DESC LIMIT 1"
@@ -1431,6 +1522,18 @@ _COUNT = "SELECT COUNT(*) AS 笔数 FROM sales WHERE "
# 整数上相邻的两头把中间那一个隔开
pytest.param(PHONES, _COUNT + "NOT (buyer_phone <= 13900000000 OR buyer_phone >= 13900000002)",
_COUNT + "NOT (buyer_phone <= 已遮罩 OR buyer_phone >= 已遮罩)", id="相邻的整数隔开"),
# 第 2 轮复核:一头是算式、等值的值是算式、两头不是整数、范围再用不等去掉一头、前面补零的号码。以前都照原样
# 封存,后两种的号码还被当成阈值,回答正文跟着不遮
pytest.param(PHONES, _COUNT + "buyer_phone BETWEEN 13900000000 + 1 AND 13900000009 - 8",
_COUNT + "buyer_phone BETWEEN 已遮罩 + 已遮罩 AND 已遮罩 - 已遮罩", id="算出来的边界"),
pytest.param(PHONES, _COUNT + "buyer_phone = 13900000000 + 1", _COUNT + "buyer_phone = 已遮罩 + 已遮罩",
id="算出来的等值"),
pytest.param(PHONES, _COUNT + "buyer_phone > 13900000000.5 AND buyer_phone < 13900000001.5",
_COUNT + "buyer_phone > 已遮罩 AND buyer_phone < 已遮罩", id="两头不是整数"),
pytest.param(PHONES, _COUNT + "buyer_phone >= 13900000001 AND buyer_phone <= 13900000002 "
"AND buyer_phone <> 13900000002",
_COUNT + "buyer_phone >= 已遮罩 AND buyer_phone <= 已遮罩 AND buyer_phone <> 已遮罩", id="范围再排除一头"),
pytest.param(PHONES, _COUNT + "buyer_phone = 013900000001", _COUNT + "buyer_phone = 已遮罩", id="前面补零的号码"),
])
async def test_a_customer_looked_up_in_any_shape_never_shows(monkeypatch, tmp_path, client, script, sql, sealed):
"""agent 换着写法找某一个客户(简单 CASE、IN (VALUES …)、两头相同的范围、行值、选出列里写着同一个号码……),回答里
+195 -11
View File
@@ -8,23 +8,28 @@
格子里的同一个数照旧换
- SQL 里数字照原样留着的只有范围比较的边界(没把某一个值夹住的)和 LIMIT / OFFSET 的行数;在找某一个值的(等值、IN、
LIKE、简单 CASE、IS [NOT] DISTINCT FROM,套了括号、写成行值、VALUES、ARRAY 的也算,还有 BETWEEN v AND v、
>= v AND <= v 这类夹住某一个值的范围)和遮罩列比较时短的数也换;选出列、函数参数、算式里够长的遮罩值也换;字符串
(范围边界也一样)、注释、名字按值换;SQL 解析不了时认不准哪个是阈值,一律不免
>= v AND <= v 这类夹住某一个值的范围)和遮罩列比较时短的数也换,被比较的一边套了 CAST、汇总也算;同一个数换个
写法(25.0、025、2.5e1)、写成几个数的算式(25 + 0、13900000000 + 1)照样认,算式里的数都换;选出列、函数参数、
算式里够长的遮罩值也换;字符串(范围边界也一样)、注释、名字按值换;SQL 解析不了时认不准哪个是阈值,一律不免
- 「夹住某一个值」在整条 SQL 里认,只有分档的几种写法各算各的:CASE 的不同分支、不同的选出列(连同 GROUP BY、
ORDER BY)、UNION 的不同支,共用的边界是阈值;同一行集上的条件(ON 和 WHERE、WHERE 和 HAVING、INTERSECT 两支、
包在 CASE / 函数 / 算式里的一半、子查询、CTE)照样认得出在找哪一个值。同一个条件里按合起来的真假认(旁边的别的
条件当成不知道真假),BETWEEN v AND v、>= v AND <= v 这类本身就只认一个值的,条件里哪一层都算;整数上 <= v、
>= v 也隔开了相邻的整数;列名认不出的只和写得一样的表达式配
>= v 也隔开了相邻的整数,两头不是整数的(age > 24.5 AND age < 25.5)也夹住中间那一个整数;范围再用不等、NOT IN
去掉一头的(age >= 25 AND age <= 26 AND age <> 26)也认,不等、IN 只在同一个条件里和范围合起来看;列名认不出的
只和写得一样的表达式配
- 阈值只在同一个数在这条 SQL 别处确实被换掉时才不留(换不换和 SQL 里换字面量是同一套判断):日期、天数、标签里写着
同一个短的数,没设遮罩的列上的等值,都不牵连阈值;不留的照等值的办法换,别的列上的短阈值、LIMIT 撞上别处换掉的
短的数照原样(已知局限)
- 按遮罩列分档的标签(CASE、IIF 的结果都是写死的常量)是 agent 自己写在 SQL 里的,不当遮罩值:SQL、回答正文里照原样;
当分组键的(每一档一行)不算派生值,格子照常显示,逐行的标签列照旧整列遮
当分组键的(每一档一行)不算派生值,格子照常显示,逐行的标签列照旧整列遮。只有只装着标签的列不收(经别名、子查询
传下去、没起别名的也算),改了名字的遮罩列撞上同一条 SQL 里的 THEN 1 ELSE 0 照收
- 快照按列遮,另按值遮:别的列里写着遮罩值的格子也换成「已遮罩」;列名里写着遮罩值的,列名也换;质询工件里,
没设遮罩的列里一格文字夹着的遮罩值按子串换(回答一路照旧整份不交),SQL 检查结果的片段、说明照整条 SQL 换
没设遮罩的列里一格文字夹着的遮罩值按子串换(回答一路照旧整份不交),SQL 检查结果的片段、说明照整条 SQL 换(截断的
片段从没闭合的引号、注释起去掉,说明里的码值表和别的字一样按值换)
- 组装回答之后还认得出遮罩值的(正文里复述的阈值除外),不交出去
- 遮罩值上万个时换一遍也不卡:不现编上万个分支的正则;上千个条件连成一长串照样按结构认,成百个分档边界看不过来的
按夹住算,也不卡
- 遮罩值上万个时换一遍也不卡:不现编上万个分支的正则;上千个条件连成一长串、上千项的连加照样按结构认,成百个分档
边界看不过来的按夹住算,也不卡
数据都是虚构的:手机号、姓名、年龄、区域、金额。
"""
@@ -232,6 +237,47 @@ def test_a_value_of_the_masked_column_itself_stays_masked_even_if_written_as_a_l
assert plan.secrets.scrub("VIP会员、铂金会员、普通会员") == "已遮罩、已遮罩、普通会员"
#: 按会员等级数大单:等级设了遮罩、改了名字,汇总里按条件数个数写着 THEN 1 ELSE 0
LEVEL_COUNTS = ("SELECT vip_level AS 等级, SUM(CASE WHEN amount > 1000 THEN 1 ELSE 0 END) AS 大单数 FROM sales "
"GROUP BY vip_level")
def test_a_renamed_masked_column_keeps_values_that_match_labels_written_elsewhere():
"""第 2 轮复核:分档标签不收进遮罩值,以前对改了名字的遮罩列也一样——vip_level AS 等级 的 1、2、3 撞上同一条 SQL
里按条件数个数的 THEN 1 ELSE 0,1 就不算遮罩值,之后 WHERE vip_level = 1 照原样封存。现在只跳过只装着标签的
那几列的格子,改了名字的遮罩列照收。"""
snapshot = {"source": "shop", "sql": LEVEL_COUNTS, "columns": ["等级", "大单数"],
"rows": [[1, 2], [2, 0], [3, 0]]}
plan = plan_of(snapshot, {"vip_level"})
assert plan.views["a1"]["rows"] == [[MASKED, 2], [MASKED, 0], [MASKED, 0]] and plan.masked["a1"] == ["等级"]
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 = 已遮罩")
# 同一个名字既装着标签又装着遮罩列的值(UNION 两支):不算只装着标签的列,照收
mixed = {"source": "shop", "sql": "SELECT vip_level AS 等级 FROM sales UNION ALL "
"SELECT CASE WHEN amount > 1000 THEN 1 ELSE 0 END FROM sales",
"columns": ["等级"], "rows": [[1], [3], [0]]}
assert {"0", "1", "3"} <= plan_of(mixed, {"vip_level"}).secrets.weak
@pytest.mark.parametrize("sql, columns, rows", [
# 标签列经子查询、CTE 原样传下去(外层再分组也一样:每一行的档位照旧算派生值,见上)
(f"SELECT 档位, COUNT(*) AS 笔数 FROM (SELECT {BANDS} AS 档位 FROM sales) t GROUP BY 档位",
["档位", "笔数"], [["大单", 2], ["小单", 3]]),
(f"WITH t AS (SELECT {BANDS} AS 档位 FROM sales) SELECT * FROM t", ["档位"], [["小单"], ["大单"]]),
# 没起别名的标签列:按位置认
(f"SELECT id, {BANDS} FROM sales", ["id", BANDS], [[1, "小单"], [2, "大单"]]),
])
def test_labels_passed_on_under_their_own_name_are_still_not_masked_values(sql, columns, rows):
"""只装着标签的列经别名、子查询、CTE 原样传下去,或者没起别名:照旧整列遮(逐行的档位),标签文字照旧不当遮罩值。"""
plan = plan_of({"source": "shop", "sql": sql, "columns": columns, "rows": rows}, {"amount"})
label = columns.index("档位") if "档位" in columns else 1
assert all(row[label] == MASKED for row in plan.views["a1"]["rows"])
assert plan.secrets.scrub("小单 3 笔、大单 2 笔") == "小单 3 笔、大单 2 笔"
# --------------------------------------------------------------------------
# 按值认:门槛、边界
# --------------------------------------------------------------------------
@@ -315,6 +361,9 @@ def people() -> inquiry_mod._Secrets:
# 不等、SQLite 的 IS、NOT LIKE 也是在找某一个值
("SELECT COUNT(*) FROM sales WHERE age != 25 AND age IS NOT 31 AND buyer_phone NOT LIKE '13900000001'",
"SELECT COUNT(*) FROM sales WHERE age != 已遮罩 AND age IS NOT 已遮罩 AND buyer_phone NOT LIKE '已遮罩'"),
# IN 列表里写着遮罩值,换的是它;旁边的范围边界合起来不只认一个值(25、26 都要),照原样
("SELECT COUNT(*) FROM sales WHERE age > 24 AND age IN (25, 26)",
"SELECT COUNT(*) FROM sales WHERE age > 24 AND age IN (已遮罩, 26)"),
])
def test_sql_keeps_everything_but_the_masked_values(people, sql, shown):
assert inquiry_mod._scrub_sql(sql, people, {"buyer_phone", "age"}, "sqlite") == shown
@@ -407,12 +456,11 @@ def test_a_value_looked_up_in_any_shape_is_masked_in_the_sql(phones, kind, sql,
"SELECT COUNT(*) FROM sales WHERE buyer_phone >= '已遮罩' AND buyer_phone <= '已遮罩'"),
("SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN '13900000001' AND '13900000001'",
"SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN '已遮罩' AND '已遮罩'"),
# 一头是几个数算出来的(13900000002 - 1 就是 13900000001):照样夹住了
# 一头是几个数算出来的(13900000002 - 1 就是 13900000001):照样夹住了,算式里的数都换(留着哪个都可能算得出号码)
("SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN 13900000001 AND 13900000002 - 1",
"SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN 已遮罩 AND 已遮罩 - 1"),
# 同一个数在算式里被换掉了,边界上的也不留:留着就把算式里换掉的露了
"SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN 已遮罩 AND 已遮罩 - 已遮罩"),
("SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN 13900000001 AND 13900000001 + 0",
"SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN 已遮罩 AND 已遮罩 + 0"),
"SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN 已遮罩 AND 已遮罩 + 已遮罩"),
])
def test_a_range_that_pins_a_single_value_is_a_lookup(phones, sql, shown):
"""两头是同一个值的范围(BETWEEN v AND v、>= v AND <= v)、大于或小于同一个值、整数的半开区间,都只夹住了一个值,
@@ -420,6 +468,107 @@ def test_a_range_that_pins_a_single_value_is_a_lookup(phones, sql, shown):
assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, "sqlite") == shown
@pytest.mark.parametrize("sql, sealed", [
# 范围的一头是几个数算出来的:算式就是那个值,夹住了一样是在找它,算式里的数都换
("SELECT COUNT(*) FROM sales WHERE age >= 25 AND age <= 25 + 0",
"SELECT COUNT(*) FROM sales WHERE age >= 已遮罩 AND age <= 已遮罩 + 已遮罩"),
("SELECT COUNT(*) FROM sales WHERE age BETWEEN 20 + 5 AND 30 - 5",
"SELECT COUNT(*) FROM sales WHERE age BETWEEN 已遮罩 + 已遮罩 AND 已遮罩 - 已遮罩"),
("SELECT COUNT(*) FROM sales WHERE age >= 50 / 2 AND age <= (5 * 5)",
"SELECT COUNT(*) FROM sales WHERE age >= 已遮罩 / 已遮罩 AND age <= (已遮罩 * 已遮罩)"),
("SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN 13900000000 + 1 AND 13900000009 - 8",
"SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN 已遮罩 + 已遮罩 AND 已遮罩 - 已遮罩"),
# 等值、IN 里的值是算出来的:按算出来的值认
("SELECT COUNT(*) FROM sales WHERE age = 20 + 5 OR buyer_phone IN (13900000000 + 2, 7)",
"SELECT COUNT(*) FROM sales WHERE age = 已遮罩 + 已遮罩 OR buyer_phone IN (已遮罩 + 已遮罩, 7)"),
# 选出列里算出来的号码:同选出列里直接写着号码,够长的遮罩值换
("SELECT 13900000000 + 1 AS 号码, COUNT(*) AS 笔数 FROM sales",
"SELECT 已遮罩 + 已遮罩 AS 号码, COUNT(*) AS 笔数 FROM sales"),
])
def test_a_value_worked_out_from_several_numbers_is_read_as_that_value(phones, sql, sealed):
"""第 2 轮复核:范围的一头写成算式(25 + 0、13900000000 + 1),夹住的值照样认得出,可算式里的数照原样留着,
一算就是那个号码。算式按算出来的值认:夹住某一个值的、等值、IN 里的,算式里的数都换;正文里也不免遮。"""
assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, "sqlite") == sealed
assert inquiry_mod._shown_literals([(sql, "sqlite", {"buyer_phone", "age"})], phones) == set()
def test_a_threshold_worked_out_from_several_numbers_is_kept_like_one_number(amounts):
"""算出来的阈值(20000 * 1.5)和写成一个数的阈值一样:没夹住某一个值就照原样,正文里复述它(连同算出来的
30000)也不遮;算出来的值在别处确实被换掉了,算式也不留(同写成一个数的阈值)。"""
sql = "SELECT COUNT(*) FROM sales WHERE amount >= 20000 * 1.5 AND amount < 20000 * 2.5"
assert inquiry_mod._scrub_sql(sql, amounts, {"amount"}, "sqlite") == sql
assert inquiry_mod._shown_literals([(sql, "sqlite", {"amount"})], amounts) == {
"20000", "1.5", "2.5", "30000", "50000"}
swapped = "SELECT COUNT(*) FROM sales WHERE amount >= 15000 + 15000 OR amount = 30000"
assert inquiry_mod._scrub_sql(swapped, amounts, {"amount"}, "sqlite") == (
"SELECT COUNT(*) FROM sales WHERE amount >= 已遮罩 + 已遮罩 OR amount = 已遮罩")
assert inquiry_mod._shown_literals([(swapped, "sqlite", {"amount"})], amounts) == set()
@pytest.mark.parametrize("sql, sealed", [
# 遮罩列套了 CAST、汇总:被比较的一边取的还是它的值,短的数照样换
("SELECT COUNT(*) FROM sales WHERE CAST(age AS INTEGER) = 25",
"SELECT COUNT(*) FROM sales WHERE CAST(age AS INTEGER) = 已遮罩"),
("SELECT COUNT(*) FROM sales WHERE CAST(age AS INTEGER) BETWEEN 25 AND 25",
"SELECT COUNT(*) FROM sales WHERE CAST(age AS INTEGER) BETWEEN 已遮罩 AND 已遮罩"),
("SELECT region FROM sales GROUP BY region HAVING MAX(age) BETWEEN 25 AND 25",
"SELECT region FROM sales GROUP BY region HAVING MAX(age) BETWEEN 已遮罩 AND 已遮罩"),
# 同一个数换个写法(小数点后补零、前面补零、科学计数法)
("SELECT COUNT(*) FROM sales WHERE age = 25.0 OR age = 025 OR age IN (2.5e1)",
"SELECT COUNT(*) FROM sales WHERE age = 已遮罩 OR age = 已遮罩 OR age IN (已遮罩)"),
("SELECT COUNT(*) FROM sales WHERE buyer_phone = 013900000001 OR buyer_phone = 1.3900000001e10",
"SELECT COUNT(*) FROM sales WHERE buyer_phone = 已遮罩 OR buyer_phone = 已遮罩"),
# 两头不是整数的范围:整数上只夹住中间那一个
("SELECT COUNT(*) FROM sales WHERE age > 24.5 AND age < 25.5",
"SELECT COUNT(*) FROM sales WHERE age > 已遮罩 AND age < 已遮罩"),
("SELECT COUNT(*) FROM sales WHERE age BETWEEN 25 AND 25.9",
"SELECT COUNT(*) FROM sales WHERE age BETWEEN 已遮罩 AND 已遮罩"),
("SELECT COUNT(*) FROM sales WHERE buyer_phone > 13900000000.5 AND buyer_phone < 13900000001.5",
"SELECT COUNT(*) FROM sales WHERE buyer_phone > 已遮罩 AND buyer_phone < 已遮罩"),
# 范围再用不等、NOT IN 去掉一头:同一个条件里合起来只剩一个值
("SELECT COUNT(*) FROM sales WHERE age >= 25 AND age <= 26 AND age <> 26",
"SELECT COUNT(*) FROM sales WHERE age >= 已遮罩 AND age <= 26 AND age <> 已遮罩"),
("SELECT COUNT(*) FROM sales WHERE buyer_phone >= 13900000001 AND buyer_phone <= 13900000002 "
"AND buyer_phone <> 13900000002",
"SELECT COUNT(*) FROM sales WHERE buyer_phone >= 已遮罩 AND buyer_phone <= 已遮罩 AND buyer_phone <> 已遮罩"),
("SELECT COUNT(*) FROM sales WHERE age BETWEEN 25 AND 27 AND age NOT IN (26, 27)",
"SELECT COUNT(*) FROM sales WHERE age BETWEEN 已遮罩 AND 27 AND age NOT IN (已遮罩, 27)"),
])
def test_other_ways_of_looking_up_one_value_are_masked_too(phones, sql, sealed):
"""第 2 轮复核留下的几种找值写法:遮罩列套了 CAST、汇总,同一个数换个写法,整数列上两头不是整数的范围,范围再用
不等、NOT IN 去掉一头。以前都照原样封存,长号码的那几种还因为照原样留着、被当成阈值,回答正文跟着不遮。"""
assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, "sqlite") == sealed
shown = inquiry_mod._shown_literals([(sql, "sqlite", {"buyer_phone", "age"})], phones)
assert not shown & {"25", "13900000001"}
@pytest.mark.parametrize("sql", [
# 范围加上不等、IN,合起来不只认一个值:照原样
"SELECT COUNT(*) FROM sales WHERE age >= 25 AND age <= 30 AND age <> 26",
"SELECT COUNT(*) FROM sales WHERE age > 0 AND age IN (26, 27)",
"SELECT COUNT(*) FROM sales WHERE age >= 25 OR age = 26",
# 两头不是整数、中间不只一个整数
"SELECT COUNT(*) FROM sales WHERE age > 24.5 AND age < 26.5",
# 不等、IN 在别的条件里(选出列的 CASE 和 WHERE):只有范围比较跨条件配对
"SELECT SUM(CASE WHEN age = 26 THEN 1 ELSE 0 END) AS 人数 FROM sales WHERE age >= 25",
])
def test_ranges_next_to_other_comparisons_that_do_not_pin_a_value_stay(phones, sql):
assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, "sqlite") == sql
def test_a_long_sum_of_numbers_does_not_stop_the_reading(ages):
"""visits <= 1+1+…(上千项):以前算式递归求值,超过递归深度,整条 SQL 退回按词认,同一句里夹住的年龄就照原样
封存。现在不递归,照样按语法树认。"""
total = "+".join(["1"] * 1500)
sql = f"SELECT COUNT(*) FROM sales WHERE visits <= {total} AND age >= 25 AND age <= 25"
assert inquiry_mod._literal_uses(sql, "sqlite") is not None
started = time.perf_counter()
assert inquiry_mod._scrub_sql(sql, ages, {"age"}, "sqlite") == sql.replace(
"age >= 25 AND age <= 25", "age >= 已遮罩 AND age <= 已遮罩")
elapsed = time.perf_counter() - started
assert elapsed < 5, f"用了 {elapsed:.2f} 秒"
@pytest.mark.parametrize("kind, sql, shown", [
# 同一个条件里还有别的条件(且、或):照样夹住了
("sqlite", "SELECT COUNT(*) FROM sales WHERE region = '华东' AND buyer_phone >= 13900000001 "
@@ -919,6 +1068,41 @@ def test_sql_check_messages_do_not_quote_masked_values():
assert [c["sql_excerpt"] for c in out] == ["status IN (已遮罩, -已遮罩)", "channel_type = '已遮罩'"]
def test_codes_listed_in_a_check_message_are_masked_by_value_like_any_text():
"""说明里码值表列出的码值是数据目录写明的,不从 SQL 摘,也不另外免遮:和说明里别的字一样按值换,恰好是按值认得出
的遮罩值(两个字以上的文字、够长的数)的码值换成「已遮罩」(多遮,不漏),短的码值、别的码值照原样。"""
secrets = secrets_of({"columns": ["channel_type", "status"], "rows": [["线上直销", 1], ["分销", 0]]},
{"channel_type", "status"})
message = ("「渠道」的「渠道类型」没有码值「线下合作」(数据目录中已列出的全部码值:线上直销=官网和小程序、"
"线下=窗口售票、分销=旅行社、1=有效)。请核对筛选条件里的取值")
checks = [{"code": "unknown_code", "level": "warning", "table": "channels", "column": "channel_type",
"message": message, "sql_excerpt": "channel_type = '线下合作'"}]
[check] = inquiry_mod._masked_checks(checks, "SELECT COUNT(*) FROM channels WHERE channel_type = '线下合作'",
secrets, {"channel_type", "status"}, "sqlite")
assert check["message"] == ("「渠道」的「渠道类型」没有码值「线下合作」(数据目录中已列出的全部码值:已遮罩=官网和小程序、"
"线下=窗口售票、已遮罩=旅行社、1=有效)。请核对筛选条件里的取值")
@pytest.mark.parametrize("excerpt, shown", [
# 截在带空格的地址中间:从没闭合的引号起整个去掉,不只去掉最后一段
("region = '华东' AND addr = '上海市 浦东新区 某…", "region = '华东' AND addr = …"),
# 写成两个单引号的引号不算闭合
("region = 'O''Neil 的店' AND addr = '上海市 浦东…", "region = 'O''Neil 的店' AND addr = …"),
# 截在带引号的名字、注释中间也一样
('region = \'华东\' AND "上海市 浦东新区…', "region = '华东' AND …"),
("region = '华东' /* 上海市 浦东…", "region = '华东' …"),
])
def test_a_cut_off_excerpt_drops_the_whole_unfinished_value(excerpt, shown):
"""检查器截过的片段停在一个带空格的遮罩文字值中间(地址):以前只去掉「…」前最后一段连续的字,前面那几段还在;
现在从没闭合的引号、注释起整个去掉。"""
secrets = secrets_of({"columns": ["addr"], "rows": [["上海市 浦东新区 某某路 1 号"]]}, {"addr"})
sql = "SELECT SUM(balance) AS 余额 FROM accounts WHERE region = '华东' AND addr = '上海市 浦东新区 某某路 1 号'"
checks = [{"code": "stock_summed", "level": "warning", "table": "accounts", "message": "存量跨日期求和",
"sql_excerpt": excerpt}]
[check] = inquiry_mod._masked_checks(checks, sql, secrets, {"addr"}, "sqlite")
assert check["sql_excerpt"] == shown
def test_report_values_that_happen_to_be_masked_values_are_shown():
"""报告口径卡里的指标恰好和遮罩列里的某个值一样(单笔目标 20000,金额也有 20000 的单):报告里本来就看得到,
引用它的回答照常交出去。"""