From caeab0fad687e98622068eedd163489c92f35dc7 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sat, 17 Oct 2026 08:17:12 +0800 Subject: [PATCH] =?UTF-8?q?SQL=20=E5=B7=AE=E5=BC=82=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E8=AE=A4=E5=BE=97=E5=90=8C=E4=B8=80=E5=88=97=E4=B8=8A=E7=9A=84?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6=E6=94=B9=E5=86=99=EF=BC=9A=E8=AF=B4=E6=B8=85?= =?UTF-8?q?=E3=80=8C=E8=BF=99=E6=AC=A1=E5=AF=B9=20X=20=E7=9A=84=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E6=98=AF=20=E2=80=A6=EF=BC=8C=E5=8F=A3=E5=BE=84?= =?UTF-8?q?=E6=98=AF=20=E2=80=A6=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 验收:口径 status = 'paid'、这次 status IN ('paid', 'refunded'),explain_sql_diff 只给兜底「两条 SQL 的写法不同, 未能归纳成具体差异」。原因是差异说明只说四类,同一列上两边各写一套条件(等号对 IN、取值集合不同、范围端点不同) 不在其中:_extra_filters 看到另一边也用了这一列就放弃。 改法:_Explainer 加 _rewrites,排在 _times 之后、_extra_filters 之前。同一张实表的同一列两边 WHERE 里都有条件、 至少一边有没配上的,就说「A 对 X 的条件是 …,B 是 …」;两边都只是取值集合、又有共同的取值时补「(这次多了 'refunded')」。两边都是时间条件时 aspect 是 time_range(FacetDiff 说明里早有这个方面、engine/compare 也按它 排序),否则是 filter 且 a、b 两边都有——dual_check_facts 对两边都有的 filter 本来就说「两边各有不同的筛选条件」 不写目录,ledger/stats、评测页的方面叫法都已认得这两个名字。 为了不说出不存在的不同,只在这些条件都满足时说:两边用到这一列的条件都只涉及这一列、比的是那一列本身; 按取值逐段算过两边留下的取值确实不同(_Range.compare 加 never_null:表结构里确定不为空的列不拿空值比; x >= 100 AND x >= 50 对 x >= 100 这类冗余写法算出来一样,不说);不在 LEFT JOIN 可空一侧;WHERE 之外(ON、 HAVING、CASE、聚合参数)都没用到这一列(SUM(CASE WHEN status = 'paid' …) 配上 IN 可能和只筛 paid 一样);不是 认不准的时间窗口;没换表;有认不出列的条件时整段不说。 只改说明、不碰等价判定:说明只在已经判了不等价之后才跑。新测试把 _rewrites 换成什么都不做,逐对(语料全部 加几对改写、两种先后、给不给表结构)比等价与否和判定的级别,一字不差;IN 只有一个值、IN 列表去重排序照样判等价。 语料执行守门(意思一样的对不许说出具体不同)全过。 测试:test_sql_semantics 新增三组(各方言说两边、可能不影响结果的不说、等价判定不变);原来断言这两类只给 兜底的用例改成「算出来的值上换了值」(LOWER(status))仍只给兜底;test_sql_text_diff、test_dual_check_facts 跟上 (同一列上的时间范围现在是 time_range,照样不写目录;换了筛选的值不写目录)。去掉 _rewrites 的调用回跑,新测试 挂 5 条。 双盲复算的前端夹具 dual_check.json 里「说不清的」那个变体原来就是同一列上的时间范围,现在说得出了:换成套着 DATE() 的时间范围(认不出取值范围,仍只给兜底),test_dual_check_fixture 重新生成,check-verify 对应的说法和 高亮那一行的断言跟上。 --- backend/app/data/sql_semantics.py | 138 +++++++++++++++++++--- backend/tests/test_dual_check_facts.py | 16 ++- backend/tests/test_dual_check_fixture.py | 9 +- backend/tests/test_sql_semantics.py | 106 +++++++++++++++-- backend/tests/test_sql_text_diff.py | 4 +- frontend/src/dev/fixtures/dual_check.json | 8 +- scripts/check-verify.mjs | 8 +- 7 files changed, 246 insertions(+), 43 deletions(-) diff --git a/backend/app/data/sql_semantics.py b/backend/app/data/sql_semantics.py index 1ab1848e..8ec28094 100644 --- a/backend/app/data/sql_semantics.py +++ b/backend/app/data/sql_semantics.py @@ -111,7 +111,7 @@ PIPES_AS_CONCAT 的库这两处的意思不同,sqlglot 的 MySQL 读法和这 **具体说法(见 _Explainer)。** 只在两边的规范形式都是「简单形状」时说(_simple_block:整条语句就是一段 SELECT,没有 CTE、子查询、UNION 这类合并、窗口函数、DISTINCT ON;FROM 里只有实表,连接只有内连接、CROSS JOIN、逗号和 LEFT JOIN; LIMIT 是至少为 1 的常数;没有认不准的名字、SELECT 列表里没有 sqlglot 不认识的函数;不认列的那一级里原文没有在 -WHERE / GROUP BY 里用列别名的),而且只说四类: +WHERE / GROUP BY 里用列别名的),而且只说五类: - 时间列(time):两边各在一张底层表的一列上按时间筛、列不同(另一边哪儿都没用这一列筛选,两边的时间条件都不是恒为 真的,时间列不在 LEFT JOIN 的可空一侧);一边按时间筛了、另一边哪儿都没有可能是时间窗口的条件的(见下),也说成 @@ -129,6 +129,13 @@ WHERE / GROUP BY 里用列别名的),而且只说四类: 不用到 LEFT JOIN 可空一侧的列(WHERE r.id IS NULL 是反连接,说的是「没关联上」;r.order_id IS NULL 和 ON 里的 r.order_id = o.id 一起已经蕴含 r.id IS NULL);不是可能的时间窗口(_window_like:date_key BETWEEN 20260901 AND 20260930、stat_month = 202609——认不准是不是时间列,说成有效行过滤会被写进数据目录)。detail 给条件涉及的实表列。 +- 同一列上的条件改写(filter / time_range,a、b 两边都有):两边 WHERE 里对同一张实表的同一列各写了一套条件、留下的 + 取值不同——等号对 IN(口径 status = 'paid',这次 status IN ('paid', 'refunded'))、取值集合不同、范围的端点不同,说 + 「A 对 status 的条件是 …,B 是 …」,两边都只是取值集合、又有共同的取值时补「(B 多了 'refunded')」。要两边用到这一列 + 的条件都只涉及这一列、比的就是那一列本身,按取值逐段算过两边留下的取值确实不同(_Range.compare:表结构里确定不为空 + 的列不拿空值比;x >= 100 AND x >= 50 对 x >= 100 这类冗余写法算出来一样,不说),这一列不在 LEFT JOIN 的可空一侧, + 两边在 WHERE 之外(ON、HAVING、CASE / FILTER、聚合的参数)都没用到这一列,不是认不准的时间窗口,没换表。两边都是 + 时间条件时 aspect 是 time_range。只改说明:走到这里时已经判了不等价(见 _Explainer._rewrites)。 - 聚合与去重(aggregate / group_by):选出来的列只差在顶层的聚合函数上(同一列做参数,SUM 对 AVG;COUNT(*) 对 SUM(x)) 或者去重上(COUNT / SUM / AVG 去不去重、COUNT(*) 对 COUNT(DISTINCT x)、SELECT DISTINCT 对不去重),分组一样。参数 得是光一列、在每组里不是定死的(不被 WHERE 定成常量、不等于分组键、表结构里它那张表的键没有在组里定死——按 u.id 分组 @@ -144,7 +151,7 @@ WHERE / GROUP BY 里用列别名的),而且只说四类: 每一句都先确认这处不同真会影响结果:两边的 WHERE、HAVING、ON 里有可能恒为假的条件(常量条件,同一个对象上取不到值的 几个条件)时什么都不说;结果看不见行的(SELECT COUNT(*) > -1 这类只出一行、值又和行无关的)不说筛选、关联上的不同; 表结构里的约束(主键、唯一约束、外键、NOT NULL)说明不起作用的不说——约束只拿来少说,不拿来判等价。被 WHERE 用常量定死 -的列出现在排序、分组里的差异,恒为真的 HAVING,都不在四类里,不说。两边给人看的写法一样、和已经说过的一句正好反过来的 +的列出现在排序、分组里的差异,恒为真的 HAVING,都不在五类里,不说。两边给人看的写法一样、和已经说过的一句正好反过来的 不说。 **兜底。** 规范形式不一致、一条具体说法都没有时,给一条 aspect="other" 的「两条 SQL 的写法不同,未能归纳成具体差异, @@ -164,8 +171,9 @@ WHERE / GROUP BY 里用列别名的),而且只说四类: 关联先后、按 WHERE 把外连接改成内连接、换成相等的列;没给表结构(或者类型不一样)时按关联条件相等的两列换着分组、 排序;PostgreSQL、MySQL 没给表结构时去重、分组、求和的一段调换关联先后、并子查询、去掉没用到的 LEFT JOIN、把只取 可空一侧聚合的 LEFT JOIN 写成内连接。 -- 只给兜底的:不是简单形状的(CTE、子查询、UNION、窗口函数,规范化没把它们并掉的);四类以外的不同(同一列上的时间 - 范围、同一列上换了筛选的值、关联条件、分组、HAVING、排序、取几行、选出来的列、聚合的参数);几张表、几列交错的条件; +- 只给兜底的:不是简单形状的(CTE、子查询、UNION、窗口函数,规范化没把它们并掉的);五类以外的不同(算出来的值上的 + 条件改写——LOWER(status) = 'paid' 对 LOWER(status) IN (…)、和别的列交错的同一列条件、关联条件、分组、HAVING、排序、 + 取几行、选出来的列、聚合的参数);几张表、几列交错的条件; 自己关联自己、同名的表关联了几次的;换了表又有关联条件不同的;条件写在聚合里的一边对条件写在 WHERE 里的一边(SUM(status = 'paid') 对 COUNT(*) … WHERE status = 'paid':真正的不同是计数为 0 的组、空集时是 0 还是 NULL,说不清);反连接; 可能是时间窗口、又认不准是不是时间列的条件(业务日期键、统计月份、分区,第几天这类范围)。 @@ -5902,8 +5910,9 @@ class FacetDiff: set_op(几支查询怎么合并)/ group_by(分组键、去重)/ having / limit(取哪几行,含取行前的排序)/ order(只是最终 结果的排序不同,不影响取到哪些行)/ parse(解析不了)/ other(兜底:未能归纳成具体差异,见 explain_sql_diff)。a、b 是两边各自的写法(给人看,没有就是 None),text 是一句中文说明。detail 只在 time 上有:两边的时间列 - [{table, column}](各是各的表),调用方据此对照数据目录(业务日期);在 filter 上有 {"columns": [{table, column}]}: - 条件只涉及的实表列,对照有效行过滤用。""" + [{table, column}](各是各的表),调用方据此对照数据目录(业务日期);在 filter、time_range 上有 {"columns": + [{table, column}]}:条件只涉及的实表列,对照有效行过滤用。filter 一边是 None 的是「一边多出来的条件」;两边都有的是 + 同一列上的条件两边各写了一套(「A 对 X 的条件是 …,B 是 …」),不是一边多出来的有效行条件。""" aspect: str a: Any @@ -6360,8 +6369,10 @@ class _Range: subject = n.this return self._truth(n, assign[_fingerprint(subject)]) - def compare(self, conds_a: list[Any], conds_b: list[Any], grouped: bool = False) -> bool | None: - """两组条件(各自 AND 连,可以一边为空 = 不筛)留下的取值是不是一样:一样 True,不一样 False,认不出 None。""" + def compare(self, conds_a: list[Any], conds_b: list[Any], grouped: bool = False, + never_null: bool = False) -> bool | None: + """两组条件(各自 AND 连,可以一边为空 = 不筛)留下的取值是不是一样:一样 True,不一样 False,认不出 None。 + never_null:对象一定不为空(表结构里不为空的列),不拿空值比。""" parsed = [self.subject(c) for c in conds_a + conds_b] if any(p is None for p in parsed) or len({p[0] for p in parsed}) != 1: return None @@ -6373,7 +6384,7 @@ class _Range: from sqlglot import exp values = self._pieces(subject, literals, ranged, grouped) - if not isinstance(_unparen(subject), exp.Count): + if not isinstance(_unparen(subject), exp.Count) and not never_null: values.append(None) for v in values: ta = all(self._truth(c, v) == "T" for c in conds_a) @@ -6745,7 +6756,7 @@ def _alias_named(root: Any) -> bool: class _Explainer: """在两边的规范形式上找有把握、又确实影响结果的不同,写成具体的中文句子,同时记下是不是所有不同都说到了。 - 只在两边都是简单形状时说(见 _simple_block),也只说下面四类;别的不同(同一列上的时间范围、同一列上换了筛选的值、 + 只在两边都是简单形状时说(见 _simple_block),也只说下面五类;别的不同(算出来的值上换了筛选的值、 关联条件、分组、HAVING、排序、取几行、选出来的列、聚合的参数……)一律算没说到,交给兜底说明: - time:底层表上按哪一列筛时间不同(两边各在一列上按时间筛,列不同,另一边哪儿都没用这一列筛选,时间列不在 LEFT @@ -6757,6 +6768,8 @@ class _Explainer: 那一列(LOWER(x) <> 'PAID' 这类认不出取值范围、可能恒为真);按取值逐段算过不是恒为真的(见 _Range.always,一定 不为空的列不算空值,见 _never_null);不用到 LEFT JOIN 可空一侧的列(反连接,见 _on_nullable_side);不是认不准 的时间窗口(见 _window_like)。 + - filter / time_range(a、b 两边都有):同一列上的条件两边各写了一套、留下的取值确实不同(等号对 IN、取值集合不同、 + 范围的端点不同),说「A 对 X 的条件是 …,B 是 …」,见 _rewrites。 - aggregate / group_by:选出来的列只差在顶层的聚合函数(同一列做参数,SUM 对 AVG;COUNT(*) 对 SUM(x))或者去重上 (COUNT / SUM / AVG 去不去重,COUNT(*) 对 COUNT(DISTINCT x),SELECT DISTINCT 对不去重),分组一样。参数在组里 定死的不说(被 WHERE / ON 定成常量、等于分组键、本身是常量、它那张表的键在组里定死了,见 _fixed_in_group);去重 @@ -7312,8 +7325,8 @@ class _Explainer: def conditions(self, A: _Block, B: _Block, mapping: Mapping[str, str], skip_a: set[str], skip_b: set[str]) -> bool: - """WHERE:两边没配上的条件里说得准的两类——按哪一列筛时间不同(_times)、一边多出来的筛选条件(_extra_filters); - 剩下的算没说到。""" + """WHERE:两边没配上的条件里说得准的三类——按哪一列筛时间不同(_times)、同一列上的条件两边各写了一套(_rewrites)、 + 一边多出来的筛选条件(_extra_filters);剩下的算没说到。""" xa = [c for c in A.where if self.fp(c) not in skip_a] xb = [c for c in B.where if self.fp(c) not in skip_b] only_a, only_b = self._unmatched(xa, xb, mapping) @@ -7322,6 +7335,7 @@ class _Explainer: if not self.visible: return False only_a, only_b, covered = self._times(A, B, only_a, only_b) + only_a, only_b = self._rewrites(A, B, xa, xb, only_a, only_b, mapping) covered &= self._extra_filters(A, B, xa, only_a, 0) covered &= self._extra_filters(B, A, xb, only_b, 1) return covered @@ -7361,6 +7375,100 @@ class _Explainer: return [c for c in only_a if all(c is not x for x in conds_a)], \ [c for c in only_b if all(c is not x for x in conds_b)], True + def _rewrites(self, A: _Block, B: _Block, xa: list[Any], xb: list[Any], only_a: list[Any], only_b: list[Any], + mapping: Mapping[str, str]) -> tuple[list[Any], list[Any]]: + """同一列上的条件两边各写了一套、留下的取值确实不同:说「A 对 X 的条件是 …,B 是 …」——等号对 IN(口径 + status = 'paid',这次 status IN ('paid', 'refunded'))、取值集合不同、范围的端点不同。两边都只是「等于常量 / 在 + 常量列表里」、又有共同的取值时补一句各多了哪几个取值。说到了的从两边拿掉,说不准的原样还回去(交给 + _extra_filters,多半是兜底)。 + + 说得准要(每一条都是为了不说出不存在的不同):两边对这一列都有条件、至少一边有没配上的;两边 WHERE 里用到这一列的 + 条件都只涉及这一列(不和别的列交错)、比的就是那一列本身(不是 LOWER(x)),按取值逐段算过两边留下的取值不一样 + (_Range.compare:表结构里确定不为空的列不拿空值比;认不出、算出来一样的不说——x >= 100 AND x >= 50 对 x >= 100 + 只是冗余的写法);这一列不在 LEFT JOIN 的可空一侧(那是关联上的事);两边在 WHERE 之外(ON、HAVING、CASE / FILTER、 + 条件里套着的聚合参数)都没用到这一列(SUM(CASE WHEN status = 'paid' …) 配上 WHERE status IN (…) 可能和只筛 + 'paid' 一样);不是认不准的时间窗口(_window_like:date_key BETWEEN 20260901 AND 20260930);没换表。两边的条件 + 都是时间条件(timed)时 aspect 是 time_range,否则是 filter(a、b 两边都有,dual_check_facts 不会把它当成「一边 + 多出来的有效行条件」写进数据目录)。 + + 只改说明,不碰等价判定:走到这里时两边已经判了不等价(见 explain_sql_diff)。""" + if self.swapped or self.usage_a is None or self.usage_b is None: + return only_a, only_b + refs_a = [self.refs(c, A) for c in xa] + refs_b = [self.refs(c, B) for c in xb] + if any(r is None for r in refs_a + refs_b): + # 有认不出用到哪些列的条件:它可能也用到了这一列,说「A 对 X 的条件是 …」就不全 + return only_a, only_b + keys: list[tuple[str, str]] = [] + for c, block in [(c, A) for c in only_a] + [(c, B) for c in only_b]: + found = self.refs(c, block) + if found is not None and len(found) == 1 and next(iter(found)) not in keys: + keys.append(next(iter(found))) + for key in keys: + on_a = [c for c, r in zip(xa, refs_a) if key in r] + on_b = [c for c, r in zip(xb, refs_b) if key in r] + pending_a = [c for c in only_a if any(c is x for x in on_a)] + pending_b = [c for c in only_b if any(c is x for x in on_b)] + if not on_a or not on_b or not (pending_a or pending_b): + continue + if any(r != {key} for r in refs_a if r & {key}) or any(r != {key} for r in refs_b if r & {key}): + continue + if self._on_nullable_side(on_a, A) or self._on_nullable_side(on_b, B): + continue + mapped_b = [self.mapped(c, mapping) for c in on_b] + if not self._plain_subjects(on_a + mapped_b): + continue + times = {self.timed(c, A) for c in on_a} | {self.timed(c, B) for c in on_b} + if times != {key} and any(_window_like(c) for c in on_a + on_b): + continue + kinds = _kinds_for(on_a + on_b) + if self.usage_a.touches({key}, skip=on_a, kinds=kinds) or self.usage_b.touches({key}, skip=on_b, kinds=kinds): + continue + never_null = _not_null_column(self.info, key[0], key[1], self.read) + if self.range.compare(on_a, mapped_b, never_null=never_null) is not False: + continue + shown_a, shown_b = self.show_all(_by_bound(on_a), A), self.show_all(_by_bound(on_b), B) + text = _say(self.la, "对", self.named(*key, qualified=len(A.all_rels()) > 1), "的条件是", shown_a, ",", + self.lb, "是", shown_b) + more = self._members(on_a), self._members(mapped_b) + # 两边都只是取值集合、又有共同的取值时补一句各多了哪几个(两边的取值毫不相干时前一句已经说全了) + if more[0] is not None and more[1] is not None and set(more[0]) & set(more[1]): + extra_a = [shown for value, shown in more[0].items() if value not in more[1]] + extra_b = [shown for value, shown in more[1].items() if value not in more[0]] + parts = [_say(label, "多了", "、".join(extra)) for label, extra in ((self.la, extra_a), + (self.lb, extra_b)) if extra] + if parts: + text = _say(text, "(", ",".join(parts), ")") + self.add("time_range" if times == {key} else "filter", shown_a, shown_b, text, + {"columns": [{"table": key[0], "column": key[1]}]}) + only_a = [c for c in only_a if all(c is not x for x in on_a)] + only_b = [c for c in only_b if all(c is not x for x in on_b)] + return only_a, only_b + + def _members(self, conds: list[Any]) -> dict[Any, str] | None: + """几个条件(AND 连)都是「一列等于常量 / 在常量列表里」时,留下的取值 {比较用的值(见 _Range._norm): 给人看的 + 写法};有别的写法(比大小、不等于、NOT、OR、列表里的 NULL)返回 None。""" + from sqlglot import exp + + kept: dict[Any, str] | None = None + for cond in conds: + cond = _unparen(cond) + if isinstance(cond, exp.EQ): + items = [cond.expression] if _literal_value(cond.expression) is not None else [cond.this] + elif isinstance(cond, exp.In) and cond.expressions and not cond.args.get("query") and \ + not cond.args.get("unnest") and not cond.args.get("field"): + items = list(cond.expressions) + else: + return None + values: dict[Any, str] = {} + for item in items: + value = _literal_value(item) + if value is None: + return None + values.setdefault(self.range._norm(value), self.sql(item)) + kept = values if kept is None else {v: shown for v, shown in kept.items() if v in values} + return kept + def _extra_filters(self, block: _Block, other: _Block, where: list[Any], rest: list[Any], side: int) -> bool: """block 这一边多出来的筛选条件(rest 是它没配上的 WHERE 项)。按用到的列连成一组(x > 5 和 x IS NOT NULL 是一组)一组一组看,说得准的说「A 只保留 … 的行,B 没有这个条件」「A 排除了 … 的行,B 没有」「A 按 X 筛时间, @@ -7943,9 +8051,9 @@ def explain_sql_diff(a: str, b: str, dialect: str | None = None, *, schema: Mapp 一元加号、0x10、换了名字的类型名,不知道是哪种库时套着的块注释、紧跟着字的 --、双引号括起来的名字……,见 _lost_in_parsing、_misread)不在语法树上判等价,只给兜底和原文的逐行对照。 - 不等价时怎么说:两边的规范形式都是简单形状时(一段 SELECT、实表、普通的连接,见 _simple_block),只把四类有把握、 - 真会影响结果的不同写成具体的中文句子(按哪一列筛时间、一边多出来的筛选条件、顶层的聚合函数和去重、关联的表和关联 - 类型,见 _Explainer);具体说法一条都没有时给一条 aspect="other" 的兜底说明(「两条 SQL 的写法不同,未能归纳成具体 + 不等价时怎么说:两边的规范形式都是简单形状时(一段 SELECT、实表、普通的连接,见 _simple_block),只把五类有把握、 + 真会影响结果的不同写成具体的中文句子(按哪一列筛时间、一边多出来的筛选条件、同一列上的条件改写、顶层的聚合函数和 + 去重、关联的表和关联类型,见 _Explainer);具体说法一条都没有时给一条 aspect="other" 的兜底说明(「两条 SQL 的写法不同,未能归纳成具体 差异,请对照原文」),只覆盖了一部分时追加一条「另有未能归纳的差异」,fallback 为真;text_diff 给出规范化之后不同 的行(没有规范形式可比时给原文的逐行对照)。 diff --git a/backend/tests/test_dual_check_facts.py b/backend/tests/test_dual_check_facts.py index 66fdf79b..c6bff52d 100644 --- a/backend/tests/test_dual_check_facts.py +++ b/backend/tests/test_dual_check_facts.py @@ -8,8 +8,9 @@ 配方里认作日期的列(候选的 upload_dates):是的算日期列,不是的、读不出配方的说清日期是按文字保存的;数据源还没有 可用的表结构时说表结构不可用 - 差了不止一处、有兜底说明、两条 SQL 规范化后一致、差在别的方面、被采纳的一边没有可记的写法、只有一边按时间筛:不写, - 照实说原因。同一列上的时间范围不同、跨两张表的条件,差异说明只给兜底,同样不写;说明以后真把跨两张表的条件说成一条 - 筛选条件,derive 自己那道「只涉及一张表」的检查也拦得下 + 照实说原因。同一列上的时间范围不同(差异说明说成 time_range)、同一列上换了筛选的值(filter,两边都有条件)不是业务日期、 + 也不是一边多出来的有效记录条件,同样不写;跨两张表的条件差异说明只给兜底,说明以后真把它说成一条筛选条件,derive 自己 + 那道「只涉及一张表」的检查也拦得下 - 指标自己的筛选范围(只看华东、只看线上)不是有效记录条件:取值出现在指标名称、口径说明、所在分组、运行输入里的, 条件用到分组的列的,不是在排除退款、作废、测试这类无效记录的,都不写 - 写的是人工填写、已确认,备注写明运行、节点、指标;同样的说法已确认过就不再写(不升版本),确认过的不同说法不覆盖; @@ -234,10 +235,15 @@ def test_equivalent_sql_or_fallback_or_other_aspects_are_not_recorded(): assert [i["aspect"] for i in joined["diff"]["items"]] == ["aggregate"] assert "不只是去不去重" in derive(joined, SCHEMA)[1] - # 同一列上的时间范围不同:差异说明只给兜底(说不清差的是哪一段),不写 + # 同一列上的时间范围不同:差异说明说成 time_range(「原流程对 paid_date 的条件是 …,盲算是 …」),不是换了时间列,不写 ranged = candidate(RIGHT, RIGHT.replace("'2026-09-07'", "'2026-09-06'"), "b") - assert ranged["diff"]["fallback"] is True and [i["aspect"] for i in ranged["diff"]["items"]] == ["other"] - assert "未能归纳" in derive(ranged, SCHEMA)[1] + assert ranged["diff"]["fallback"] is False and [i["aspect"] for i in ranged["diff"]["items"]] == ["time_range"] + assert "两边差在时间范围" in derive(ranged, SCHEMA)[1] + # 同一列上换了筛选的值:两边都有条件(filter 两边都有),不是一边多出来的有效记录条件,不写 + valued = candidate(RIGHT, RIGHT.replace("status <> 'refunded'", "status NOT IN ('refunded', 'void')"), "b") + assert valued["diff"]["fallback"] is False and [(i["aspect"], i["a"] is not None, i["b"] is not None) + for i in valued["diff"]["items"]] == [("filter", True, True)] + assert "两边各有不同的筛选条件" in derive(valued, SCHEMA)[1] # 只差一个方面、可那个方面对不上目录里的三项(关联方式):不写,原因说清差在哪 left = (f"SELECT SUM(o.amount) AS gmv FROM orders o LEFT JOIN customers c ON c.id = o.customer_id " diff --git a/backend/tests/test_dual_check_fixture.py b/backend/tests/test_dual_check_fixture.py index cbdea294..f10f100e 100644 --- a/backend/tests/test_dual_check_fixture.py +++ b/backend/tests/test_dual_check_fixture.py @@ -9,12 +9,13 @@ check-verify 在夹具上能过,真实数据上却一处都不高亮(2026-10 差异都没有」,delta_pct 是 (盲算值 − 原流程值) ÷ |原流程值| × 100 保留两位。夹具的数据源当 SQLite 比(方言 sqlite), 两边的叫法是「原流程」「盲算」,同节点调用时一样。 -**变体。** variants 换掉「销售额」的那一对 SQL,挑的是收尾版差异说明(只说时间列、多出来的筛选、聚合与去重、关联 -四类,别的只给兜底)下各有代表性的几种:按哪一列筛时间不同(time_column,两列都声明为日期类型,预告写业务日期)、 +**变体。** variants 换掉「销售额」的那一对 SQL,挑的是收尾版差异说明(只说时间列、多出来的筛选、同一列上的条件改写、 +聚合与去重、关联五类,别的只给兜底)下各有代表性的几种:按哪一列筛时间不同(time_column,两列都声明为日期类型,预告写业务日期)、 只有一边按时间筛、一边多出来的筛选(排除无效记录的 filter_extra 预告写有效记录条件,指标自己范围的 filter_scope 不写)、 差两个筛选条件、关联方式、只差一个关联条件(join_condition:LEFT JOIN 的 ON 里多一个条件,只给兜底,逐行对照里那一行 -条件前面带着它所在的 JOIN)、聚合只差去重(dedup,预告写去重规则)、说不清的(fallback:同一列上的时间范围不同,只给 -兜底,审批卡默认展开逐行对照)、一边解析失败。 +条件前面带着它所在的 JOIN)、聚合只差去重(dedup,预告写去重规则)、说不清的(fallback:套着 DATE() 的时间范围不同—— +光一列上的范围不同如今说得出「对 paid_at 的条件是 …」,套着函数的认不出取值范围,只给兜底,审批卡默认展开逐行对照)、 +一边解析失败。 **写目录的预告。** 服务端在摘要里另带每个指标的 catalog(采用原流程、采用盲算各会往数据目录里写什么, dual_check_facts.preview,约定之外另加的),审批卡照它说;老服务端没有时卡上按差异的形状有条件地说,两种说法都按 diff --git a/backend/tests/test_sql_semantics.py b/backend/tests/test_sql_semantics.py index 7a9bbf93..0b9f35cf 100644 --- a/backend/tests/test_sql_semantics.py +++ b/backend/tests/test_sql_semantics.py @@ -616,10 +616,10 @@ def test_specific_differences_are_said_in_one_sentence_each(a, b, expected): @pytest.mark.parametrize("a,b", [ - # 同一列上的时间范围不同、同一列上换了筛选的值:不在能说的四类里 - (BASE, "SELECT SUM(amount) FROM orders WHERE paid_at BETWEEN '2026-09-01' AND '2026-09-30'"), - ("SELECT SUM(amount) FROM orders WHERE status = 'paid'", - "SELECT SUM(amount) FROM orders WHERE status IN ('paid', 'shipped')"), + # 算出来的值上换了筛选的值:认不出取值范围,不在能说的五类里(光一列上的条件改写见 + # test_rewritten_conditions_on_one_column_say_both_sides) + ("SELECT SUM(amount) FROM orders WHERE LOWER(status) = 'paid'", + "SELECT SUM(amount) FROM orders WHERE LOWER(status) IN ('paid', 'shipped')"), # 聚合的参数不同、选出来的列换了先后 ("SELECT SUM(paid_amount) FROM orders", "SELECT SUM(amount) FROM orders"), ("SELECT SUM(o.amount) FROM orders o JOIN refunds r ON r.order_id = o.id", @@ -653,8 +653,8 @@ def test_specific_differences_are_said_in_one_sentence_each(a, b, expected): "WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) SELECT COUNT(*) FROM t WHERE total > 100"), ], ids=lambda v: None) def test_differences_outside_the_four_kinds_get_only_the_fallback(a, b): - """具体说法只说四类(按哪一列筛时间、一边多出来的筛选条件、顶层的聚合函数和去重、关联的表和关联类型),而且只在 - 两边都是简单形状时说。别的不同(同一列上的范围、换了筛选的值、聚合的参数、分组、HAVING、取行、排序、关联条件、 + """具体说法只说五类(按哪一列筛时间、一边多出来的筛选条件、同一列上的条件改写、顶层的聚合函数和去重、关联的表和 + 关联类型),而且只在两边都是简单形状时说。别的不同(算出来的值上换了筛选的值、聚合的参数、分组、HAVING、取行、排序、关联条件、 UNION、CTE 和子查询里的不同)以前说得出具体的,复验在这些地方一再抓到不存在的差异(被 WHERE 定死的排序键、恒为真的 HAVING、外层不在乎的子查询差异……),现在一律只给兜底说明,附规范化后不同的行。""" for dialect in DIALECTS: @@ -663,6 +663,94 @@ def test_differences_outside_the_four_kinds_get_only_the_fallback(a, b): assert not diff.equivalent and diff.items == [FacetDiff("other", None, None, FALLBACK)], (dialect, diff.items) +@pytest.mark.parametrize("a,b,aspect,text", [ + # 等号对 IN:口径 status = 'paid',这次多收了 refunded(账本的叫法:A 是这次、B 是口径) + ("SELECT SUM(amount) FROM orders WHERE status IN ('paid', 'refunded')", + "SELECT SUM(amount) FROM orders WHERE status = 'paid'", "filter", + "这次对 status 的条件是 status IN ('paid', 'refunded'),口径是 status = 'paid'(这次多了 'refunded')"), + # 取值集合不同:各多了几个 + ("SELECT SUM(o.amount) FROM orders o JOIN stores s ON s.id = o.store_id WHERE o.status IN ('paid', 'open', 'done')", + "SELECT SUM(o.amount) FROM orders o JOIN stores s ON s.id = o.store_id WHERE o.status IN ('paid', 'void')", "filter", + "这次对 orders.status 的条件是 orders.status IN ('done', 'open', 'paid'),口径是 orders.status IN ('paid', 'void')" + "(这次多了 'done'、'open',口径多了 'void')"), + # 取值毫不相干:前一句已经说全了,不补 + ("SELECT COUNT(*) FROM orders WHERE status = 'paid'", "SELECT COUNT(*) FROM orders WHERE status = 'done'", "filter", + "这次对 status 的条件是 status = 'paid',口径是 status = 'done'"), + # 范围的端点不同 + ("SELECT COUNT(*) FROM orders WHERE amount >= 100", "SELECT COUNT(*) FROM orders WHERE amount > 100", "filter", + "这次对 amount 的条件是 amount >= 100,口径是 amount > 100"), + # 同一时间列上的范围不同:time_range + (BASE, "SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-09-01' AND paid_at < '2026-11-01'", "time_range", + "这次对 paid_at 的条件是 paid_at >= '2026-09-01' AND paid_at < '2026-10-01'," + "口径是 paid_at >= '2026-09-01' AND paid_at < '2026-11-01'"), +], ids=lambda v: None) +def test_rewritten_conditions_on_one_column_say_both_sides(a, b, aspect, text): + """同一列上的条件两边各写了一套、留下的取值不同(下一轮验收:口径 status = 'paid',这次 status IN ('paid', + 'refunded') 以前只给兜底):说「这次对 X 的条件是 …,口径是 …」,aspect 两边都有(dual_check_facts 不会把它当成一边 + 多出来的有效行条件)。各方言、给不给表结构都一样。""" + for dialect in DIALECTS: + for schema in (None, sql_pairs.schema_cache()): + diff = explain_sql_diff(a, b, dialect, schema=schema, labels=("这次", "口径")) + check_shape(diff) + assert not diff.equivalent and not diff.fallback, (dialect, diff.items) + [item] = diff.items + assert (item.aspect, item.text) == (aspect, text), (dialect, item) + assert item.a is not None and item.b is not None + assert item.detail == {"columns": [{"table": "orders", "column": text.split("对 ")[1].split(" ")[0] + .split(".")[-1]}]} + + +@pytest.mark.parametrize("a,b,schema", [ + # 冗余的写法:算出来留下的取值一样,不说 + ("SELECT COUNT(*) FROM orders WHERE amount >= 100 AND amount >= 50", "SELECT COUNT(*) FROM orders WHERE amount >= 100", + None), + # 主键不为空:差的只是空值那一段,不说(没给表结构时照说,那时不知道它不为空) + ("SELECT COUNT(*) FROM orders WHERE id = 1 OR id IS NULL", "SELECT COUNT(*) FROM orders WHERE id = 1", "schema"), + # WHERE 之外还用到了这一列:SUM(CASE WHEN status = 'paid' …) 配上 IN (…) 和只筛 'paid' 结果一样 + ("SELECT SUM(CASE WHEN status = 'paid' THEN amount END) FROM orders WHERE status IN ('paid', 'refunded')", + "SELECT SUM(amount) FROM orders WHERE status = 'paid'", None), + # 和别的列交错的条件 + ("SELECT COUNT(*) FROM orders WHERE status = 'paid' OR amount > 100", + "SELECT COUNT(*) FROM orders WHERE status IN ('paid', 'done') OR amount > 100", None), +], ids=lambda v: None) +def test_rewritten_conditions_that_may_not_change_the_result_are_not_said(a, b, schema): + for dialect in DIALECTS: + diff = explain_sql_diff(a, b, dialect, schema=sql_pairs.schema_cache() if schema else None) + check_shape(diff) + assert not diff.equivalent and not any("的条件是" in d.text for d in diff.items), (dialect, diff.items) + + +def test_explaining_rewritten_conditions_never_changes_the_equivalence_verdict(monkeypatch): + """同一列上的条件改写只进说明:等价与否、在哪一级规范形式上判的,和不说这一类时一字不差(语料各对、两种先后、给不给 + 表结构);规范化本来就认得的改写(IN 只有一个值、IN 列表去重排序)照样判等价、没有条目。""" + from app.data import sql_semantics + + pairs = [(p.a, p.b) for p in sql_pairs.PAIRS] + [ + ("SELECT SUM(amount) FROM orders WHERE status IN ('paid')", "SELECT SUM(amount) FROM orders WHERE status = 'paid'"), + ("SELECT COUNT(*) FROM orders WHERE status IN ('void', 'paid', 'paid')", + "SELECT COUNT(*) FROM orders WHERE status IN ('paid', 'void')"), + ("SELECT SUM(amount) FROM orders WHERE status IN ('paid', 'refunded')", + "SELECT SUM(amount) FROM orders WHERE status = 'paid'"), + ] + schema = sql_pairs.schema_cache() + + def verdicts() -> list[tuple[bool, str | None]]: + out = [] + for a, b in pairs: + for x, y in ((a, b), (b, a)): + for sc in (None, schema): + diff = explain_sql_diff(x, y, "sqlite", schema=sc) + out.append((diff.equivalent, diff.level)) + return out + + with_rewrites = verdicts() + monkeypatch.setattr(sql_semantics._Explainer, "_rewrites", lambda self, A, B, xa, xb, oa, ob, m: (oa, ob)) + assert verdicts() == with_rewrites + for a, b in pairs[-3:-1]: + diff = explain_sql_diff(a, b, "sqlite") + assert diff.equivalent and diff.items == [], (a, b) + + def test_a_difference_inside_a_cte_used_only_in_a_condition_subquery_is_never_missed(): """第 5 轮回退的例子:只在 IN 子查询里用到的 CTE 里的条件不同,以前整个漏报(返回空列表)。现在条件里有子查询的不是 简单形状,不说具体的,但一定不判等价、给兜底说明和规范化后不同的行。EXISTS、标量子查询一样。""" @@ -754,9 +842,9 @@ def test_nothing_specific_gives_one_fallback_item_and_the_lines_that_differ(): def test_specific_items_that_cover_only_part_end_with_the_rest_note(): - """按哪一列筛时间说得准,同一列 status 上换了筛选的值说不了:说时间那一句,补一句「另有未能归纳的差异」。""" - a = "SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-09-01' AND status = 'paid'" - b = "SELECT SUM(amount) FROM orders WHERE created_at >= '2026-09-01' AND status IN ('paid', 'done')" + """按哪一列筛时间说得准,LOWER(status) 上换了筛选的值说不了:说时间那一句,补一句「另有未能归纳的差异」。""" + a = "SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-09-01' AND LOWER(status) = 'paid'" + b = "SELECT SUM(amount) FROM orders WHERE created_at >= '2026-09-01' AND LOWER(status) IN ('paid', 'done')" diff = explain_sql_diff(a, b, labels=LABELS) check_shape(diff) assert diff.fallback and [d.text for d in diff.items] == ["原流程按 paid_at 筛时间,盲算按 created_at", FALLBACK_REST] diff --git a/backend/tests/test_sql_text_diff.py b/backend/tests/test_sql_text_diff.py index f49f670d..87a9680d 100644 --- a/backend/tests/test_sql_text_diff.py +++ b/backend/tests/test_sql_text_diff.py @@ -73,11 +73,11 @@ def test_an_extra_condition_sorted_first_does_not_drag_the_shared_ones_along(): def test_two_conditions_on_each_side_mark_only_the_one_that_differs(): - """同一列上的区间只差结束日期(这种只给兜底说明,审核人全靠逐行对照):开始日期那一行两边一样,不标。""" + """同一列上的区间只差结束日期(差异说明说成 time_range,逐行对照照样给):开始日期那一行两边一样,不标。""" a = f"SELECT SUM(fee) FROM parcels WHERE {WEEK}" b = "SELECT SUM(fee) FROM parcels WHERE sent_on BETWEEN '2031-03-03' AND '2031-03-10'" got = diff(a, b) - assert got.fallback + assert not got.fallback and [i.aspect for i in got.items] == ["time_range"] assert got.text_diff == (" WHERE", "- AND parcels.sent_on <= '2031-03-09'", "+ AND parcels.sent_on <= '2031-03-10'") diff --git a/frontend/src/dev/fixtures/dual_check.json b/frontend/src/dev/fixtures/dual_check.json index 6024462e..e2003b81 100644 --- a/frontend/src/dev/fixtures/dual_check.json +++ b/frontend/src/dev/fixtures/dual_check.json @@ -843,8 +843,8 @@ }, "fallback": { "sql": [ - "SELECT SUM(amount) AS gmv\nFROM orders\nWHERE paid_at >= '2026-09-01'\n AND paid_at < '2026-09-08'", - "SELECT SUM(amount) AS gmv\nFROM orders\nWHERE paid_at >= '2026-09-01'\n AND paid_at < '2026-09-09'" + "SELECT SUM(amount) AS gmv\nFROM orders\nWHERE paid_at >= '2026-09-01'\n AND DATE(paid_at) < '2026-09-08'", + "SELECT SUM(amount) AS gmv\nFROM orders\nWHERE paid_at >= '2026-09-01'\n AND DATE(paid_at) < '2026-09-09'" ], "diff": { "equivalent": false, @@ -857,8 +857,8 @@ "fallback": true, "text_diff": [ " WHERE", - "- AND orders.paid_at < '2026-09-08'", - "+ AND orders.paid_at < '2026-09-09'" + "- AND DATE(orders.paid_at) < '2026-09-08'", + "+ AND DATE(orders.paid_at) < '2026-09-09'" ] }, "catalog": { diff --git a/scripts/check-verify.mjs b/scripts/check-verify.mjs index 0ae030a4..59e96516 100644 --- a/scripts/check-verify.mjs +++ b/scripts/check-verify.mjs @@ -450,9 +450,9 @@ await section('审批卡:没有预告时,「记入数据目录」只说必 // 而避开本来对的一边。变体的 catalog 是服务端对这一对 SQL 的真实预告,按它核对 const { CATALOG_TABLE_FIELD_LABEL: LABEL } = await import(await load('/src/lib/terms.ts')) // [变体, 说明, 服务端不写的那一种卡上照实说的那句(只在卡上提数据目录时有)]。变体挑的是收尾版差异说明下各有代表性 - // 的几种(backend/tests/test_dual_check_fixture.py 的模块说明):同一列上的时间范围不同只给兜底(fallback) + // 的几种(backend/tests/test_dual_check_fixture.py 的模块说明):套着 DATE() 的时间范围不同只给兜底(fallback) const cases = [ - ['fallback', '说不清的(同一列上的时间范围不同,只给兜底)'], + ['fallback', '说不清的(套着 DATE() 的时间范围不同,只给兜底)'], ['two_filters', '差两个筛选条件'], ['join', '差在关联方式'], ['parse', '一边解析失败'], @@ -492,7 +492,7 @@ await section('审批卡:没有预告时,「记入数据目录」只说必 && note.includes('按不同的列筛时间') && note.includes('采用的一边所用的时间列'), note) } if (name === 'fallback') { - // 收尾版差异说明归纳不了同一列上的时间范围:卡上写那句固定的兜底说明,默认展开对照,审核人逐行对照两条 SQL + // 收尾版差异说明归纳不了套着函数的时间范围:卡上写那句固定的兜底说明,默认展开对照,审核人逐行对照两条 SQL check('……写那句固定的兜底说明,不当成一条具体差异', (await text(gmv.locator('[data-dual-fallback]'))) === '两条 SQL 的写法不同,未能归纳成具体差异' && await gmv.locator('[data-dual-diff-item]').count() === 0, await text(gmv.locator('[data-dual-fallback]'))) check('……默认展开对照', (await gmv.locator('[data-dual-compare-toggle]').getAttribute('aria-expanded')) === 'true') @@ -532,7 +532,7 @@ await section('审批卡:没有预告时,「记入数据目录」只说必 const marked = [...m.normDel, ...m.normAdd] check('……两边相同的条件(开始日期)不标,只标不同的那一行', m.normDel.length === 1 && m.normAdd.length === 1 && !marked.some((l) => l.includes(">= '2026-09-01'")) - && marked.every((l) => l.includes("orders.paid_at < '2026-09-")), marked.join(' ‖ ')) + && marked.every((l) => l.includes("DATE(orders.paid_at) < '2026-09-")), marked.join(' ‖ ')) } if (name === 'two_filters') { check('……一边多出的两个条件各占一行', m.normDel.includes("orders.channel = 'web'")