From 5d4d1fdc153e20de12999ea585f5bfd6bcec826b Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 15:08:56 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E8=B4=A8=E8=AF=A2=E9=81=AE?= =?UTF-8?q?=E7=BD=A9=E6=94=B6=E5=8F=A3=EF=BC=88=E7=AC=AC=202=20=E8=BD=AE?= =?UTF-8?q?=E5=A4=8D=E6=A0=B8=EF=BC=89=EF=BC=9A=E7=AE=97=E5=BC=8F=E8=BE=B9?= =?UTF-8?q?=E7=95=8C=E3=80=81=E5=A5=97=E4=BA=86=20CAST=20/=20=E6=B1=87?= =?UTF-8?q?=E6=80=BB=E7=9A=84=E5=88=97=E3=80=81=E6=8D=A2=E4=B8=AA=E5=86=99?= =?UTF-8?q?=E6=B3=95=E7=9A=84=E6=95=B0=E3=80=81=E4=B8=A4=E5=A4=B4=E4=B8=8D?= =?UTF-8?q?=E6=98=AF=E6=95=B4=E6=95=B0=E3=80=81=E8=8C=83=E5=9B=B4=E5=86=8D?= =?UTF-8?q?=E6=8E=92=E9=99=A4=E4=B8=80=E5=A4=B4=E9=83=BD=E8=AE=A4=E5=BE=97?= =?UTF-8?q?=E5=87=BA=EF=BC=9B=E5=8F=AA=E8=A3=85=E7=9D=80=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E7=9A=84=E5=88=97=E6=89=8D=E4=B8=8D=E6=94=B6=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=EF=BC=9B=E6=88=AA=E6=96=AD=E7=89=87=E6=AE=B5=E6=95=B4=E6=AE=B5?= =?UTF-8?q?=E5=8E=BB=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复核留下的三条 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 / 没起 别名、截断片段、码值表;端到端补长号码的五种写法(算出来的边界、算出来的等值、两头不是整数、范围再排除一头、补零)、 年龄的六种写法和改了名字的等级列。每处实现都逐个去掉验证过测试变红 --- backend/app/engine/inquiry.py | 703 ++++++++++++++++++-------- backend/tests/test_inquiry_api.py | 113 ++++- backend/tests/test_inquiry_masking.py | 206 +++++++- 3 files changed, 800 insertions(+), 222 deletions(-) diff --git a/backend/app/engine/inquiry.py b/backend/app/engine/inquiry.py index 93327002..bcf3400f 100644 --- a/backend/app/engine/inquiry.py +++ b/backend/app/engine/inquiry.py @@ -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 的名字, 含改了名字的);快照没取到的(存不下、被删了),按这个源现在设的遮罩列认。""" diff --git a/backend/tests/test_inquiry_api.py b/backend/tests/test_inquiry_api.py index da36b04a..2780a8dc 100644 --- a/backend/tests/test_inquiry_api.py +++ b/backend/tests/test_inquiry_api.py @@ -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 …)、两头相同的范围、行值、选出列里写着同一个号码……),回答里 diff --git a/backend/tests/test_inquiry_masking.py b/backend/tests/test_inquiry_masking.py index db6cd2a4..3d5947ac 100644 --- a/backend/tests/test_inquiry_masking.py +++ b/backend/tests/test_inquiry_masking.py @@ -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 的单):报告里本来就看得到, 引用它的回答照常交出去。"""