mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
8c229c3d1a
以前分档写在子查询、WITH 里(SELECT *, CASE … END AS 档位 FROM sales),外层再按档位分组,工具边界把档位整列遮成「已遮罩」,模型分不清哪个数属于哪一档,质询回答成了「已遮罩有 3 笔,已遮罩有 2 笔」。现在 _derived_hidden 认出只装着标签(_label_columns)、又一档一行的结果列(这一层或派生来源里那一层的 GROUP BY 键,按位置、别名、照原样写都算,_grouped_labels),不算取自遮罩列:格子里只有标签、和遮罩列不同名才算,认不出的照旧整列遮;SQL 里照旧认得出它取自金额。工具边界、质询回答、质询工件用的是同一套。* 展开的表在冻结的表结构里查得到、没有同名列时标签照常认(原来就有),不知道表结构时照旧当它带得出(多遮)。测试 test_inquiry_masking 里「外层再分组照旧算」的两个用例按新规则改;补六种写法的工具边界用例、逐行和混了别的值的反例、质询端到端用例,去掉豁免后全部变红。_tainted 多交回一个「认不出、整份都算」的标记,整份遮时不豁免。
1491 lines
109 KiB
Python
1491 lines
109 KiB
Python
"""审批质询的遮罩(engine/inquiry.py):回答正文、查询清单、质询工件里都不露出数据源遮罩列的值,也不误伤别的数。
|
||
|
||
- 改了名字的遮罩列(起别名、套函数、经子查询或 CTE 转一道、UNION 的另一支、和 * 写在同一支里没起别名的表达式)
|
||
照样遮;COUNT 只数个数、窗口的分区键只决定次序,不遮;认不出落在哪一列的(两个 * 之间、子查询里没起别名的表达式)、
|
||
解析不了又写着遮罩列的,整份快照遮掉
|
||
- 按值认的遮罩值有门槛:纯数字够长(SECRET_DIGITS 位)、文字至少两个字;日期、小数、千分位里的一截不算;提问人自己
|
||
写进问题、选中原文里的,报告列出的指标和运行输入的值,都不换;SQL 里照原样留着的阈值、行数只在回答正文里不换,
|
||
格子里的同一个数照旧换
|
||
- SQL 里数字照原样留着的只有范围比较的边界(没把某一个值夹住的)和 LIMIT / OFFSET 的行数;在找某一个值的(等值、IN、
|
||
LIKE、简单 CASE、IS [NOT] DISTINCT FROM,套了括号、写成行值、VALUES、ARRAY 的也算,还有 BETWEEN v AND v、
|
||
>= v AND <= v 这类夹住某一个值的范围)和遮罩列比较时短的数也换,被比较的一边套了 CAST、汇总也算;同一个数换个
|
||
写法(25.0、025、2.5e1)、写成几个数的算式(25 + 0、13900000000 + 1、125 % 100、50 DIV 2,整数相除照数据源的算法)
|
||
照样认,算式里的数都换(CAST 的类型参数不算);选出列、函数参数、算式里够长的遮罩值也换;字符串(范围边界也一样)、
|
||
注释、名字按值换;SQL 解析不了时认不准哪个是阈值,一律不免
|
||
- 「夹住某一个值」在整条 SQL 里认,只有分档的几种写法各算各的:CASE 的不同分支、不同的选出列(连同 GROUP BY、
|
||
ORDER BY)、UNION 的不同支,共用的边界是阈值;同一行集上的条件(ON 和 WHERE、WHERE 和 HAVING、INTERSECT 两支、
|
||
包在 CASE / 函数 / 算式里的一半、子查询、CTE)照样认得出在找哪一个值。同一个条件里按合起来的真假认(旁边的别的
|
||
条件当成不知道真假),BETWEEN v AND v、>= v AND <= 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、回答正文里照原样;
|
||
当分组键的(每一档一行)不算派生值,格子照常显示,逐行的标签列照旧整列遮。只有只装着标签的列不收(按作用域认取自
|
||
哪个来源:经别名、子查询、CTE 传下去、没起别名的也算),改了名字的遮罩列撞上同一条 SQL 里的 THEN 1 ELSE 0 照收,
|
||
UNION 另一支、子查询里的遮罩列流进标签列也照收
|
||
- 快照按列遮,另按值遮:别的列里写着遮罩值的格子也换成「已遮罩」;列名里写着遮罩值的,列名也换;质询工件里,
|
||
没设遮罩的列里一格文字夹着的遮罩值按子串换(回答一路照旧整份不交),SQL 检查结果的片段、说明照整条 SQL 换(截断的
|
||
片段从没闭合的引号、注释起去掉,说明里的码值表和别的字一样按值换)
|
||
- 组装回答之后还认得出遮罩值的(正文里复述的阈值除外),不交出去
|
||
- 遮罩值上万个时换一遍也不卡:不现编上万个分支的正则;上千个条件连成一长串、上千项的连加照样按结构认,成百个分档
|
||
边界看不过来的按夹住算,也不卡
|
||
|
||
数据都是虚构的:手机号、姓名、年龄、区域、金额。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import time
|
||
|
||
import pytest
|
||
|
||
from app.engine import inquiry as inquiry_mod
|
||
|
||
MASKED = "已遮罩"
|
||
|
||
|
||
def secrets_of(snapshot: dict, hidden: set[str]) -> inquiry_mod._Secrets:
|
||
secrets = inquiry_mod._Secrets()
|
||
secrets.add(snapshot, hidden)
|
||
return secrets
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 改了名字的遮罩列
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def hidden_of(sql: str, columns: list[str], masked: set[str] = frozenset({"buyer_phone"}), kind: str = "sqlite"
|
||
) -> set[str]:
|
||
snapshot = {"source": "shop", "sql": sql, "columns": columns, "rows": []}
|
||
return inquiry_mod._derived_hidden(snapshot, set(masked), kind)[0]
|
||
|
||
|
||
@pytest.mark.parametrize("sql, columns, hidden", [
|
||
("SELECT buyer_phone AS 手机号, amount AS 金额 FROM sales", ["手机号", "金额"], {"buyer_phone", "手机号"}),
|
||
("SELECT UPPER(buyer_phone), amount FROM sales", ["UPPER(buyer_phone)", "amount"],
|
||
{"buyer_phone", "upper(buyer_phone)"}),
|
||
("SELECT t.p AS 号码 FROM (SELECT buyer_phone AS p FROM sales) t", ["号码"], {"buyer_phone", "p", "号码"}),
|
||
("WITH t AS (SELECT buyer_phone AS p, amount FROM sales) SELECT p, amount FROM t", ["p", "amount"],
|
||
{"buyer_phone", "p"}),
|
||
("WITH t(p) AS (SELECT buyer_phone FROM sales) SELECT p AS 号码 FROM t", ["号码"], {"buyer_phone", "p", "号码"}),
|
||
("SELECT region AS 区域 FROM sales UNION ALL SELECT buyer_phone FROM sales", ["区域"], {"buyer_phone", "区域"}),
|
||
("SELECT (SELECT buyer_phone FROM sales LIMIT 1) AS 某个号码", ["某个号码"], {"buyer_phone", "某个号码"}),
|
||
])
|
||
def test_a_masked_column_under_another_name_is_masked(sql, columns, hidden):
|
||
assert hidden_of(sql, columns) == hidden
|
||
|
||
|
||
SALES_COLUMNS = ["id", "region", "buyer_phone", "amount", "is_test"]
|
||
|
||
|
||
@pytest.mark.parametrize("sql, columns, hidden", [
|
||
# * 后面没起别名的表达式:从结果末尾倒着数(SQLite 照写法原样给列名,起不出能往下传的名字)
|
||
("SELECT *, SUBSTR(buyer_phone, 4) FROM sales ORDER BY amount DESC LIMIT 1",
|
||
[*SALES_COLUMNS, "SUBSTR(buyer_phone, 4)"], {"buyer_phone", "substr(buyer_phone, 4)"}),
|
||
("SELECT s.*, buyer_phone || '-x' FROM sales s", [*SALES_COLUMNS, "buyer_phone || '-x'"],
|
||
{"buyer_phone", "buyer_phone || '-x'"}),
|
||
# sqlglot 给 CAST 记的名字是里面的列名,结果的列名却是整个写法:照样按位置认
|
||
("SELECT *, CAST(buyer_phone AS TEXT) FROM sales", [*SALES_COLUMNS, "CAST(buyer_phone AS TEXT)"],
|
||
{"buyer_phone", "cast(buyer_phone as text)"}),
|
||
# * 前面的从头数,* 后面不是遮罩列的不遮
|
||
("SELECT UPPER(buyer_phone), s.*, amount * 2 FROM sales s", ["UPPER(buyer_phone)", *SALES_COLUMNS, "amount * 2"],
|
||
{"buyer_phone", "upper(buyer_phone)"}),
|
||
# 派生来源里 UNION 的另一支:结果的列名取第一支的
|
||
("SELECT * FROM (SELECT region FROM sales UNION ALL SELECT buyer_phone FROM sales) t", ["region"],
|
||
{"buyer_phone", "region"}),
|
||
("WITH t(p) AS (SELECT region FROM sales UNION ALL SELECT buyer_phone FROM sales) SELECT p FROM t", ["p"],
|
||
{"buyer_phone", "p"}),
|
||
("SELECT * FROM (SELECT *, buyer_phone AS p FROM sales) t", [*SALES_COLUMNS, "p"], {"buyer_phone", "p"}),
|
||
# 多套几层括号不改变结果流到哪
|
||
("((SELECT UPPER(buyer_phone) FROM sales))", ["UPPER(buyer_phone)"], {"buyer_phone", "upper(buyer_phone)"}),
|
||
("SELECT * FROM ((SELECT buyer_phone AS p FROM sales)) t", ["p"], {"buyer_phone", "p"}),
|
||
])
|
||
def test_an_unnamed_expression_next_to_a_star_or_under_a_union_is_masked(sql, columns, hidden):
|
||
assert hidden_of(sql, columns) == hidden
|
||
|
||
|
||
@pytest.mark.parametrize("sql, columns", [
|
||
# 两个 * 之间:前后都数不出位置
|
||
("SELECT a.*, SUBSTR(a.buyer_phone, 4), b.* FROM sales a JOIN sales b ON a.id = b.id",
|
||
[*SALES_COLUMNS, "SUBSTR(a.buyer_phone, 4)", *SALES_COLUMNS]),
|
||
# 子查询里没起别名的表达式:外层的 * 原样带出来,列名各数据库起法不同,认不准
|
||
("SELECT * FROM (SELECT SUBSTR(buyer_phone, 4), amount FROM sales) t", ["SUBSTR(buyer_phone, 4)", "amount"]),
|
||
("SELECT * FROM (SELECT SUBSTR(buyer_phone, 4) FROM sales)", ["SUBSTR(buyer_phone, 4)"]),
|
||
("WITH t AS ((SELECT SUBSTR(buyer_phone, 4) FROM sales)) SELECT * FROM t", ["SUBSTR(buyer_phone, 4)"]),
|
||
# UNION 第一支是 *:另一支的遮罩列落在哪个名字上说不清
|
||
("SELECT * FROM (SELECT * FROM sales UNION ALL SELECT id, region, SUBSTR(buyer_phone, 4), amount, is_test "
|
||
"FROM sales) t", SALES_COLUMNS),
|
||
])
|
||
def test_a_masked_expression_that_cannot_be_placed_hides_the_whole_snapshot(sql, columns):
|
||
assert hidden_of(sql, columns) == {"buyer_phone", *(c.lower() for c in columns)}
|
||
|
||
|
||
def test_a_lateral_subquery_is_read_like_a_derived_table():
|
||
"""Postgres 的 LATERAL:别名记在 LATERAL 上,起了别名的照名字传,没起别名的整份遮。"""
|
||
assert hidden_of("SELECT x.* FROM sales s CROSS JOIN LATERAL (SELECT s.buyer_phone AS p) x", ["p"],
|
||
kind="postgresql") == {"buyer_phone", "p"}
|
||
columns = [*SALES_COLUMNS, "substr"]
|
||
assert hidden_of("SELECT * FROM sales s CROSS JOIN LATERAL (SELECT SUBSTR(s.buyer_phone, 4)) x", columns,
|
||
kind="postgresql") == {*columns}
|
||
|
||
|
||
@pytest.mark.parametrize("sql, columns", [
|
||
("SELECT region AS 区域, COUNT(DISTINCT buyer_phone) AS 买家数 FROM sales GROUP BY region", ["区域", "买家数"]),
|
||
("SELECT region AS 区域, SUM(amount) AS 销售额 FROM sales WHERE buyer_phone = '13900000001' GROUP BY region",
|
||
["区域", "销售额"]),
|
||
("SELECT ROW_NUMBER() OVER (PARTITION BY buyer_phone ORDER BY amount) AS 序号 FROM sales", ["序号"]),
|
||
("SELECT (SELECT COUNT(*) FROM sales s2 WHERE s2.buyer_phone = s.buyer_phone) AS 笔数 FROM sales s", ["笔数"]),
|
||
# 子查询里只数个数、条件里的子查询:值带不出来,外层的 * 也不怕
|
||
("SELECT * FROM (SELECT region, COUNT(DISTINCT buyer_phone) FROM sales GROUP BY region) t",
|
||
["region", "COUNT(DISTINCT buyer_phone)"]),
|
||
("SELECT region FROM sales WHERE amount > (SELECT AVG(LENGTH(buyer_phone)) FROM sales)", ["region"]),
|
||
# 汇总里套着结果全是写死的 CASE / IIF:按条件数个数、算占比,同 COUNT
|
||
("SELECT SUM(CASE WHEN buyer_phone LIKE '139%' THEN 1 ELSE 0 END) AS 笔数 FROM sales", ["笔数"]),
|
||
("SELECT region, AVG(IIF(buyer_phone LIKE '139%', 1.0, 0)) AS 占比 FROM sales GROUP BY region", ["region", "占比"]),
|
||
])
|
||
def test_counting_filtering_and_ordering_by_a_masked_column_shows_nothing(sql, columns):
|
||
assert hidden_of(sql, columns) == {"buyer_phone"}
|
||
|
||
|
||
@pytest.mark.parametrize("sql, columns", [
|
||
# 汇总的是遮罩列的值本身(已知局限:SUM / AVG / MIN / MAX 算派生),CASE 里有一支取的是它也一样
|
||
("SELECT region, MAX(buyer_phone) AS 最大号码 FROM sales GROUP BY region", ["region", "最大号码"]),
|
||
("SELECT SUM(CASE WHEN region = '华东' THEN buyer_phone END) AS 合计 FROM sales", ["合计"]),
|
||
])
|
||
def test_aggregating_the_masked_values_themselves_is_still_derived(sql, columns):
|
||
assert hidden_of(sql, columns) >= {"buyer_phone", columns[-1].lower()}
|
||
|
||
|
||
def test_sql_that_cannot_be_read_but_names_a_masked_column_hides_everything():
|
||
assert hidden_of("SELECT buyer_phone AS 号码 FROM sales WHERE (", ["号码", "金额"]) == {"buyer_phone", "号码", "金额"}
|
||
assert hidden_of("SELECT region FROM sales WHERE (", ["region"]) == {"buyer_phone"}
|
||
|
||
|
||
def test_names_that_point_at_a_masked_column_are_known_for_comparisons():
|
||
snapshot = {"source": "shop", "sql": "SELECT p FROM (SELECT buyer_phone AS p FROM sales) t", "columns": ["p"],
|
||
"rows": []}
|
||
assert inquiry_mod._derived_hidden(snapshot, {"buyer_phone"}, "sqlite")[1] == {"buyer_phone", "p"}
|
||
|
||
|
||
BANDS = "CASE WHEN amount BETWEEN 0 AND 20000 THEN '小单' WHEN amount BETWEEN 20000 AND 60000 THEN '大单' END"
|
||
|
||
|
||
@pytest.mark.parametrize("sql", [
|
||
# 按位置、按别名、照原样再写一遍分组
|
||
f"SELECT {BANDS} AS 档位, COUNT(*) AS 笔数 FROM sales GROUP BY 1",
|
||
f"SELECT {BANDS} AS 档位, COUNT(*) AS 笔数 FROM sales GROUP BY 档位",
|
||
f"SELECT {BANDS} AS 档位, COUNT(*) AS 笔数 FROM sales GROUP BY {BANDS}",
|
||
# 档位编号、没写 ELSE、ELSE NULL、IIF 的两个分支也是写死的
|
||
"SELECT CASE WHEN amount <= 20000 THEN 1 WHEN amount <= 60000 THEN 2 ELSE NULL END AS 档位, COUNT(*) AS 笔数 "
|
||
"FROM sales GROUP BY 1",
|
||
"SELECT IIF(amount > 20000, '大单', '小单') AS 档位, COUNT(*) AS 笔数 FROM sales GROUP BY 1",
|
||
# 分档写在子查询、WITH 里,外层再按它分组(K3b):同样每一档一行
|
||
f"SELECT 档位, COUNT(*) AS 笔数 FROM (SELECT {BANDS} AS 档位 FROM sales) t GROUP BY 档位",
|
||
f"WITH t AS (SELECT {BANDS} AS 档位 FROM sales) SELECT 档位, COUNT(*) AS 笔数 FROM t GROUP BY 1",
|
||
])
|
||
def test_a_band_label_used_as_the_group_key_is_not_a_masked_value(sql):
|
||
"""按遮罩列分档、每一档一行(CASE、IIF 的结果全是写死的标签,又是分组键;分档写在派生来源里、外层按它分组的也算):
|
||
标签只说一行落在哪一档,带不出金额,档位列不遮,才分得清哪一档是几笔。笔数照样只是计数。"""
|
||
assert hidden_of(sql, ["档位", "笔数"], {"amount"}) == {"amount"}
|
||
|
||
|
||
@pytest.mark.parametrize("sql", [
|
||
# 不分组:每一行的档位就是这一笔落在哪一档
|
||
f"SELECT id, {BANDS} AS 档位, 1 AS 笔数 FROM sales",
|
||
# 结果里有一支取的是金额本身
|
||
"SELECT CASE WHEN amount > 20000 THEN amount ELSE 0 END AS 档位, COUNT(*) AS 笔数 FROM sales GROUP BY 1",
|
||
# 分档写在子查询里,外层不分组:子查询里的档位逐行带出来,照旧算
|
||
f"SELECT 档位, 1 AS 笔数 FROM (SELECT {BANDS} AS 档位 FROM sales) t",
|
||
])
|
||
def test_band_labels_row_by_row_still_count_as_derived(sql):
|
||
assert hidden_of(sql, ["档位", "笔数"], {"amount"}) >= {"amount", "档位"}
|
||
|
||
|
||
def plan_of(snapshot: dict, base: set[str], kind: str = "sqlite") -> inquiry_mod._Plan:
|
||
"""一份 agent 的查询快照按回答时的办法收遮罩值、遮好(数据源设了遮罩的列是 base)。"""
|
||
return inquiry_mod._settle_plan({"a1": snapshot}, {"a1": (base, [])}, [], {"shop": kind}, [], [], {},
|
||
frozenset())
|
||
|
||
|
||
def test_band_labels_written_in_the_sql_are_not_masked_values():
|
||
"""分档的标签是 agent 自己写在 SQL 里的字,不是数据源里的值:当分组键的整列照常显示;逐行的标签列照旧整列遮
|
||
(格子里看不出这一笔落在哪一档),但标签文字也不当遮罩值——SQL 里、回答正文里照原样,换掉了封存的就不是实际
|
||
执行的语句,「以 20000 元为界,小单 3 笔」也会变成满屏「已遮罩」。"""
|
||
grouped = {"source": "shop", "sql": f"SELECT {BANDS} AS 档位, COUNT(*) AS 笔数 FROM sales GROUP BY 1 ORDER BY 1",
|
||
"columns": ["档位", "笔数"], "rows": [["大单", 2], ["小单", 3]]}
|
||
plan = plan_of(grouped, {"amount"})
|
||
assert plan.views["a1"]["rows"] == [["大单", 2], ["小单", 3]] and plan.masked["a1"] == []
|
||
assert plan.secrets.scrub("以 20000 元为界,小单 3 笔、大单 2 笔") == "以 20000 元为界,小单 3 笔、大单 2 笔"
|
||
assert inquiry_mod._scrub_sql(grouped["sql"], plan.secrets, plan.tainted["a1"], "sqlite") == grouped["sql"]
|
||
|
||
rows = {"source": "shop", "sql": "SELECT id, CASE WHEN age < 30 THEN '30岁以下' ELSE '30岁及以上' END AS 年龄段 "
|
||
"FROM sales ORDER BY id",
|
||
"columns": ["id", "年龄段"], "rows": [[1, "30岁以下"], [2, "30岁及以上"]]}
|
||
plan = plan_of(rows, {"age"})
|
||
assert plan.views["a1"]["rows"] == [[1, MASKED], [2, MASKED]] and plan.masked["a1"] == ["年龄段"]
|
||
assert plan.secrets.scrub("30岁以下的有 1 人") == "30岁以下的有 1 人"
|
||
assert inquiry_mod._scrub_sql(rows["sql"], plan.secrets, plan.tainted["a1"], "sqlite") == rows["sql"]
|
||
assert inquiry_mod._shown_literals([(rows["sql"], "sqlite", plan.tainted["a1"])], plan.secrets) == {"30"}
|
||
|
||
|
||
def test_a_value_of_the_masked_column_itself_stays_masked_even_if_written_as_a_label():
|
||
"""只有派生列里的标签不收:遮罩列自己选出来的值照收,哪怕 agent 在 SQL 里把同样的字写成了标签。"""
|
||
snapshot = {"source": "shop", "sql": "SELECT level, CASE WHEN level = 'VIP会员' THEN 'VIP会员' ELSE '普通会员' END "
|
||
"AS 等级 FROM customers",
|
||
"columns": ["level", "等级"], "rows": [["VIP会员", "VIP会员"], ["铂金会员", "普通会员"]]}
|
||
plan = plan_of(snapshot, {"level"})
|
||
assert plan.views["a1"]["rows"] == [[MASKED, MASKED], [MASKED, MASKED]]
|
||
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 档位, 1 AS 笔数 FROM (SELECT {BANDS} AS 档位 FROM sales) t", ["档位", "笔数"], [["大单", 1], ["小单", 1]]),
|
||
(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 笔"
|
||
|
||
|
||
#: 按条件数大单写的 1、0:和会员等级(1、2、3)撞在一起
|
||
BIG_ONE = "CASE WHEN amount > 1000 THEN 1 ELSE 0 END"
|
||
#: 标签那一支的 1、0 在前,等级 1、2、3 在后
|
||
LEVEL_ROWS = [[1], [0], [0], [1], [1], [2], [3], [1]]
|
||
|
||
|
||
@pytest.mark.parametrize("sql, columns, rows", [
|
||
# UNION 标签在前、遮罩列在后:遮罩列没起别名,或者起了别的名字
|
||
(f"SELECT {BIG_ONE} AS 等级 FROM sales UNION ALL SELECT vip_level FROM sales", ["等级"], LEVEL_ROWS),
|
||
(f"SELECT {BIG_ONE} AS 等级 FROM sales UNION ALL SELECT vip_level AS lv FROM sales", ["等级"], LEVEL_ROWS),
|
||
# 子查询里的 UNION,外层按名字取;写了列名表的 CTE 里的 UNION
|
||
(f"SELECT 等级, COUNT(*) AS 人数 FROM (SELECT {BIG_ONE} AS 等级 FROM sales UNION ALL SELECT vip_level FROM sales) t "
|
||
"GROUP BY 等级", ["等级", "人数"], [[0, 2], [1, 4], [2, 1], [3, 1]]),
|
||
(f"WITH t(lv) AS (SELECT {BIG_ONE} FROM sales UNION ALL SELECT vip_level FROM sales) SELECT lv AS 等级 FROM t",
|
||
["等级"], LEVEL_ROWS),
|
||
# 标签写在 CTE 里,结果的 UNION 另一支取遮罩列
|
||
(f"WITH b AS (SELECT {BIG_ONE} AS lvl FROM sales) SELECT lvl FROM b UNION ALL SELECT vip_level FROM sales",
|
||
["lvl"], LEVEL_ROWS),
|
||
# 遮罩列经按原名选出的子查询、以标签的别名传出
|
||
(f"SELECT {BIG_ONE} AS 档 FROM sales UNION ALL SELECT b.vip_level AS 档 FROM (SELECT vip_level FROM sales) b",
|
||
["档"], LEVEL_ROWS),
|
||
# 不用 UNION:两个子查询连起来,一边是标签,一边是改了名字的遮罩列,名字相同
|
||
(f"SELECT y.档 AS 档 FROM (SELECT id, {BIG_ONE} AS 档 FROM sales) x "
|
||
"JOIN (SELECT id, vip_level AS 档 FROM (SELECT id, vip_level FROM sales) z) y ON x.id = y.id", ["档"],
|
||
[[1], [2], [3], [1]]),
|
||
# USING 连起来的同名列:右连接时,左边没对上的行取的是右边那张表里的值
|
||
(f"WITH b AS (SELECT id, {BIG_ONE} AS vip_level FROM sales) SELECT vip_level AS 等级 FROM b "
|
||
"RIGHT JOIN members USING (vip_level)", ["等级"], [[1], [2], [3]]),
|
||
])
|
||
def test_a_column_that_also_carries_the_masked_column_is_not_a_label_column(sql, columns, rows):
|
||
"""第 3 轮复核:只装着标签的列以前按名字认,从「所有名字都只装着标签」起往下剔,遮罩列原样选出(SELECT vip_level)
|
||
时名字就是它自己,永远剔不掉;结果的 UNION 按位置那一遍只往里加标签,另一支取的是遮罩列也不剔,派生来源里的
|
||
UNION 根本不按位置看。于是标签在前、遮罩列在后时,等级 1 撞上 THEN 1 就不收,之后 WHERE vip_level = 1 照原样
|
||
封存。现在按作用域认每一项取自哪个来源,结果的这一列在每一支里都只装着标签才算,取自数据源里的表的一律不算。"""
|
||
plan = plan_of({"source": "shop", "sql": sql, "columns": columns, "rows": rows}, {"vip_level"})
|
||
assert inquiry_mod._label_columns(sql, "sqlite", columns) == {}
|
||
assert {"1", "2", "3"} <= plan.secrets.weak
|
||
for level in (1, 2, 3):
|
||
assert inquiry_mod._scrub_sql(f"SELECT COUNT(*) AS 人数 FROM sales WHERE vip_level = {level}", plan.secrets,
|
||
plan.tainted["a1"], "sqlite") == (
|
||
"SELECT COUNT(*) AS 人数 FROM sales WHERE vip_level = 已遮罩")
|
||
|
||
|
||
_EXTRA = "CASE WHEN amount > 60000 THEN '特大单' END"
|
||
|
||
|
||
@pytest.mark.parametrize("sql, columns, texts", [
|
||
# CTE 里分档,外层连上数据源里的表、按档位汇总:档位没写表名也认得出取自 CTE(SQL 能执行,别的表就没有同名的列,
|
||
# 不然会报列名有歧义)
|
||
(f"WITH banded AS (SELECT id, {BANDS} AS 档位 FROM sales) SELECT 档位, SUM(s.visits) AS 访问 FROM banded b "
|
||
"JOIN sales s ON s.id = b.id GROUP BY 档位", ["档位", "访问"], {"小单", "大单"}),
|
||
# 按别的列连起来(USING (id)):档位没合成一列,照样取自 CTE
|
||
(f"WITH banded AS (SELECT id, {BANDS} AS 档位 FROM sales) SELECT 档位, SUM(o.amount) AS 金额 FROM banded "
|
||
"JOIN orders o USING (id) GROUP BY 档位", ["档位", "金额"], {"小单", "大单"}),
|
||
# 写了表名、经两层子查询传下来
|
||
(f"SELECT t.档位 FROM (SELECT b.档位 FROM (SELECT {BANDS} AS 档位 FROM sales) b) t", ["档位"], {"小单", "大单"}),
|
||
# UNION 两支都是标签(第二支没写 ELSE、没起别名):两支的标签都算
|
||
(f"SELECT {BANDS} AS 档位 FROM sales UNION ALL SELECT {_EXTRA} FROM sales", ["档位"], {"小单", "大单", "特大单"}),
|
||
# 派生来源里的 UNION 两支都是标签,外层经 * 带出来
|
||
(f"SELECT * FROM (SELECT {BANDS} AS 档位 FROM sales UNION ALL SELECT {_EXTRA} FROM sales) t", ["档位"],
|
||
{"小单", "大单", "特大单"}),
|
||
])
|
||
def test_label_columns_are_traced_to_where_they_are_written(sql, columns, texts):
|
||
"""改成按作用域认之后,写在别处、原样传下来的标签照样认得出:外层连上数据源里的表没写表名、按别的列 USING、写了
|
||
表名经两层子查询;UNION 两支都是标签的,每一支的标签都算(以前派生来源里的 UNION 按名字认,只算起了这个名字的
|
||
第一支)。"""
|
||
assert inquiry_mod._label_columns(sql, "sqlite", columns) == {"档位": texts}
|
||
|
||
|
||
#: 派生来源里排在标签前面、和标签同名的列:SQLite 让同名的几列都留着,外层按名字取的是排在前面的那一列(真实等级)
|
||
SHADOWED = [
|
||
pytest.param(f"SELECT vip_level AS 档 FROM (SELECT *, {BIG_ONE} AS vip_level FROM sales) t", id="* 在前"),
|
||
pytest.param(f"SELECT vip_level AS 档 FROM (SELECT s.*, {BIG_ONE} AS vip_level FROM sales s) t", id="t.* 在前"),
|
||
pytest.param(f"WITH t AS (SELECT *, {BIG_ONE} AS vip_level FROM sales) SELECT vip_level AS 档 FROM t",
|
||
id="CTE 里 * 在前"),
|
||
pytest.param(f"SELECT x.vip_level AS 档 FROM (SELECT * FROM sales s JOIN (SELECT id AS bid, {BIG_ONE} AS vip_level "
|
||
"FROM sales) b ON s.id = b.bid) x", id="JOIN 子查询 * 在前"),
|
||
pytest.param(f"SELECT vip_level AS 档 FROM (SELECT *, b.lbl AS vip_level FROM sales s JOIN (SELECT id AS bid, "
|
||
f"{BIG_ONE} AS lbl FROM sales) b ON s.id = b.bid) t", id="JOIN 之后 * 在前"),
|
||
pytest.param(f"SELECT vip_level AS 档 FROM (SELECT (vip_level), {BIG_ONE} AS vip_level FROM sales) t",
|
||
id="(col) 在前"),
|
||
pytest.param(f"SELECT vip_level AS 档 FROM (SELECT ((vip_level)), {BIG_ONE} AS vip_level FROM sales) t",
|
||
id="((col)) 在前"),
|
||
pytest.param(f"SELECT vip_level AS 档 FROM (SELECT vip_level COLLATE NOCASE, {BIG_ONE} AS vip_level FROM sales) t",
|
||
id="COLLATE 在前"),
|
||
pytest.param(f"SELECT vip_level AS 档 FROM (SELECT (vip_level) COLLATE BINARY, {BIG_ONE} AS vip_level "
|
||
"FROM sales) t", id="(col) COLLATE 在前"),
|
||
pytest.param(f"SELECT vip_level AS 档 FROM (SELECT (vip_level), {BIG_ONE} AS vip_level FROM sales UNION ALL "
|
||
f"SELECT vip_level, {BIG_ONE} FROM sales) t", id="多支的第一支 (col) 在前"),
|
||
pytest.param(f"SELECT vip_level AS 档 FROM (SELECT vip_level COLLATE NOCASE, {BIG_ONE} AS vip_level FROM sales "
|
||
f"UNION ALL SELECT vip_level, {BIG_ONE} FROM sales) t", id="多支的第一支 COLLATE 在前"),
|
||
]
|
||
|
||
|
||
@pytest.mark.parametrize("sql", SHADOWED)
|
||
def test_a_masked_column_shadowing_a_label_in_a_derived_source_is_not_a_label_column(sql):
|
||
"""第 3 轮复验(J2 留下的缺口):派生来源里 * 带出的遮罩列、没起别名但套了括号或 COLLATE 的遮罩列排在同名的标签
|
||
前面,SQLite 交回的是真实等级(1、2、3),以前却按标签认、短码值不收,之后 WHERE vip_level = 1 照原样封存。现在
|
||
第一个同名项前面有 * 带出这一列(或说不清带不带)、有去掉括号和 COLLATE 后同名的项,都不算只装着标签的列。"""
|
||
plan = plan_of({"source": "shop", "sql": sql, "columns": ["档"], "rows": [[1], [2], [3]]}, {"vip_level"})
|
||
assert inquiry_mod._label_columns(sql, "sqlite", ["档"]) == {}
|
||
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 = 已遮罩")
|
||
|
||
|
||
@pytest.mark.parametrize("sql", [
|
||
# 标签排在 * 前面:SQLite 取的是排在前面的标签
|
||
f"SELECT vip_level AS 档 FROM (SELECT {BIG_ONE} AS vip_level, * FROM sales) t",
|
||
# * 只带出另一个派生来源(写了表名):那里只有标签
|
||
f"SELECT x.vip_level AS 档 FROM (SELECT b.* FROM sales s JOIN (SELECT id AS bid, {BIG_ONE} AS vip_level "
|
||
"FROM sales) b ON s.id = b.bid) x",
|
||
# * 只展开 CTE 里的标签
|
||
f"WITH b AS (SELECT {BIG_ONE} AS vip_level FROM sales) SELECT vip_level AS 档 FROM (SELECT * FROM b) t",
|
||
])
|
||
def test_a_label_ahead_of_a_star_or_alone_behind_one_is_still_a_label_column(sql):
|
||
"""收紧之后,标签排在前面、* 只带出派生来源里的标签的,照旧算只装着标签的列。"""
|
||
assert inquiry_mod._label_columns(sql, "sqlite", ["档"]) == {"档": {"0", "1"}}
|
||
|
||
|
||
#: 没写表名的 * 连着数据源里的表和装着标签的派生来源
|
||
STAR_JOIN = ("SELECT * FROM sales s JOIN (SELECT id AS bid, CASE WHEN amount > 1000 THEN '大单' ELSE '小单' END AS 档 "
|
||
"FROM sales) b ON s.id = b.bid")
|
||
STAR_COLUMNS = ["id", "region", "amount", "bid", "档"]
|
||
|
||
|
||
def test_a_star_over_a_table_known_to_lack_the_column_keeps_the_label():
|
||
"""复核第 1 轮(收紧之后的多遮):没写表名的 * 展开数据源里的表,以前一律算「说不清带不带这一列」,标签照收成
|
||
遮罩值,回答里的「大单」、SQL 里的 '大单' 都换成「已遮罩」。冻结的表结构里查得到这张表、又没有同名的列时,照旧
|
||
按派生来源认标签;表里有同名的列、查不到表结构的,照旧不算(宁可多遮)。"""
|
||
from app.engine import masking
|
||
|
||
lacking = {"tables": {"sales": {"columns": [{"name": "id"}, {"name": "region"}, {"name": "amount"}]}}}
|
||
holding = {"tables": {"sales": {"columns": [{"name": "id"}, {"name": "档"}, {"name": "amount"}]}}}
|
||
assert masking._label_columns(STAR_JOIN, "sqlite", STAR_COLUMNS, masking._schema_tables(lacking)) == {
|
||
"档": {"大单", "小单"}}
|
||
assert masking._label_columns(STAR_JOIN, "sqlite", STAR_COLUMNS, masking._schema_tables(holding)) == {}
|
||
assert masking._label_columns(STAR_JOIN, "sqlite", STAR_COLUMNS) == {}
|
||
truncated = {"tables": {"orders": {"columns": [{"name": "id"}]}}, "truncated": True}
|
||
assert masking._label_columns(STAR_JOIN, "sqlite", STAR_COLUMNS, masking._schema_tables(truncated)) == {}
|
||
# 写了表名的 s.* 展开的是数据源里的表:同样按表结构认它带不带这一列
|
||
nested = ("SELECT x.档 FROM (SELECT s.*, b.* FROM sales s JOIN (SELECT id AS bid, CASE WHEN amount > 1000 "
|
||
"THEN '大单' ELSE '小单' END AS 档 FROM sales) b ON s.id = b.bid) x")
|
||
assert masking._label_columns(nested, "sqlite", ["档"], masking._schema_tables(lacking)) == {"档": {"大单", "小单"}}
|
||
assert masking._label_columns(nested, "sqlite", ["档"], masking._schema_tables(holding)) == {}
|
||
assert masking._label_columns(nested, "sqlite", ["档"]) == {}
|
||
|
||
|
||
async def test_a_snapshot_reads_labels_with_its_frozen_schema():
|
||
"""快照记着查询当时冻结的表结构(schema_artifact):按它认,* 连着的表没有同名的列时标签不当遮罩值,回答正文、
|
||
SQL 里的档名照原样;表结构取不到的照旧不算。"""
|
||
from app.core import artifact_store
|
||
from app.engine import masking
|
||
|
||
lacking = {"source": "shop", "tables": {"sales": {"columns": [{"name": "id"}, {"name": "region"},
|
||
{"name": "amount"}]}}}
|
||
schema = await artifact_store.put_json(lacking, kind="schema_snapshot", run_id="r-labels", node_id="n1")
|
||
snapshot = {"source": "shop", "sql": STAR_JOIN, "columns": STAR_COLUMNS, "schema_artifact": schema,
|
||
"rows": [[1, "华东", 1200, 1, "大单"], [2, "华南", 300, 2, "小单"]]}
|
||
assert masking._snapshot_labels(snapshot, "sqlite") == {"档": {"大单", "小单"}}
|
||
plan = plan_of(snapshot, {"amount"})
|
||
assert plan.secrets.scrub("这一笔属于大单") == "这一笔属于大单"
|
||
sql = "SELECT COUNT(*) FROM (SELECT CASE WHEN amount > 1000 THEN '大单' ELSE '小单' END AS 档 FROM sales) WHERE 档 = '大单'"
|
||
assert inquiry_mod._scrub_sql(sql, plan.secrets, plan.tainted["a1"], "sqlite") == sql
|
||
assert masking._snapshot_labels({**snapshot, "schema_artifact": "missing"}, "sqlite") == {}
|
||
|
||
|
||
def test_the_tool_boundary_reads_labels_with_the_sources_frozen_schema():
|
||
"""工具边界按数据源的表结构认标签:* 连着的表没有同名的列,标签不收成遮罩值,同一份结果里别的文字格里的「大单」照常。"""
|
||
from app.engine import masking
|
||
|
||
payload = {"sql": STAR_JOIN, "columns": [*STAR_COLUMNS, "备注"],
|
||
"rows": [[1, "华东", 1200, 1, "大单", "大单客户"], [2, "华南", 300, 2, "小单", "例行回访"]]}
|
||
schema = {"tables": {"sales": {"columns": [{"name": "id"}, {"name": "region"}, {"name": "amount"},
|
||
{"name": "备注"}]}}}
|
||
done = masking.mask_query_result(payload, ["amount"], "sqlite", schema=schema)
|
||
assert done.columns == ["amount", "档"] and not {"大单", "小单"} & done.secrets.strong
|
||
assert [row[-1] for row in done.payload["rows"]] == ["大单客户", "例行回访"]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 按值认:门槛、边界
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def test_masked_values_are_matched_as_whole_values():
|
||
"""遮罩列的值按整值认:更长的数里的一截、千分位写法里的一截不换;一个字的值、太短的数不换(换了会把不相干的
|
||
词、阈值弄坏);数按原样和千分位两种写法认。"""
|
||
snapshot = {"columns": ["phone", "name", "gender", "code", "salary", "region"],
|
||
"rows": [["13900000001", "张伟", "男", "390", 12000, "华东"]]}
|
||
scrub = secrets_of(snapshot, {"phone", "name", "gender", "code", "salary"}).scrub
|
||
assert scrub("客户 13900000001、139000000012、1,390 元、390 笔、张伟买了男装,月薪 12,000,另 12000.5") == (
|
||
"客户 已遮罩、139000000012、1,390 元、390 笔、已遮罩买了男装,月薪 已遮罩,另 12000.5")
|
||
assert scrub("华东") == "华东", "没遮的列不换"
|
||
assert inquiry_mod._Secrets().scrub("13900000001") == "13900000001"
|
||
|
||
|
||
def test_short_masked_numbers_leave_thresholds_dates_and_limits_alone():
|
||
"""遮罩列是年龄这类短的数:正文、SQL 里的阈值、日期、LIMIT 撞上同一个数也不换。"""
|
||
snapshot = {"columns": ["age", "vip_level"], "rows": [[25, 10], [31, 10], [47, 10]]}
|
||
scrub = secrets_of(snapshot, {"age", "vip_level"}).scrub
|
||
text = "近 30 天里,2026-01-25 之后的 25 笔订单比 1 月 10 日多 47 笔"
|
||
assert scrub(text) == text
|
||
|
||
|
||
def test_long_values_next_to_dates_and_decimals_are_not_cut_out_of_them():
|
||
secrets = secrets_of({"columns": ["phone"], "rows": [["13900000001"]]}, {"phone"})
|
||
assert secrets.scrub("13900000001-2、13900000001.5、20261390000000-13900000001") == (
|
||
"13900000001-2、13900000001.5、20261390000000-13900000001")
|
||
assert secrets.scrub("号码:13900000001。") == "号码:已遮罩。"
|
||
|
||
|
||
def test_values_the_asker_wrote_themselves_are_left_alone():
|
||
"""提问人自己写进问题、选中原文里的数(「13900000001 这位客户」)照原样:问题本身就带着它,换了只会让回答对不上。"""
|
||
secrets = secrets_of({"columns": ["phone"], "rows": [["13900000001"], ["13900000002"]]}, {"phone"})
|
||
secrets.protect(["13900000001 这位客户买了几笔?", ""])
|
||
assert secrets.scrub("13900000001 共 2 笔,13900000002 共 1 笔") == "13900000001 共 2 笔,已遮罩 共 1 笔"
|
||
|
||
|
||
def test_a_cell_holding_a_masked_value_is_masked_in_any_column():
|
||
"""别的列里写着同一个手机号(另一张表的联系方式没设遮罩):按值换。短的数不按值换,免得把同样是 25 的笔数遮了。"""
|
||
secrets = secrets_of({"columns": ["phone", "age"], "rows": [["13900000001", 25]]}, {"phone", "age"})
|
||
snapshot = {"columns": ["联系方式", "笔数", "金额"], "column_types": {"联系方式": "text", "笔数": "number"},
|
||
"rows": [["13900000001", 25, 13900000001], ["13900000009", 3, 1200]]}
|
||
shown, masked = inquiry_mod._masked_view(snapshot, set(), secrets)
|
||
assert shown["rows"] == [[MASKED, 25, MASKED], ["13900000009", 3, 1200]]
|
||
assert masked == ["联系方式", "金额"]
|
||
assert snapshot["rows"][0][0] == "13900000001", "原快照不动"
|
||
by_name, masked = inquiry_mod._masked_view(snapshot, {"笔数"}, inquiry_mod._Secrets())
|
||
assert [r[1] for r in by_name["rows"]] == [MASKED, MASKED] and masked == ["笔数"]
|
||
assert by_name["column_types"]["笔数"] == "text"
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# SQL:只换字面量
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture
|
||
def people() -> inquiry_mod._Secrets:
|
||
return secrets_of({"columns": ["buyer_phone", "age"], "rows": [["13900000001", 25], ["13900000003", 31]]},
|
||
{"buyer_phone", "age"})
|
||
|
||
|
||
@pytest.mark.parametrize("sql, shown", [
|
||
("SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone = '13900000001'",
|
||
"SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone = '已遮罩'"),
|
||
("SELECT COUNT(*) FROM sales s WHERE s.buyer_phone IN ('13900000001', '13900000003') AND age BETWEEN 25 AND 31",
|
||
"SELECT COUNT(*) FROM sales s WHERE s.buyer_phone IN ('已遮罩', '已遮罩') AND age BETWEEN 25 AND 31"),
|
||
("SELECT COUNT(*) FROM sales WHERE age = 25 AND amount > 25 AND paid_on >= '2026-01-25' LIMIT 25",
|
||
"SELECT COUNT(*) FROM sales WHERE age = 已遮罩 AND amount > 25 AND paid_on >= '2026-01-25' LIMIT 25"),
|
||
("SELECT COUNT(*) FROM sales WHERE 25 = age AND note LIKE '%13900000003%' -- 13900000001 的单\nLIMIT 20",
|
||
"SELECT COUNT(*) FROM sales WHERE 已遮罩 = age AND note LIKE '%已遮罩%' -- 已遮罩 的单\nLIMIT 20"),
|
||
("SELECT region FROM sales WHERE buyer_phone NOT IN ('13900000009') AND age <> 40",
|
||
"SELECT region FROM sales WHERE buyer_phone NOT IN ('13900000009') AND age <> 40"),
|
||
("SELECT amount FROM sales WHERE amount = 13900000001", "SELECT amount FROM sales WHERE amount = 已遮罩"),
|
||
# 和遮罩列做范围比较:边界是阈值,不是在找某个人,照原样。25 两边都有边界(<= 25、NOT BETWEEN 25 …),可这几个
|
||
# 条件合起来谁也找不着(> 31 和 <= 25 不会同时成立),夹不住 25
|
||
("SELECT COUNT(*) FROM sales WHERE age > 31 AND age <= 25 AND 25 < age AND age NOT BETWEEN 25 AND 31",
|
||
"SELECT COUNT(*) FROM sales WHERE age > 31 AND age <= 25 AND 25 < age AND age NOT BETWEEN 25 AND 31"),
|
||
# 不等、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
|
||
|
||
|
||
@pytest.fixture
|
||
def amounts() -> inquiry_mod._Secrets:
|
||
"""金额设了遮罩,金额都是五位数:和阈值、LIMIT、别的列的条件撞在一起。"""
|
||
return secrets_of({"columns": ["amount"], "rows": [[10000], [12000], [20000], [30000], [50000]]}, {"amount"})
|
||
|
||
|
||
@pytest.mark.parametrize("sql, shown", [
|
||
# 范围比较、LIMIT / OFFSET:长的数也照原样
|
||
("SELECT COUNT(*) FROM sales WHERE visits > 20000 AND amount >= 30000 AND amount BETWEEN 10000 AND 50000 "
|
||
"LIMIT 10000 OFFSET 12000",
|
||
"SELECT COUNT(*) FROM sales WHERE visits > 20000 AND amount >= 30000 AND amount BETWEEN 10000 AND 50000 "
|
||
"LIMIT 10000 OFFSET 12000"),
|
||
# 按同一个数分成两段(不低于、低于):只从一边隔开,是阈值
|
||
("SELECT SUM(amount >= 30000) AS 大单, SUM(amount < 30000) AS 小单 FROM sales",
|
||
"SELECT SUM(amount >= 30000) AS 大单, SUM(amount < 30000) AS 小单 FROM sales"),
|
||
# 选出列、算式、函数参数里的数:认不准是不是在找某一个值,够长的遮罩值换(短的数照原样)
|
||
("SELECT 20000 AS 门槛, amount - 10000 AS 差额, ROUND(amount, 2) FROM sales",
|
||
"SELECT 已遮罩 AS 门槛, amount - 已遮罩 AS 差额, ROUND(amount, 2) FROM sales"),
|
||
("SELECT COUNT(*) FROM sales WHERE amount - 30000 >= 0", "SELECT COUNT(*) FROM sales WHERE amount - 已遮罩 >= 0"),
|
||
# 等值、IN:和遮罩列比较的换,和别的列比较的、够长的遮罩值也换
|
||
("SELECT COUNT(*) FROM sales WHERE amount = 30000 OR amount IN (10000, -12000) OR visits = 20000",
|
||
"SELECT COUNT(*) FROM sales WHERE amount = 已遮罩 OR amount IN (已遮罩, -已遮罩) OR visits = 已遮罩"),
|
||
])
|
||
def test_long_masked_numbers_are_kept_only_as_thresholds_and_row_counts(amounts, sql, shown):
|
||
assert inquiry_mod._scrub_sql(sql, amounts, {"amount"}, "sqlite") == shown
|
||
|
||
|
||
@pytest.fixture
|
||
def phones() -> inquiry_mod._Secrets:
|
||
"""手机号存成整数(遮罩值在 SQL 里写成数字),年龄是短的数。"""
|
||
return secrets_of({"columns": ["buyer_phone", "age"], "rows": [[13900000001, 25], [13900000002, 31]]},
|
||
{"buyer_phone", "age"})
|
||
|
||
|
||
@pytest.mark.parametrize("kind, sql, shown", [
|
||
("sqlite", "SELECT SUM(CASE buyer_phone WHEN 13900000001 THEN 1 ELSE 0 END) AS 笔数 FROM sales",
|
||
"SELECT SUM(CASE buyer_phone WHEN 已遮罩 THEN 1 ELSE 0 END) AS 笔数 FROM sales"),
|
||
("sqlite", "SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone IN (VALUES (13900000001), (13900000002))",
|
||
"SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone IN (VALUES (已遮罩), (已遮罩))"),
|
||
("sqlite", "SELECT COUNT(*) AS 笔数 FROM sales WHERE (buyer_phone, region) = (13900000001, '华东')",
|
||
"SELECT COUNT(*) AS 笔数 FROM sales WHERE (buyer_phone, region) = (已遮罩, '华东')"),
|
||
("sqlite", "SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone IS NOT DISTINCT FROM 13900000001",
|
||
"SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone IS NOT DISTINCT FROM 已遮罩"),
|
||
("sqlite", "SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone = (13900000001)",
|
||
"SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone = (已遮罩)"),
|
||
# 选出列里的同一个号码:认不准是不是在找某一个值,够长的遮罩值照样换,不留下「13900000001 AS 号码 … = 已遮罩」
|
||
("sqlite", "SELECT COUNT(*) AS 笔数, 13900000001 AS 号码 FROM sales WHERE buyer_phone = 13900000001",
|
||
"SELECT COUNT(*) AS 笔数, 已遮罩 AS 号码 FROM sales WHERE buyer_phone = 已遮罩"),
|
||
("sqlite", "SELECT COUNT(*) FROM sales WHERE COALESCE(buyer_phone, 0) = 13900000001",
|
||
"SELECT COUNT(*) FROM sales WHERE COALESCE(buyer_phone, 0) = 已遮罩"),
|
||
# 短的数:认得出被比较的是遮罩列才换
|
||
("sqlite", "SELECT COUNT(*) FROM sales WHERE age = (25) OR (age, region) = (31, '华东') OR age IN (VALUES (25)) "
|
||
"OR age IS NOT DISTINCT FROM 31 OR age IS DISTINCT FROM 25",
|
||
"SELECT COUNT(*) FROM sales WHERE age = (已遮罩) OR (age, region) = (已遮罩, '华东') OR age IN (VALUES (已遮罩)) "
|
||
"OR age IS NOT DISTINCT FROM 已遮罩 OR age IS DISTINCT FROM 已遮罩"),
|
||
("sqlite", "SELECT SUM(CASE age WHEN 25 THEN 1 END) AS 人数, SUM(CASE WHEN age = 31 THEN 1 END) FROM sales",
|
||
"SELECT SUM(CASE age WHEN 已遮罩 THEN 1 END) AS 人数, SUM(CASE WHEN age = 已遮罩 THEN 1 END) FROM sales"),
|
||
("postgresql", "SELECT COUNT(*) FROM sales WHERE buyer_phone = ANY(ARRAY[13900000001, 13900000002]) "
|
||
"AND age <> ALL(ARRAY[25]) AND age = ANY(VALUES (31))",
|
||
"SELECT COUNT(*) FROM sales WHERE buyer_phone = ANY(ARRAY[已遮罩, 已遮罩]) "
|
||
"AND age <> ALL(ARRAY[已遮罩]) AND age = ANY(VALUES (已遮罩))"),
|
||
])
|
||
def test_a_value_looked_up_in_any_shape_is_masked_in_the_sql(phones, kind, sql, shown):
|
||
"""在找某一个值的写法不止「col = 值」:简单 CASE、IN (VALUES …)、行值、IS NOT DISTINCT FROM、套了括号、
|
||
= ANY(ARRAY[…]),都按语法树认出来;THEN 后面的 1、0 照原样。"""
|
||
assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, kind) == shown
|
||
|
||
|
||
@pytest.mark.parametrize("sql, shown", [
|
||
("SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN 13900000001 AND 13900000001",
|
||
"SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN 已遮罩 AND 已遮罩"),
|
||
("SELECT COUNT(*) FROM sales WHERE buyer_phone >= 13900000001 AND buyer_phone <= 13900000001",
|
||
"SELECT COUNT(*) FROM sales WHERE buyer_phone >= 已遮罩 AND buyer_phone <= 已遮罩"),
|
||
("SELECT COUNT(*) FROM sales WHERE 13900000001 <= buyer_phone AND buyer_phone <= 13900000001",
|
||
"SELECT COUNT(*) FROM sales WHERE 已遮罩 <= buyer_phone AND buyer_phone <= 已遮罩"),
|
||
# 大于或小于就是不等于
|
||
("SELECT COUNT(*) FROM sales WHERE NOT (buyer_phone > 13900000001 OR buyer_phone < 13900000001)",
|
||
"SELECT COUNT(*) FROM sales WHERE NOT (buyer_phone > 已遮罩 OR buyer_phone < 已遮罩)"),
|
||
# 整数的 < v + 1 就是 <= v
|
||
("SELECT COUNT(*) FROM sales WHERE buyer_phone >= 13900000001 AND buyer_phone < 13900000002",
|
||
"SELECT COUNT(*) FROM sales WHERE buyer_phone >= 已遮罩 AND buyer_phone < 已遮罩"),
|
||
("SELECT COUNT(*) FROM sales WHERE age >= 25 AND age <= 25",
|
||
"SELECT COUNT(*) FROM sales WHERE age >= 已遮罩 AND age <= 已遮罩"),
|
||
("SELECT COUNT(*) FROM sales WHERE buyer_phone >= '13900000001' AND buyer_phone <= '13900000001'",
|
||
"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):照样夹住了,算式里的数都换(留着哪个都可能算得出号码)
|
||
("SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN 13900000001 AND 13900000002 - 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 已遮罩 + 已遮罩"),
|
||
])
|
||
def test_a_range_that_pins_a_single_value_is_a_lookup(phones, sql, shown):
|
||
"""两头是同一个值的范围(BETWEEN v AND v、>= v AND <= v)、大于或小于同一个值、整数的半开区间,都只夹住了一个值,
|
||
是在找它,和等值比较一样换。"""
|
||
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("kind, sql, sealed", [
|
||
# 取模、整除算出来的边界和等值:125 % 100、MOD(125, 100) 是 25,50 DIV 2、51 // 2 也是
|
||
("sqlite", "SELECT COUNT(*) FROM sales WHERE age >= 25 AND age <= 125 % 100",
|
||
"SELECT COUNT(*) FROM sales WHERE age >= 已遮罩 AND age <= 已遮罩 % 已遮罩"),
|
||
("postgresql", "SELECT COUNT(*) FROM sales WHERE age = MOD(125, 100)",
|
||
"SELECT COUNT(*) FROM sales WHERE age = MOD(已遮罩, 已遮罩)"),
|
||
("mysql", "SELECT COUNT(*) FROM sales WHERE age >= 25 AND age <= 50 DIV 2",
|
||
"SELECT COUNT(*) FROM sales WHERE age >= 已遮罩 AND age <= 已遮罩 DIV 已遮罩"),
|
||
("duckdb", "SELECT COUNT(*) FROM sales WHERE age >= 25 AND age <= 51 // 2",
|
||
"SELECT COUNT(*) FROM sales WHERE age >= 已遮罩 AND age <= 已遮罩 // 已遮罩"),
|
||
# SQLite、Postgres 里两个整数相除只留整数部分:53 / 2 是 26,age < 26 和 age >= 25 夹住 25
|
||
("sqlite", "SELECT COUNT(*) FROM sales WHERE age >= 25 AND age < 53 / 2",
|
||
"SELECT COUNT(*) FROM sales WHERE age >= 已遮罩 AND age < 已遮罩 / 已遮罩"),
|
||
("postgresql", "SELECT COUNT(*) FROM sales WHERE buyer_phone >= 13900000001 AND buyer_phone < 27800000005 / 2",
|
||
"SELECT COUNT(*) FROM sales WHERE buyer_phone >= 已遮罩 AND buyer_phone < 已遮罩 / 已遮罩"),
|
||
])
|
||
def test_a_value_worked_out_with_remainders_or_whole_division_is_read_as_that_value(phones, kind, sql, sealed):
|
||
"""第 3 轮复核:取模(%、MOD)、整除(MySQL 的 DIV、DuckDB 的 //)写成的算式以前不按算出来的值认,age >= 25 AND
|
||
age <= 125 % 100 照原样封存;整数相除在 SQLite、Postgres 里只留整数部分,以前按小数算(53 / 2 算成 26.5),
|
||
夹住的值也认不出。现在都按数据源算出来的那一个值认,算式里的数都换,正文里也不免遮。"""
|
||
assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, kind) == sealed
|
||
assert not inquiry_mod._shown_literals([(sql, kind, {"buyer_phone", "age"})], phones) & {"25", "13900000001"}
|
||
|
||
|
||
@pytest.mark.parametrize("kind, divided", [("mysql", "53 / 2"), ("duckdb", "53 / 2"), ("sqlite", "53.0 / 2")])
|
||
def test_division_that_keeps_the_fraction_is_read_as_written(phones, kind, divided):
|
||
"""MySQL、DuckDB 的 / 照小数算,SQLite 里写成小数的也是:53 / 2、53.0 / 2 是 26.5,age >= 25 AND age < 26.5 里
|
||
25、26 都成立,没夹住,照原样。"""
|
||
sql = f"SELECT COUNT(*) FROM sales WHERE age >= 25 AND age < {divided}"
|
||
assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, kind) == sql
|
||
|
||
|
||
def test_type_parameters_of_a_cast_bound_are_left_alone(amounts):
|
||
"""第 3 轮复核:夹住某一个值的边界写成 CAST(v AS DECIMAL(10, 2)) 时,类型参数 10、2 以前也当成算出边界的数换掉,
|
||
封存的 SQL 成了 DECIMAL(已遮罩, 已遮罩),SQL 检查结果的片段里别处的 10、2 也跟着换。只换算出边界的那个数;Oracle
|
||
的 CAST … DEFAULT 0 ON CONVERSION ERROR 里的 0 也不算出值,照原样。"""
|
||
sql = ("SELECT COUNT(*) FROM sales WHERE amount >= CAST(30000 AS DECIMAL(10, 2)) "
|
||
"AND amount <= CAST(30000 AS DECIMAL(10, 2))")
|
||
assert inquiry_mod._scrub_sql(sql, amounts, {"amount"}, "sqlite") == sql.replace("30000", MASKED)
|
||
assert inquiry_mod._literal_swaps(sql, amounts, {"amount"}, "sqlite") == {"30000"}
|
||
checks = [{"code": "stock_summed", "level": "warning", "table": "sales", "message": "存量跨日期求和",
|
||
"sql_excerpt": "ROUND(SUM(amount), 2) >= 10"}]
|
||
[check] = inquiry_mod._masked_checks(checks, sql, amounts, {"amount"}, "sqlite")
|
||
assert check["sql_excerpt"] == "ROUND(SUM(amount), 2) >= 10"
|
||
fallback = ("SELECT COUNT(*) FROM sales WHERE amount >= CAST('30000' AS NUMBER DEFAULT 0 ON CONVERSION ERROR) "
|
||
"AND amount <= CAST('30000' AS NUMBER DEFAULT 0 ON CONVERSION ERROR)")
|
||
assert inquiry_mod._scrub_sql(fallback, amounts, {"amount"}, "oracle") == fallback.replace("30000", MASKED)
|
||
|
||
|
||
@pytest.mark.parametrize("sql, sealed", [
|
||
# 遮罩列套了 CAST、汇总:被比较的一边取的还是它的值,短的数照样换
|
||
("SELECT COUNT(*) FROM sales WHERE CAST(age AS INTEGER) = 25",
|
||
"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 "
|
||
"AND buyer_phone <= 13900000001",
|
||
"SELECT COUNT(*) FROM sales WHERE region = '华东' AND buyer_phone >= 已遮罩 AND buyer_phone <= 已遮罩"),
|
||
("sqlite", "SELECT COUNT(*) FROM sales WHERE (age >= 25 AND age <= 25) OR region = '华南'",
|
||
"SELECT COUNT(*) FROM sales WHERE (age >= 已遮罩 AND age <= 已遮罩) OR region = '华南'"),
|
||
# CASE WHEN 里的一个条件
|
||
("sqlite", "SELECT COUNT(CASE WHEN buyer_phone BETWEEN 13900000001 AND 13900000001 THEN 1 END) AS 笔数 FROM sales",
|
||
"SELECT COUNT(CASE WHEN buyer_phone BETWEEN 已遮罩 AND 已遮罩 THEN 1 END) AS 笔数 FROM sales"),
|
||
# 整数的开区间:两头都不是那个值,夹住的是中间那一个,两头一样换(留着 24、26 就等于写出了 25)
|
||
("sqlite", "SELECT COUNT(*) FROM sales WHERE age > 24 AND age < 26",
|
||
"SELECT COUNT(*) FROM sales WHERE age > 已遮罩 AND age < 已遮罩"),
|
||
("sqlite", "SELECT COUNT(*) FROM sales WHERE age >= 25 AND age < 26",
|
||
"SELECT COUNT(*) FROM sales WHERE age >= 已遮罩 AND age < 已遮罩"),
|
||
("sqlite", "SELECT COUNT(*) FROM sales WHERE buyer_phone > 13900000000 AND buyer_phone < 13900000002",
|
||
"SELECT COUNT(*) FROM sales WHERE buyer_phone > 已遮罩 AND buyer_phone < 已遮罩"),
|
||
# 不低于、大于同一个值,二者只成立一个(MySQL 的 XOR):就是等于它
|
||
("mysql", "SELECT COUNT(*) FROM sales WHERE age >= 25 XOR age > 25",
|
||
"SELECT COUNT(*) FROM sales WHERE age >= 已遮罩 XOR age > 已遮罩"),
|
||
])
|
||
def test_a_value_pinned_within_one_condition_is_still_a_lookup(phones, kind, sql, shown):
|
||
"""同一个条件里(从比较往上只越过且、或、非、异或、括号)夹住的,按条件合起来的真假认出来照样换:旁边还有别的
|
||
条件、写在 CASE WHEN 里、整数的开区间、XOR。"""
|
||
assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, kind) == shown
|
||
|
||
|
||
@pytest.mark.parametrize("sql, shown", [
|
||
("SELECT region FROM sales GROUP BY region HAVING SUM(amount) BETWEEN 20000 AND 20000",
|
||
"SELECT region FROM sales GROUP BY region HAVING SUM(amount) BETWEEN 已遮罩 AND 已遮罩"),
|
||
("SELECT region FROM sales GROUP BY region HAVING SUM(amount) >= 20000 AND SUM(amount) <= 20000",
|
||
"SELECT region FROM sales GROUP BY region HAVING SUM(amount) >= 已遮罩 AND SUM(amount) <= 已遮罩"),
|
||
])
|
||
def test_the_same_aggregate_pinned_from_both_sides_is_a_lookup(amounts, sql, shown):
|
||
"""列名认不出的(汇总值、函数)只和写得一样的表达式配:HAVING 里同一个汇总值两头相同,照样是在找它。"""
|
||
assert inquiry_mod._scrub_sql(sql, amounts, {"amount"}, "sqlite") == shown
|
||
|
||
|
||
@pytest.mark.parametrize("sql", [
|
||
# 两个 WHEN 分支共用一个边界:各是各的条件,20000 只是分档的界
|
||
f"SELECT {BANDS} AS 档位, COUNT(*) AS 笔数 FROM sales GROUP BY 1",
|
||
# 分组时把 CASE 原样再写一遍:选出列和 GROUP BY 是同一个算法的两处写法,都不筛行
|
||
f"SELECT {BANDS} AS 档位, COUNT(*) AS 笔数 FROM sales GROUP BY {BANDS}",
|
||
# 两个选出列各按一边数(写成汇总、写成标量子查询都一样)
|
||
"SELECT SUM(CASE WHEN amount <= 20000 THEN 1 ELSE 0 END) AS 小单, "
|
||
"SUM(CASE WHEN amount >= 20000 THEN 1 ELSE 0 END) AS 大单 FROM sales",
|
||
"SELECT (SELECT COUNT(*) FROM sales WHERE amount <= 20000) AS 小单, "
|
||
"(SELECT COUNT(*) FROM sales WHERE amount >= 20000) AS 大单",
|
||
# UNION 的两支
|
||
"SELECT '小单' AS 档位, COUNT(*) AS 笔数 FROM sales WHERE amount <= 20000 "
|
||
"UNION ALL SELECT '大单', COUNT(*) FROM sales WHERE amount >= 20000",
|
||
# IIF 的两个分支:华东按一边数,别的区域按另一边数
|
||
"SELECT SUM(IIF(region = '华东', amount <= 20000, amount >= 20000)) AS 笔数 FROM sales",
|
||
# WHERE 里一笔的上限、HAVING 里汇总值的下限:比的不是同一个东西(列名认不出的只和写得一样的表达式配)
|
||
"SELECT region, COUNT(*) AS 笔数 FROM sales WHERE amount <= 20000 GROUP BY region HAVING SUM(amount) >= 20000",
|
||
# 同一个条件里,算式不和金额这一列配:金额在 15000 到 20000 之间
|
||
"SELECT COUNT(*) FROM sales WHERE amount <= 20000 AND amount + 5000 >= 20000",
|
||
# 边界两边都有比较,可合起来不只认 20000 这一个值:不超过 20000 的,或 20000 以上访问又多的
|
||
"SELECT COUNT(*) FROM sales WHERE amount <= 20000 OR (amount >= 20000 AND visits > 25000)",
|
||
])
|
||
def test_band_edges_shared_by_separate_conditions_are_thresholds(amounts, sql):
|
||
"""第 3 轮收全句所有范围比较一起配对,列名认不出的(SUM(amount)、函数)还和哪一列都配:分档的两个 WHEN 分支、
|
||
两个选出列、UNION 两支各写一边,共用的那个边界就被当成「夹住了某一笔」遮掉,回答里「以 20000 元为界」也跟着遮。
|
||
这些都是阈值:照原样封存,正文里复述也不遮。"""
|
||
assert inquiry_mod._scrub_sql(sql, amounts, {"amount"}, "sqlite") == sql
|
||
assert "20000" in inquiry_mod._shown_literals([(sql, "sqlite", {"amount"})], amounts)
|
||
|
||
|
||
_PHONE, _NEXT = "13900000001", "13900000002"
|
||
|
||
|
||
@pytest.mark.parametrize("kind, sql", [
|
||
# 内连接的 ON 和 WHERE 筛的是同一批行:各写一边照样夹住
|
||
("sqlite", "SELECT o.region FROM sales o JOIN buyers b ON b.id = o.bid AND b.buyer_phone >= {v} "
|
||
"WHERE b.buyer_phone <= {v}"),
|
||
# WHERE 和 HAVING 比的是同一列(分组的键)
|
||
("sqlite", "SELECT buyer_phone, COUNT(*) FROM sales WHERE buyer_phone >= {v} GROUP BY buyer_phone "
|
||
"HAVING buyer_phone <= {v}"),
|
||
# INTERSECT 两支都要成立;EXCEPT 是前一支成立、后一支不成立(不低于 25 又不高于 25)
|
||
("sqlite", "SELECT id FROM sales WHERE age >= {a} INTERSECT SELECT id FROM sales WHERE age <= {a}"),
|
||
("sqlite", "SELECT id FROM sales WHERE age >= {a} EXCEPT SELECT id FROM sales WHERE age > {a}"),
|
||
# 一半包在 CASE、布尔比较、函数、算式里
|
||
("sqlite", "SELECT COUNT(*) FROM sales WHERE age >= {a} AND CASE WHEN age <= {a} THEN 1 ELSE 0 END = 1"),
|
||
("sqlite", "SELECT COUNT(*) FROM sales WHERE age >= {a} AND (age <= {a}) = TRUE"),
|
||
("sqlite", "SELECT COUNT(*) FROM sales WHERE age >= {a} AND COALESCE(age <= {a}, 0)"),
|
||
("sqlite", "SELECT COUNT(*) FROM sales WHERE IIF(age >= {a}, 1, 0) + IIF(age <= {a}, 1, 0) = 2"),
|
||
# 嵌套的 CASE:里层只在外层成立时才算
|
||
("sqlite", "SELECT SUM(CASE WHEN age >= {a} THEN CASE WHEN age <= {a} THEN 1 END END) AS 人数 FROM sales"),
|
||
# 选出列里的 FILTER 数的是 WHERE 筛过的行
|
||
("duckdb", "SELECT COUNT(*) FILTER (WHERE age >= {a}) AS 人数 FROM sales WHERE age <= {a}"),
|
||
# 两个汇总值相减:不低于 25 的减去高于 25 的,就是正好 25 的
|
||
("sqlite", "SELECT SUM(age >= {a}) - SUM(age > {a}) AS 人数 FROM sales"),
|
||
# 拆进子查询、CTE
|
||
("sqlite", "SELECT COUNT(*) FROM sales WHERE age >= {a} AND id IN (SELECT id FROM sales WHERE age <= {a})"),
|
||
("sqlite", "WITH a AS (SELECT * FROM sales WHERE buyer_phone >= {v}) SELECT COUNT(*) FROM a "
|
||
"WHERE buyer_phone <= {v}"),
|
||
])
|
||
def test_a_lookup_split_across_clauses_is_still_a_lookup(phones, kind, sql):
|
||
"""把找某一个值的两半拆到同一行集的不同地方(ON 和 WHERE、WHERE 和 HAVING、INTERSECT 两支、包在 CASE 或函数里、
|
||
子查询、CTE):第 4 轮只在同一个条件里认,两半各自当阈值照原样留着,长的号码随即被当成「摆在读者眼前」,回答
|
||
正文里也不遮,最后一道关跟着放行。只有分档的几种写法(CASE 的不同分支、不同的选出列、UNION 的不同支)各算各的,
|
||
别的都按同一行集看:两半照样换掉,正文里也不免遮。"""
|
||
written = sql.format(v=_PHONE, a=25)
|
||
assert inquiry_mod._scrub_sql(written, phones, {"buyer_phone", "age"}, kind) == sql.format(v=MASKED, a=MASKED)
|
||
assert inquiry_mod._shown_literals([(written, kind, {"buyer_phone", "age"})], phones) == set()
|
||
|
||
|
||
@pytest.mark.parametrize("sql, sealed", [
|
||
# 整数上 age <= 24 和 age < 25 一样,从下面把 25 隔开;age >= 26 和 age > 25 一样,从上面隔开
|
||
("SELECT COUNT(*) FROM sales WHERE NOT (age <= 24 OR age >= 26)",
|
||
"SELECT COUNT(*) FROM sales WHERE NOT (age <= 已遮罩 OR age >= 已遮罩)"),
|
||
("SELECT COUNT(*) FROM sales WHERE age >= 25 AND NOT age >= 26",
|
||
"SELECT COUNT(*) FROM sales WHERE age >= 已遮罩 AND NOT age >= 已遮罩"),
|
||
(f"SELECT COUNT(*) FROM sales WHERE buyer_phone >= {_PHONE} AND NOT buyer_phone >= {_NEXT}",
|
||
"SELECT COUNT(*) FROM sales WHERE buyer_phone >= 已遮罩 AND NOT buyer_phone >= 已遮罩"),
|
||
# 就是 age <> 25
|
||
("SELECT COUNT(*) FROM sales WHERE age <= 24 OR age >= 26",
|
||
"SELECT COUNT(*) FROM sales WHERE age <= 已遮罩 OR age >= 已遮罩"),
|
||
])
|
||
def test_neighbouring_whole_numbers_pin_the_value_between_them(phones, sql, sealed):
|
||
assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, "sqlite") == sealed
|
||
assert inquiry_mod._shown_literals([(sql, "sqlite", {"buyer_phone", "age"})], phones) == set()
|
||
|
||
|
||
@pytest.mark.parametrize("sql", [
|
||
"SELECT COUNT(*) FROM sales WHERE age >= 18 AND age <= 30",
|
||
"SELECT CASE WHEN age <= 24 THEN '24岁及以下' WHEN age >= 26 THEN '26岁及以上' END AS 年龄段, COUNT(*) AS 人数 "
|
||
"FROM sales GROUP BY 1",
|
||
"SELECT COUNT(*) FROM sales WHERE age BETWEEN 18 AND 24",
|
||
])
|
||
def test_ordinary_ranges_are_not_pinned_by_neighbouring_whole_numbers(phones, sql):
|
||
assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, "sqlite") == sql
|
||
|
||
|
||
@pytest.mark.parametrize("sql, sealed", [
|
||
# 被更宽的范围吸收了,条件合起来不只认这一个号码,可 BETWEEN v AND v 本身就写出了它
|
||
(f"SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN {_PHONE} AND {_PHONE} OR buyer_phone > 13000000000",
|
||
"SELECT COUNT(*) FROM sales WHERE buyer_phone BETWEEN 已遮罩 AND 已遮罩 OR buyer_phone > 13000000000"),
|
||
# 旁边还有矛盾的条件(谁也查不着):两半紧挨着、隔着别的条件写都一样
|
||
(f"SELECT COUNT(*) FROM sales WHERE buyer_phone >= {_PHONE} AND buyer_phone <= {_PHONE} "
|
||
"AND buyer_phone > 13900000005",
|
||
"SELECT COUNT(*) FROM sales WHERE buyer_phone >= 已遮罩 AND buyer_phone <= 已遮罩 AND buyer_phone > 13900000005"),
|
||
(f"SELECT COUNT(*) FROM sales WHERE buyer_phone >= {_PHONE} AND buyer_phone > 13900000005 "
|
||
f"AND buyer_phone <= {_PHONE}",
|
||
"SELECT COUNT(*) FROM sales WHERE buyer_phone >= 已遮罩 AND buyer_phone > 13900000005 AND buyer_phone <= 已遮罩"),
|
||
# 大于或小于同一个值(就是不等于),旁边又或上一个更宽的范围
|
||
(f"SELECT COUNT(*) FROM sales WHERE buyer_phone > {_PHONE} OR buyer_phone > 13000000000 OR buyer_phone < {_PHONE}",
|
||
"SELECT COUNT(*) FROM sales WHERE buyer_phone > 已遮罩 OR buyer_phone > 13000000000 OR buyer_phone < 已遮罩"),
|
||
])
|
||
def test_a_range_that_names_one_value_is_a_lookup_whatever_surrounds_it(phones, sql, sealed):
|
||
"""BETWEEN v AND v、>= v AND <= v、> v OR < v 不论整数都只认一个值:条件里哪一层这样写都算在找它,旁边的条件把它
|
||
吸收了、和它矛盾了也一样(规格要求这几种必须继续遮)。整数上的半开、开区间只看整个条件,和相邻的分档连成一段的
|
||
不算(见下面成百个分档的那条)。"""
|
||
assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, "sqlite") == sealed
|
||
assert _PHONE not in inquiry_mod._shown_literals([(sql, "sqlite", {"buyer_phone", "age"})], phones)
|
||
|
||
|
||
@pytest.fixture
|
||
def ages() -> inquiry_mod._Secrets:
|
||
"""年龄设了遮罩,都是两位数:和天数修饰、日期、标签、别的列上的数撞在一起。"""
|
||
return secrets_of({"columns": ["age"], "rows": [[25], [30], [20], [31]]}, {"age"})
|
||
|
||
|
||
@pytest.mark.parametrize("sql, shown", [
|
||
("SELECT CASE WHEN age BETWEEN 18 AND 30 THEN '青年' WHEN age BETWEEN 30 AND 45 THEN '中年' END AS 年龄段, "
|
||
"COUNT(*) AS 人数 FROM sales GROUP BY 1", {"18", "30", "45"}),
|
||
# 标签里写着同样的数:短的数在字符串里不换,阈值也不跟着遮
|
||
("SELECT CASE WHEN age BETWEEN 18 AND 30 THEN '18-30岁' WHEN age BETWEEN 30 AND 45 THEN '30-45岁' END AS 年龄段, "
|
||
"COUNT(*) AS 人数 FROM sales GROUP BY 1", {"18", "30", "45"}),
|
||
])
|
||
def test_overlapping_age_bands_are_thresholds(ages, sql, shown):
|
||
"""年龄段的两个 WHEN 分支共用边界 30:同上,是分档的界。"""
|
||
assert inquiry_mod._scrub_sql(sql, ages, {"age"}, "sqlite") == sql
|
||
assert inquiry_mod._shown_literals([(sql, "sqlite", {"age"})], ages) == shown
|
||
|
||
|
||
@pytest.mark.parametrize("sql, shown", [
|
||
("SELECT COUNT(*) AS 笔数, 13900000001 AS 号码 FROM sales WHERE buyer_phone >= 13900000001",
|
||
"SELECT COUNT(*) AS 笔数, 已遮罩 AS 号码 FROM sales WHERE buyer_phone >= 已遮罩"),
|
||
("SELECT COUNT(*) FROM sales WHERE buyer_phone >= 13900000001 AND buyer_phone - 13900000001 < 5",
|
||
"SELECT COUNT(*) FROM sales WHERE buyer_phone >= 已遮罩 AND buyer_phone - 已遮罩 < 5"),
|
||
])
|
||
def test_a_number_masked_elsewhere_in_the_sql_is_not_kept_as_a_threshold(phones, sql, shown):
|
||
"""同一个号码在选出列、算式里被换掉了,条件里的单边范围也不留:留着就把那里换掉的露了。"""
|
||
assert inquiry_mod._scrub_sql(sql, phones, {"buyer_phone", "age"}, "sqlite") == shown
|
||
assert inquiry_mod._shown_literals([(sql, "sqlite", {"buyer_phone", "age"})], phones) <= {"5"}
|
||
|
||
|
||
@pytest.mark.parametrize("sql, shown", [
|
||
# 天数修饰、日期里的数:短的数在字符串里从来不换,日期里的一截也不认
|
||
("SELECT COUNT(*) AS 人数 FROM sales WHERE age >= 30 AND paid_on >= DATE('2026-02-03', '-30 days')", {"30"}),
|
||
("SELECT COUNT(*) AS 人数 FROM sales WHERE age > 31 AND paid_on <= '2026-01-31'", {"31"}),
|
||
# 标签里的数
|
||
("SELECT CASE WHEN age < 30 THEN '30岁以下' ELSE '30岁及以上' END AS 年龄段, COUNT(*) AS 人数 FROM sales GROUP BY 1",
|
||
{"30"}),
|
||
# 没设遮罩的列上的等值:短的数只在和遮罩列比较时换
|
||
("SELECT COUNT(*) AS 人数 FROM sales WHERE age >= 30 AND amount = 30", {"30"}),
|
||
])
|
||
def test_a_threshold_stays_when_the_same_number_is_left_alone_elsewhere(ages, sql, shown):
|
||
"""第 3 轮把「这条 SQL 别的字面量里写着同一个数」一律当成那里换掉了:日期、天数、标签里的数,没设遮罩的列上的
|
||
等值,那里其实一个字没换,阈值却跟着遮了,回答正文又照样写着它。现在只有别处确实换掉了才不留。"""
|
||
assert inquiry_mod._scrub_sql(sql, ages, {"age"}, "sqlite") == sql
|
||
assert inquiry_mod._shown_literals([(sql, "sqlite", {"age"})], ages) == shown
|
||
|
||
|
||
@pytest.mark.parametrize("sql, sealed", [
|
||
# 找某一个年龄的等值把 30 换掉了:阈值留着就把它露了
|
||
("SELECT COUNT(*) AS 人数 FROM sales WHERE age >= 30 AND age = 30",
|
||
"SELECT COUNT(*) AS 人数 FROM sales WHERE age >= 已遮罩 AND age = 已遮罩"),
|
||
("SELECT COUNT(*) AS 人数 FROM sales WHERE age > 31 OR age IN ('31', '99')",
|
||
"SELECT COUNT(*) AS 人数 FROM sales WHERE age > 已遮罩 OR age IN ('已遮罩', '99')"),
|
||
])
|
||
def test_a_threshold_goes_when_the_same_number_is_masked_elsewhere(ages, sql, sealed):
|
||
assert inquiry_mod._scrub_sql(sql, ages, {"age"}, "sqlite") == sealed
|
||
assert inquiry_mod._shown_literals([(sql, "sqlite", {"age"})], ages) == set()
|
||
|
||
|
||
@pytest.mark.parametrize("sql, sealed", [
|
||
("SELECT COUNT(*) AS 人数 FROM sales WHERE age = 30 AND visits >= 30",
|
||
"SELECT COUNT(*) AS 人数 FROM sales WHERE age = 已遮罩 AND visits >= 30"),
|
||
("SELECT region FROM sales WHERE age IN (30, 31) AND visits > 30 LIMIT 31",
|
||
"SELECT region FROM sales WHERE age IN (已遮罩, 已遮罩) AND visits > 30 LIMIT 31"),
|
||
# 开区间夹住的 25 两头都换了,别的列上的 25 照原样
|
||
("SELECT COUNT(*) AS 人数 FROM sales WHERE age > 24 AND age < 26 AND visits >= 25",
|
||
"SELECT COUNT(*) AS 人数 FROM sales WHERE age > 已遮罩 AND age < 已遮罩 AND visits >= 25"),
|
||
])
|
||
def test_a_short_number_masked_elsewhere_leaves_other_columns_and_limits_alone(ages, sql, sealed):
|
||
"""同一个短的数在别处换掉了,别的列上的阈值、LIMIT 撞上它照原样(已知局限):短的数本来就不按值遮,撞上只是巧合,
|
||
一律换会把阈值、行数改得面目全非(第 2 轮起「age = 25 AND amount > 25 … LIMIT 25」就照原样留着阈值和行数)。
|
||
正文里不免遮——反正短的数正文里也不按值遮。够长的数在别的列上照样换(visits = 20000 那条)。"""
|
||
assert inquiry_mod._scrub_sql(sql, ages, {"age"}, "sqlite") == sealed
|
||
assert inquiry_mod._shown_literals([(sql, "sqlite", {"age"})], ages) == set()
|
||
|
||
|
||
@pytest.mark.parametrize("sql, sealed", [
|
||
# 批次号里的 20000 是日期一样的写法,按值认不出、没换:阈值照原样
|
||
("SELECT COUNT(*) FROM sales WHERE amount >= 20000 AND batch = '20000-01'",
|
||
"SELECT COUNT(*) FROM sales WHERE amount >= 20000 AND batch = '20000-01'"),
|
||
# 够长的遮罩值在别的列的等值里也换:换掉了,阈值也不留
|
||
("SELECT COUNT(*) FROM sales WHERE amount >= 20000 AND visits = 20000",
|
||
"SELECT COUNT(*) FROM sales WHERE amount >= 已遮罩 AND visits = 已遮罩"),
|
||
])
|
||
def test_long_numbers_go_by_what_was_actually_masked_elsewhere(amounts, sql, sealed):
|
||
assert inquiry_mod._scrub_sql(sql, amounts, {"amount"}, "sqlite") == sealed
|
||
|
||
|
||
def test_numbers_shown_across_queries_go_by_what_was_actually_masked(amounts):
|
||
"""几条查询之间同理:别的查询里同一个数只写在批次号里、没换,正文里复述阈值照样不遮;别的查询里确实换掉了(和
|
||
金额做等值比较、分词都分不了整条按值换),正文里也遮。"""
|
||
shown = inquiry_mod._shown_literals
|
||
threshold = ("SELECT COUNT(*) FROM sales WHERE amount >= 20000 LIMIT 5", "sqlite", {"amount"})
|
||
assert shown([threshold, ("SELECT COUNT(*) FROM sales WHERE batch = '20000-01'", "sqlite", {"amount"})],
|
||
amounts) == {"20000", "5"}
|
||
assert shown([threshold, ("SELECT COUNT(*) FROM sales WHERE amount = 20000", "sqlite", {"amount"})],
|
||
amounts) == {"5"}
|
||
assert shown([threshold, ("SELECT 'x FROM sales WHERE amount = 20000", "sqlite", {"amount"})], amounts) == {"5"}
|
||
|
||
|
||
def test_a_threshold_named_in_an_alias_or_a_comment_stays(amounts):
|
||
"""agent 把阈值写进别名、注释(AS 超过30000的笔数):名字、注释照常按值换,阈值本身照原样,回答正文里复述也不遮。"""
|
||
sql = "SELECT SUM(amount >= 30000) AS 超过30000的笔数 FROM sales -- 30000 以上算大单"
|
||
assert inquiry_mod._scrub_sql(sql, amounts, {"amount"}, "sqlite") == (
|
||
"SELECT SUM(amount >= 30000) AS 超过已遮罩的笔数 FROM sales -- 已遮罩 以上算大单")
|
||
assert inquiry_mod._shown_literals([(sql, "sqlite", {"amount"})], amounts) == {"30000"}
|
||
|
||
|
||
def test_sql_starting_with_blank_lines_is_read_at_the_right_places():
|
||
"""agent 写的 SQL 常以换行开头:解析时去掉了开头的空白,按语法树认出的位置要补回来,才对得上原文里的词——阈值、
|
||
行数照原样,找的那个号码换掉。"""
|
||
secrets = secrets_of({"columns": ["buyer_phone", "amount"], "rows": [[13900000001, 30000]]}, {"buyer_phone", "amount"})
|
||
sql = "\n SELECT COUNT(*) FROM sales WHERE amount >= 30000 AND buyer_phone IN (13900000001, 1) LIMIT 30000"
|
||
assert inquiry_mod._scrub_sql(sql, secrets, {"buyer_phone", "amount"}, "sqlite") == (
|
||
"\n SELECT COUNT(*) FROM sales WHERE amount >= 30000 AND buyer_phone IN (已遮罩, 1) LIMIT 30000")
|
||
assert inquiry_mod._shown_literals([(sql, "sqlite", {"buyer_phone", "amount"})], secrets) == {"30000"}
|
||
|
||
|
||
def test_string_range_bounds_are_masked_by_value(people):
|
||
"""字符串的范围边界按值换:写成字符串的长号码做边界,多半是在找那一个人;日期边界不是遮罩值,照原样。"""
|
||
sql = "SELECT COUNT(*) FROM sales WHERE buyer_phone >= '13900000001' AND buyer_phone < '13900000003' " \
|
||
"AND paid_on >= '2026-01-25'"
|
||
assert inquiry_mod._scrub_sql(sql, people, {"buyer_phone", "age"}, "sqlite") == (
|
||
"SELECT COUNT(*) FROM sales WHERE buyer_phone >= '已遮罩' AND buyer_phone < '已遮罩' "
|
||
"AND paid_on >= '2026-01-25'")
|
||
|
||
|
||
def test_sql_that_cannot_be_parsed_keeps_no_thresholds():
|
||
"""SQL 解析不了:认不准哪个数是阈值,一律不免——够长的遮罩值在哪都换,简单的等值比较照样按词认(短的数也换)。"""
|
||
secrets = secrets_of({"columns": ["age", "amount"], "rows": [[25, 30000], [31, 10000]]}, {"age", "amount"})
|
||
sql = "SELECT COUNT(*) FROM sales WHERE age = 25 AND amount >= 30000 AND visits > 31 LIMIT 10000 GROUP"
|
||
assert inquiry_mod._scrub_sql(sql, secrets, {"age", "amount"}, "sqlite") == (
|
||
"SELECT COUNT(*) FROM sales WHERE age = 已遮罩 AND amount >= 已遮罩 AND visits > 31 LIMIT 已遮罩 GROUP")
|
||
assert inquiry_mod._shown_literals([(sql, "sqlite", {"age", "amount"})], secrets) == set()
|
||
|
||
|
||
def test_only_thresholds_and_row_counts_count_as_shown(amounts):
|
||
"""照原样留在 SQL 里的只有范围边界(没夹住某一个值的)和 LIMIT / OFFSET 的行数;选出列、函数参数、CASE、夹住
|
||
某一个值的范围里的数不算,在别处(这一条或别的查询里)被换掉的数也不算。"""
|
||
def shown(*sqls: str) -> set[str]:
|
||
return inquiry_mod._shown_literals([(sql, "sqlite", {"amount"}) for sql in sqls], amounts)
|
||
|
||
assert shown("SELECT COUNT(*) FROM sales WHERE amount >= 30000 AND visits > 20000 LIMIT 10000 OFFSET 12000") == {
|
||
"30000", "20000", "10000", "12000"}
|
||
assert shown("SELECT 50000 AS 门槛, SUM(CASE amount WHEN 12000 THEN 1 END), ROUND(amount, 10000) FROM sales "
|
||
"WHERE amount BETWEEN 20000 AND 20000") == set()
|
||
assert shown("SELECT COUNT(*) FROM sales WHERE amount >= 30000", "SELECT COUNT(*) FROM sales WHERE amount = 30000"
|
||
) == set()
|
||
assert shown("SELECT COUNT(*) FROM sales WHERE amount >= 30000 AND note LIKE '%30000%'") == set()
|
||
|
||
|
||
@pytest.mark.parametrize("kind, sql, shown", [
|
||
("oracle", "SELECT COUNT(*) FROM sales WHERE buyer_phone = N'13900000001'",
|
||
"SELECT COUNT(*) FROM sales WHERE buyer_phone = N'已遮罩'"),
|
||
("postgresql", "SELECT COUNT(*) FROM sales WHERE buyer_phone = E'13900000001' OR note = $$13900000003$$ "
|
||
"OR note = $t$13900000001$t$ OR buyer_phone = U&'13900000003'",
|
||
"SELECT COUNT(*) FROM sales WHERE buyer_phone = E'已遮罩' OR note = $$已遮罩$$ "
|
||
"OR note = $t$已遮罩$t$ OR buyer_phone = U&'已遮罩'"),
|
||
# 名字里写着遮罩值:带引号的、不带引号的(SQLite 的中文名字可以不加引号)
|
||
("postgresql", 'SELECT COUNT(*) AS "13900000001 的单" FROM sales',
|
||
'SELECT COUNT(*) AS "已遮罩 的单" FROM sales'),
|
||
("mysql", "SELECT COUNT(*) AS `13900000001 的单` FROM sales", "SELECT COUNT(*) AS `已遮罩 的单` FROM sales"),
|
||
])
|
||
def test_prefixed_strings_and_names_are_scrubbed_too(people, kind, sql, shown):
|
||
assert inquiry_mod._scrub_sql(sql, people, {"buyer_phone", "age"}, kind) == shown
|
||
|
||
|
||
def test_a_name_written_into_an_unquoted_alias_is_scrubbed():
|
||
secrets = secrets_of({"columns": ["buyer_name"], "rows": [["张伟"], ["李娜"]]}, {"buyer_name"})
|
||
assert inquiry_mod._scrub_sql("SELECT COUNT(*) AS 张伟的单数 FROM sales WHERE buyer_name = '张伟'", secrets,
|
||
{"buyer_name"}, "sqlite") == (
|
||
"SELECT COUNT(*) AS 已遮罩的单数 FROM sales WHERE buyer_name = '已遮罩'")
|
||
|
||
|
||
def test_numbers_left_as_written_in_the_sql_are_not_masked_in_the_answer(amounts):
|
||
"""范围比较的边界、LIMIT 照原样留在 SQL 里,回答正文里同样的数再遮也遮不住什么,只会和 SQL 对不上;等值比较
|
||
里换掉的照旧遮。"""
|
||
sql = "SELECT COUNT(*) FROM sales WHERE amount >= 30000 AND amount <> 50000 LIMIT 10000"
|
||
assert inquiry_mod._shown_literals([(sql, "sqlite", {"amount"})], amounts) == {"30000", "10000"}
|
||
amounts.show(inquiry_mod._shown_literals([(sql, "sqlite", {"amount"})], amounts))
|
||
assert amounts.scrub("金额不低于 30000 元、不是 50000 元的,取前 10000 条", prose=True) == (
|
||
"金额不低于 30000 元、不是 已遮罩 元的,取前 10000 条")
|
||
assert inquiry_mod._scrub_sql(sql, amounts, {"amount"}, "sqlite") == (
|
||
"SELECT COUNT(*) FROM sales WHERE amount >= 30000 AND amount <> 已遮罩 LIMIT 10000")
|
||
assert inquiry_mod._scrub_sql("SELECT COUNT(*) FROM sales WHERE amount = 30000", amounts, {"amount"},
|
||
"sqlite") == "SELECT COUNT(*) FROM sales WHERE amount = 已遮罩", \
|
||
"只免了正文里的:和遮罩列做等值比较的照旧换"
|
||
|
||
|
||
def test_a_threshold_left_in_the_sql_is_shown_in_the_prose_but_not_in_the_cells(amounts):
|
||
"""SQL 里照原样留着的阈值只在回答正文里不遮:格子里的同一个数照旧按值遮(col >= v 再取第一行,查出来的可能就是
|
||
那一笔),列名、SQL 里的名字也照旧换。组装出来正文里的不算露出,格子里渲染出来的算。"""
|
||
sql = "SELECT region AS 区域, visits AS 访问 FROM sales WHERE visits >= 20000 ORDER BY visits LIMIT 1"
|
||
amounts.show(inquiry_mod._shown_literals([(sql, "sqlite", {"amount"})], amounts))
|
||
assert amounts.scrub("访问不少于 20000 次", prose=True) == "访问不少于 20000 次"
|
||
assert amounts.scrub("访问不少于 20000 次") == "访问不少于 已遮罩 次"
|
||
snapshot = {"columns": ["区域", "访问"], "rows": [["华东", 20000]]}
|
||
view, masked = inquiry_mod._masked_view(snapshot, set(), amounts)
|
||
assert view["rows"] == [["华东", MASKED]] and masked == ["访问"]
|
||
doc = inquiry_mod._compose("访问不少于 20000 次的区域里最少的是 [[v:Q1.r0.区域]],访问 [[v:Q1.r0.访问]] 次。",
|
||
{"Q1": query_entry(["区域", "访问"])}, run_id="r1", views={"a1": view},
|
||
secrets=amounts, allow=[])
|
||
assert doc["markdown"] == "访问不少于 20000 次的区域里最少的是 华东,访问 已遮罩 次。"
|
||
# 一格文字里夹着这个数:按整格比认不出来,渲染出来才露,照旧不交出去
|
||
noted = {"columns": ["备注"], "rows": [["回访 20000 次"]]}
|
||
views = {"a1": inquiry_mod._masked_view(noted, set(), amounts)[0]}
|
||
with pytest.raises(inquiry_mod._Leak):
|
||
inquiry_mod._compose("备注是 [[v:Q1.r0.备注]]。", {"Q1": query_entry(["备注"])}, run_id="r1", views=views,
|
||
secrets=amounts, allow=[])
|
||
# 引用的列名里写着这个数:列名在快照、SQL 里都照旧换,标记里的列名换得一样,引用照样解析得了
|
||
named = {"columns": ["超过20000次"], "rows": [[3]]}
|
||
shown, _ = inquiry_mod._masked_view(named, set(), amounts)
|
||
assert shown["columns"] == ["超过已遮罩次"]
|
||
doc = inquiry_mod._compose("访问超过 20000 次的有 [[v:Q1.r0.超过20000次]] 笔。", {"Q1": query_entry(named["columns"])},
|
||
run_id="r1", views={"a1": shown}, secrets=amounts, allow=[])
|
||
assert doc["markdown"] == "访问超过 20000 次的有 3 笔。"
|
||
|
||
|
||
def test_a_comparison_with_a_renamed_masked_column_is_masked_too(people):
|
||
sql = "SELECT * FROM (SELECT buyer_phone AS p FROM sales) t WHERE p = '13900000003' AND 1 = 1"
|
||
assert inquiry_mod._scrub_sql(sql, people, {"buyer_phone", "p"}, "sqlite") == (
|
||
"SELECT * FROM (SELECT buyer_phone AS p FROM sales) t WHERE p = '已遮罩' AND 1 = 1")
|
||
|
||
|
||
def test_sql_that_cannot_be_split_into_words_is_scrubbed_like_text(people):
|
||
assert inquiry_mod._scrub_sql("SELECT 'x FROM t WHERE p = 13900000001 LIMIT 25", people, {"buyer_phone"},
|
||
"sqlite") == "SELECT 'x FROM t WHERE p = 已遮罩 LIMIT 25"
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 组装回答的最后一道关
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def query_entry(columns: list[str]) -> dict:
|
||
return {"alias": "Q1", "kind": "query", "artifact": "a1", "source": "shop", "columns": columns, "rows": 1,
|
||
"truncated": False}
|
||
|
||
|
||
def test_an_answer_still_showing_a_masked_value_is_not_handed_out():
|
||
"""别的列里一格文字夹着遮罩值(备注里写着手机号):按整格比认不出来,标记里也看不出来,渲染出来才露——宁可不交
|
||
这份回答。"""
|
||
secrets = secrets_of({"columns": ["phone"], "rows": [["13900000001"]]}, {"phone"})
|
||
snapshot = {"columns": ["备注"], "rows": [["客户13900000001来电改址"]]}
|
||
views = {"a1": inquiry_mod._masked_view(snapshot, set(), secrets)[0]}
|
||
assert views["a1"]["rows"] == [["客户13900000001来电改址"]]
|
||
catalog = {"Q1": query_entry(["备注"])}
|
||
with pytest.raises(inquiry_mod._Leak):
|
||
inquiry_mod._compose("备注是 [[v:Q1.r0.备注]]。", catalog, run_id="r1", views=views, secrets=secrets, allow=[])
|
||
doc = inquiry_mod._compose("没有遮罩值。", catalog, run_id="r1", views=views, secrets=secrets, allow=[])
|
||
assert doc["markdown"] == "没有遮罩值。"
|
||
|
||
|
||
def test_a_masked_value_inside_a_text_cell_is_cut_out_of_the_artifact_view():
|
||
"""质询工件这一路(embedded):没设遮罩的列里一格文字夹着遮罩值,把那一截换成「已遮罩」,这一格别的字、别的格子
|
||
照原样;这一列不记进 masked(界面会把记进去的列整列画成「已遮罩」,这一列在数据源里也没设遮罩)。门槛同回答
|
||
一路:短的数不按值换。回答一路不这样换,渲染出来露了就整份不交(上一条测试)。"""
|
||
secrets = secrets_of({"columns": ["phone", "age"], "rows": [["13900000001", 25]]}, {"phone", "age"})
|
||
snapshot = {"columns": ["区域", "备注", "金额"], "column_types": {"金额": "number"},
|
||
"rows": [["华东", "客户13900000001来电改址", 1200], ["华南", "例行回访,25 岁", 900]]}
|
||
view, masked = inquiry_mod._masked_view(snapshot, set(), secrets, embedded=True)
|
||
assert view["rows"] == [["华东", "客户已遮罩来电改址", 1200], ["华南", "例行回访,25 岁", 900]]
|
||
assert masked == [] and view["column_types"] == {"金额": "number"}
|
||
assert snapshot["rows"][0][1] == "客户13900000001来电改址", "原快照不动"
|
||
as_dicts = {**snapshot, "rows": [{"区域": "华东", "备注": "客户13900000001来电改址", "金额": 1200}]}
|
||
assert inquiry_mod._masked_view(as_dicts, set(), secrets, embedded=True)[0]["rows"] == [
|
||
{"区域": "华东", "备注": "客户已遮罩来电改址", "金额": 1200}]
|
||
# JSON 列(对象)按界面画出来的样子换,换过的照文字给
|
||
nested = {"columns": ["联系人"], "rows": [[{"电话": "13900000001", "年龄": 25}]]}
|
||
assert inquiry_mod._masked_view(nested, set(), secrets, embedded=True)[0]["rows"] == [
|
||
['{"电话": "已遮罩", "年龄": 25}']]
|
||
# 整格就是遮罩值的照旧整格换、记进 masked;按列遮的照旧
|
||
whole = {"columns": ["联系方式", "备注"], "rows": [["13900000001", "回电 13900000001"]]}
|
||
view, masked = inquiry_mod._masked_view(whole, set(), secrets, embedded=True)
|
||
assert view["rows"] == [[MASKED, "回电 已遮罩"]] and masked == ["联系方式"]
|
||
# 回答一路照旧:文字格不动,交给最后一道关
|
||
assert inquiry_mod._masked_view(snapshot, set(), secrets)[0] is snapshot
|
||
|
||
|
||
def test_sql_check_excerpts_in_the_artifact_are_masked_like_the_sql():
|
||
"""质询工件里快照记下的 SQL 检查结果:片段(sql_excerpt)摘自同一条 SQL,照查询清单的 SQL 换掉遮罩值(日期这样的
|
||
字也认得出是在找哪一天);给模型的那句改法(for_model)照抄了片段,不交出去(同证据面板)。"""
|
||
secrets = secrets_of({"columns": ["birth_date"], "rows": [["1990-05-01"]]}, {"birth_date"})
|
||
sql = "SELECT SUM(balance) FROM customers c JOIN balances b ON b.cid = c.id WHERE birth_date = '1990-05-01'"
|
||
message = "「客户」的业务日期按「注册日期」计,查询却按「生日」筛选"
|
||
checks = [{"code": "wrong_date_column", "level": "warning", "table": "customers", "column": "birth_date",
|
||
"message": message, "for_model": "这条 SQL 按 birth_date = '1990-05-01' 筛选",
|
||
"sql_excerpt": "birth_date = '1990-05-01'"},
|
||
{"code": "stock_summed", "level": "info", "table": "balances", "message": "存量跨日期求和",
|
||
"sql_excerpt": "SUM(balance)"}]
|
||
assert inquiry_mod._masked_checks(checks, sql, secrets, {"birth_date"}, "sqlite") == [
|
||
{"code": "wrong_date_column", "level": "warning", "table": "customers", "column": "birth_date",
|
||
"message": message, "sql_excerpt": "birth_date = '已遮罩'"},
|
||
{"code": "stock_summed", "level": "info", "table": "balances", "message": "存量跨日期求和",
|
||
"sql_excerpt": "SUM(balance)"}]
|
||
|
||
|
||
def test_check_excerpts_follow_the_sql_recorded_at_answer_time():
|
||
"""agent 自己的查询快照查询时就遮好了,事后取质询工件时收不到遮罩列里的值:片段、说明照事件里记下的那条 SQL(回答时
|
||
按内存里的遮罩值换过)换——那条里整个换成「已遮罩」的字面量、换掉的那一截(LIKE 的 %…%),片段、说明里一样换。"""
|
||
sql = ("SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone = '13900000001' AND age = 25 "
|
||
"AND note LIKE '%13900000002%'")
|
||
recorded = "SELECT COUNT(*) AS 笔数 FROM sales WHERE buyer_phone = '已遮罩' AND age = 已遮罩 AND note LIKE '%已遮罩%'"
|
||
assert inquiry_mod._recorded_swaps(sql, recorded, "sqlite") == ({"13900000001", "25"}, {"13900000002"})
|
||
assert inquiry_mod._recorded_swaps(sql, sql, "sqlite") == (set(), set())
|
||
assert inquiry_mod._recorded_swaps(sql, recorded + " LIMIT 5", "sqlite") == (set(), set()), "对不上就不认"
|
||
checks = [{"code": "c1", "level": "warning", "table": "sales", "message": "没有码值「25」",
|
||
"sql_excerpt": "buyer_phone = '13900000001' AND age = 25"},
|
||
{"code": "c2", "level": "info", "table": "sales", "message": "按备注筛选",
|
||
"sql_excerpt": "note LIKE '%13900000002%'"}]
|
||
whole, pieces = inquiry_mod._recorded_swaps(sql, recorded, "sqlite")
|
||
assert inquiry_mod._masked_checks(checks, sql, inquiry_mod._Secrets(), set(), "sqlite", swapped=whole,
|
||
pieces=pieces) == [
|
||
{"code": "c1", "level": "warning", "table": "sales", "message": "没有码值「已遮罩」",
|
||
"sql_excerpt": "buyer_phone = '已遮罩' AND age = 已遮罩"},
|
||
{"code": "c2", "level": "info", "table": "sales", "message": "按备注筛选", "sql_excerpt": "note LIKE '%已遮罩%'"}]
|
||
|
||
|
||
@pytest.mark.parametrize("sql, excerpt, shown", [
|
||
# 片段只是一段表达式,单独拿出来认不出在找哪一个值:按整条 SQL 里换掉的字面量换
|
||
("SELECT SUM(CASE age WHEN 25 THEN balance END) AS 余额 FROM accounts",
|
||
"SUM(CASE age WHEN 25 THEN balance END)", "SUM(CASE age WHEN 已遮罩 THEN balance END)"),
|
||
("SELECT SUM(balance) AS 余额 FROM accounts WHERE (age, region) = (25, '华东')",
|
||
"(age, region) = (25, '华东')", "(age, region) = (已遮罩, '华东')"),
|
||
("SELECT SUM(CASE WHEN age BETWEEN 25 AND 25 THEN balance END) AS 余额 FROM accounts",
|
||
"SUM(CASE WHEN age BETWEEN 25 AND 25 THEN balance END)",
|
||
"SUM(CASE WHEN age BETWEEN 已遮罩 AND 已遮罩 THEN balance END)"),
|
||
# 整条 SQL 里照原样留着的阈值,片段里也照原样
|
||
("SELECT SUM(CASE WHEN amount >= 20000 THEN balance END) AS 余额 FROM accounts",
|
||
"SUM(CASE WHEN amount >= 20000 THEN balance END)", "SUM(CASE WHEN amount >= 20000 THEN balance END)"),
|
||
# 检查器截过的片段:截在号码中间,那半截按值认不出,去掉
|
||
("SELECT SUM(balance) AS 余额 FROM accounts WHERE region = '华东' AND phone = '13900000001'",
|
||
"region = '华东' AND phone = '1390000…", "region = '华东' AND phone = …"),
|
||
])
|
||
def test_sql_check_excerpts_follow_what_the_whole_sql_masked(sql, excerpt, shown):
|
||
secrets = secrets_of({"columns": ["phone", "age", "amount"], "rows": [["13900000001", 25, 20000]]},
|
||
{"phone", "age", "amount"})
|
||
checks = [{"code": "stock_summed", "level": "warning", "table": "accounts", "message": "存量跨日期求和",
|
||
"sql_excerpt": excerpt}]
|
||
[check] = inquiry_mod._masked_checks(checks, sql, secrets, {"phone", "age", "amount"}, "sqlite")
|
||
assert check["sql_excerpt"] == shown
|
||
|
||
|
||
def test_sql_check_messages_do_not_quote_masked_values():
|
||
"""检查结果的说明照引 SQL 里写的取值(码值表已列全时「没有码值「3」」):整条 SQL 里换掉的取值,说明里一样换成
|
||
「已遮罩」,短的码值、文字码值都一样;码值表里列出的码值是数据目录写明的,照原样。"""
|
||
secrets = secrets_of({"columns": ["status", "channel_type"], "rows": [[3, "线下合作"], [1, "线上"]]},
|
||
{"status", "channel_type"})
|
||
sql = ("SELECT COUNT(*) FROM visits v JOIN channels c ON c.id = v.cid WHERE status IN (3, -3) "
|
||
"AND channel_type = '线下合作'")
|
||
listed = "(数据目录中已列出的全部码值:1=有效、0=作废)。请核对筛选条件里的取值"
|
||
checks = [{"code": "unknown_code", "level": "warning", "table": "visits", "column": "status",
|
||
"message": f"「入园记录」的「检票状态」没有码值「3」、「-3」{listed}", "sql_excerpt": "status IN (3, -3)"},
|
||
{"code": "unknown_code", "level": "warning", "table": "channels", "column": "channel_type",
|
||
"message": f"「渠道」的「渠道类型」没有码值「线下合作」{listed}",
|
||
"sql_excerpt": "channel_type = '线下合作'"}]
|
||
out = inquiry_mod._masked_checks(checks, sql, secrets, {"status", "channel_type"}, "sqlite")
|
||
assert [c["message"] for c in out] == [f"「入园记录」的「检票状态」没有码值「已遮罩」、「-已遮罩」{listed}",
|
||
f"「渠道」的「渠道类型」没有码值「已遮罩」{listed}"]
|
||
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 的单):报告里本来就看得到,
|
||
引用它的回答照常交出去。"""
|
||
secrets = secrets_of({"columns": ["amount"], "rows": [[20000], [35000]]}, {"amount"})
|
||
catalog = {"m:target": {"alias": "m:target", "kind": "metric", "eid": "metric:c1:target", "artifact": "c1",
|
||
"locator": {"metric": "target"}, "value": 20000, "rendered": "20,000",
|
||
"label": "单笔目标 = 20,000"}}
|
||
with pytest.raises(inquiry_mod._Leak):
|
||
inquiry_mod._compose("单笔目标为 [[m:target]]。", catalog, run_id="r1", views={}, secrets=secrets, allow=[])
|
||
secrets.protect(inquiry_mod._ref_values(inquiry_mod._report_refs(catalog)))
|
||
doc = inquiry_mod._compose("单笔目标为 [[m:target]],有一笔 35000 元。", catalog, run_id="r1", views={},
|
||
secrets=secrets, allow=[])
|
||
assert doc["markdown"] == "单笔目标为 20,000,有一笔 已遮罩 元。"
|
||
|
||
|
||
def test_a_column_named_after_a_masked_value_is_renamed():
|
||
"""agent 把遮罩值写进了别名(「13900000001 的单」):快照、回答文档里的列名同样换掉。回答引用了原列名的,标记里
|
||
的列名照样换,正好对上换过的列名,引用照样解析得了。"""
|
||
secrets = secrets_of({"columns": ["phone"], "rows": [["13900000001"]]}, {"phone"})
|
||
snapshot = {"columns": ["13900000001 的单", "金额"], "column_types": {"13900000001 的单": "number"},
|
||
"rows": [[2, 1200]]}
|
||
shown, masked = inquiry_mod._masked_view(snapshot, set(), secrets)
|
||
assert shown["columns"] == ["已遮罩 的单", "金额"] and shown["rows"] == [[2, 1200]]
|
||
assert shown["column_types"] == {"已遮罩 的单": "number"} and masked == []
|
||
assert snapshot["columns"][0] == "13900000001 的单", "原快照不动"
|
||
rows_as_dicts = {**snapshot, "rows": [{"13900000001 的单": 2, "金额": 1200}]}
|
||
assert inquiry_mod._masked_view(rows_as_dicts, set(), secrets)[0]["rows"] == [{"已遮罩 的单": 2, "金额": 1200}]
|
||
|
||
doc = inquiry_mod._compose("这位客户共 [[v:Q1.r0.13900000001 的单]] 笔。", {"Q1": query_entry(snapshot["columns"])},
|
||
run_id="r1", views={"a1": shown}, secrets=secrets, allow=[])
|
||
assert doc["markdown"] == "这位客户共 2 笔。"
|
||
assert "13900000001" not in json.dumps(doc, ensure_ascii=False)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 规模
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def test_twenty_thousand_masked_values_are_scrubbed_without_stalling():
|
||
"""上万个遮罩值(几份上千行的快照):以前现编一个上万分支的正则,编一次一秒上下,每次回答、每次取工件都卡住整个
|
||
进程。现在先按词、按开头两个字筛出正文里真有的几个,再换。"""
|
||
rows = [[f"139{i:08d}", f"客户{i:05d}"] for i in range(10_000)]
|
||
secrets = secrets_of({"columns": ["phone", "name"], "rows": rows}, {"phone", "name"})
|
||
text = "近 30 天里客户00042 下了 3 笔,号码 13900000042," * 60
|
||
started = time.perf_counter()
|
||
for _ in range(5):
|
||
out = secrets.scrub(text)
|
||
elapsed = (time.perf_counter() - started) / 5
|
||
assert "13900000042" not in out and "客户00042" not in out and "近 30 天" in out
|
||
assert elapsed < 0.5, f"换一遍用了 {elapsed:.2f} 秒"
|
||
|
||
|
||
def test_a_long_chain_of_conditions_is_still_read_by_its_structure(ages):
|
||
"""上千个条件连成一长串(一层套一层的且):照样按条件合起来看,夹住的值照样换,不因为层数太深退回到按词认——
|
||
按词认不出范围夹住了值,短的数就漏了。"""
|
||
chain = " AND ".join(f"visits{i} >= {i}" for i in range(1500))
|
||
sql = f"SELECT COUNT(*) FROM sales WHERE age >= 25 AND age <= 25 AND {chain}"
|
||
assert inquiry_mod._literal_uses(sql, "sqlite") is not None
|
||
assert inquiry_mod._scrub_sql(sql, ages, {"age"}, "sqlite") == sql.replace(
|
||
"age >= 25 AND age <= 25", "age >= 已遮罩 AND age <= 已遮罩")
|
||
|
||
|
||
def test_hundreds_of_band_edges_are_read_within_a_budget():
|
||
"""成百个分档边界连成一长串:看得过来的照条件合起来认(一百个相邻的分档连成一段,谁也没夹住);多到看不过来的,
|
||
两边都隔开就按夹住算(宁可多换),也不会一个个细看算上好几秒。年龄从 0 到 599 都有,按夹住算的哪一档都会换。"""
|
||
every = secrets_of({"columns": ["age"], "rows": [[i] for i in range(600)]}, {"age"})
|
||
|
||
def bins(n: int) -> str:
|
||
return "SELECT COUNT(*) FROM sales WHERE " + " OR ".join(f"(age >= {i} AND age < {i + 1})" for i in range(n))
|
||
|
||
started = time.perf_counter()
|
||
assert inquiry_mod._scrub_sql(bins(100), every, {"age"}, "sqlite") == bins(100)
|
||
assert "已遮罩" in inquiry_mod._scrub_sql(bins(600), every, {"age"}, "sqlite")
|
||
elapsed = time.perf_counter() - started
|
||
assert elapsed < 5, f"用了 {elapsed:.2f} 秒"
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 回答时才认得出的值:记下位置,事后取质询工件时照着再换一遍
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def test_values_known_only_at_answer_time_are_recorded_as_positions_and_replayed():
|
||
"""agent 先查了备注(那时还不知道备注里的号码是遮罩值,查询时原样交回、原样存下),后来又查出了手机号(查询时
|
||
遮掉,值只在内存里)。回答时按内存里的值换掉的地方记成位置(不含值);事后取质询工件时内存里的值已经不在,照着
|
||
位置再换一遍,和回答时看到的一样。"""
|
||
from app.engine import masking
|
||
|
||
note = "客户13900000001来电改址,备用 139-0000-0001"
|
||
snapshot = {"columns": ["区域", "备注", "号码", "13900000001 的单"],
|
||
"rows": [["华东", note, "13900000001", 1], ["华南", "例行回访", "13800000000", 2]]}
|
||
seen = secrets_of({"columns": ["phone"], "rows": [["13900000001"]]}, {"phone"})
|
||
edits = masking.view_edits(snapshot, set(), seen, inquiry_mod._Secrets())
|
||
first, second = note.index("13900000001"), note.index("139-0000-0001")
|
||
assert edits == {"cells": [[0, 2]], "spans": [[0, 1, first, first + 11], [0, 1, second, second + 13]],
|
||
"names": [[3, 0, 11]], "masked": ["号码"]}
|
||
assert "1390000000" not in json.dumps(edits, ensure_ascii=False), "只记位置,不记值"
|
||
replayed = masking.replay_edits(snapshot, [edits])
|
||
assert replayed["columns"] == ["区域", "备注", "号码", f"{MASKED} 的单"]
|
||
assert replayed["rows"] == [["华东", f"客户{MASKED}来电改址,备用 {MASKED}", MASKED, 1],
|
||
["华南", "例行回访", "13800000000", 2]]
|
||
# 不靠内存里的值也认得出的(报告快照里就有),事后照样认得出,不记
|
||
assert masking.view_edits(snapshot, set(), seen, seen) is None
|
||
# 记下的位置对不上(越界、坏形状)的跳过,不出错
|
||
broken = {"cells": [[9, 9], "x"], "spans": [[0, 1, 50, 60], [0, 9, 0, 1]], "names": [[7, 0, 1]], "masked": [3]}
|
||
assert masking.replay_edits(snapshot, [broken, None, "x"]) == snapshot
|
||
|
||
|
||
def test_the_plan_records_what_only_the_values_seen_by_the_agent_masked():
|
||
"""_settle_plan 带上 agent 查询时遮掉的值(seen)时,另记下只靠它们才换掉的位置(replay);不带 seen 的(事后取
|
||
质询工件)不记。"""
|
||
notes = {"source": "shop", "sql": "SELECT region AS 区域, note AS 备注 FROM callbacks", "columns": ["区域", "备注"],
|
||
"rows": [["华东", "客户13900000001来电改址"], ["华南", "例行回访"]]}
|
||
seen = secrets_of({"columns": ["phone"], "rows": [["13900000001"]]}, {"phone"})
|
||
plan = inquiry_mod._settle_plan({"a1": notes}, {"a1": ({"buyer_phone"}, [])}, [], {"shop": "sqlite"}, [], [], {},
|
||
frozenset(), seen)
|
||
assert plan.replay == {"a1": {"cells": [], "spans": [[0, 1, 2, 13]], "names": [], "masked": []}}
|
||
later = inquiry_mod._settle_plan({"a1": notes}, {"a1": ({"buyer_phone"}, [])}, [], {"shop": "sqlite"}, [], [], {},
|
||
frozenset({"a1"}))
|
||
assert later.replay == {} and later.views["a1"]["rows"][0][1] == "客户13900000001来电改址"
|
||
|
||
|
||
def test_values_the_agent_saw_survive_a_report_that_fills_the_cap():
|
||
"""报告快照里的遮罩值收满了上限(SECRETS_MAX):agent 查询时遮掉的值先收,照样认得出(以前先收报告的,满了就丢)。"""
|
||
report = {"columns": ["buyer_phone"], "rows": [[str(13800000000 + i)] for i in range(inquiry_mod.SECRETS_MAX)]}
|
||
seen = secrets_of({"columns": ["phone"], "rows": [["19914742785"]]}, {"phone"})
|
||
plan = inquiry_mod._settle_plan({}, {}, [(report, {"buyer_phone"})], {}, [], [], {}, frozenset(), seen)
|
||
assert plan.secrets.hit("19914742785")
|