mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:21:58 +00:00
SQL 差异说明认得同一列上的条件改写:说清「这次对 X 的条件是 …,口径是 …」
验收:口径 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 对应的说法和
高亮那一行的断言跟上。
This commit is contained in:
@@ -111,7 +111,7 @@ PIPES_AS_CONCAT 的库这两处的意思不同,sqlglot 的 MySQL 读法和这
|
|||||||
**具体说法(见 _Explainer)。** 只在两边的规范形式都是「简单形状」时说(_simple_block:整条语句就是一段 SELECT,没有
|
**具体说法(见 _Explainer)。** 只在两边的规范形式都是「简单形状」时说(_simple_block:整条语句就是一段 SELECT,没有
|
||||||
CTE、子查询、UNION 这类合并、窗口函数、DISTINCT ON;FROM 里只有实表,连接只有内连接、CROSS JOIN、逗号和 LEFT JOIN;
|
CTE、子查询、UNION 这类合并、窗口函数、DISTINCT ON;FROM 里只有实表,连接只有内连接、CROSS JOIN、逗号和 LEFT JOIN;
|
||||||
LIMIT 是至少为 1 的常数;没有认不准的名字、SELECT 列表里没有 sqlglot 不认识的函数;不认列的那一级里原文没有在
|
LIMIT 是至少为 1 的常数;没有认不准的名字、SELECT 列表里没有 sqlglot 不认识的函数;不认列的那一级里原文没有在
|
||||||
WHERE / GROUP BY 里用列别名的),而且只说四类:
|
WHERE / GROUP BY 里用列别名的),而且只说五类:
|
||||||
|
|
||||||
- 时间列(time):两边各在一张底层表的一列上按时间筛、列不同(另一边哪儿都没用这一列筛选,两边的时间条件都不是恒为
|
- 时间列(time):两边各在一张底层表的一列上按时间筛、列不同(另一边哪儿都没用这一列筛选,两边的时间条件都不是恒为
|
||||||
真的,时间列不在 LEFT JOIN 的可空一侧);一边按时间筛了、另一边哪儿都没有可能是时间窗口的条件的(见下),也说成
|
真的,时间列不在 LEFT JOIN 的可空一侧);一边按时间筛了、另一边哪儿都没有可能是时间窗口的条件的(见下),也说成
|
||||||
@@ -129,6 +129,13 @@ WHERE / GROUP BY 里用列别名的),而且只说四类:
|
|||||||
不用到 LEFT JOIN 可空一侧的列(WHERE r.id IS NULL 是反连接,说的是「没关联上」;r.order_id IS NULL 和 ON 里的
|
不用到 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
|
r.order_id = o.id 一起已经蕴含 r.id IS NULL);不是可能的时间窗口(_window_like:date_key BETWEEN 20260901 AND
|
||||||
20260930、stat_month = 202609——认不准是不是时间列,说成有效行过滤会被写进数据目录)。detail 给条件涉及的实表列。
|
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))
|
- 聚合与去重(aggregate / group_by):选出来的列只差在顶层的聚合函数上(同一列做参数,SUM 对 AVG;COUNT(*) 对 SUM(x))
|
||||||
或者去重上(COUNT / SUM / AVG 去不去重、COUNT(*) 对 COUNT(DISTINCT x)、SELECT DISTINCT 对不去重),分组一样。参数
|
或者去重上(COUNT / SUM / AVG 去不去重、COUNT(*) 对 COUNT(DISTINCT x)、SELECT DISTINCT 对不去重),分组一样。参数
|
||||||
得是光一列、在每组里不是定死的(不被 WHERE 定成常量、不等于分组键、表结构里它那张表的键没有在组里定死——按 u.id 分组
|
得是光一列、在每组里不是定死的(不被 WHERE 定成常量、不等于分组键、表结构里它那张表的键没有在组里定死——按 u.id 分组
|
||||||
@@ -144,7 +151,7 @@ WHERE / GROUP BY 里用列别名的),而且只说四类:
|
|||||||
每一句都先确认这处不同真会影响结果:两边的 WHERE、HAVING、ON 里有可能恒为假的条件(常量条件,同一个对象上取不到值的
|
每一句都先确认这处不同真会影响结果:两边的 WHERE、HAVING、ON 里有可能恒为假的条件(常量条件,同一个对象上取不到值的
|
||||||
几个条件)时什么都不说;结果看不见行的(SELECT COUNT(*) > -1 这类只出一行、值又和行无关的)不说筛选、关联上的不同;
|
几个条件)时什么都不说;结果看不见行的(SELECT COUNT(*) > -1 这类只出一行、值又和行无关的)不说筛选、关联上的不同;
|
||||||
表结构里的约束(主键、唯一约束、外键、NOT NULL)说明不起作用的不说——约束只拿来少说,不拿来判等价。被 WHERE 用常量定死
|
表结构里的约束(主键、唯一约束、外键、NOT NULL)说明不起作用的不说——约束只拿来少说,不拿来判等价。被 WHERE 用常量定死
|
||||||
的列出现在排序、分组里的差异,恒为真的 HAVING,都不在四类里,不说。两边给人看的写法一样、和已经说过的一句正好反过来的
|
的列出现在排序、分组里的差异,恒为真的 HAVING,都不在五类里,不说。两边给人看的写法一样、和已经说过的一句正好反过来的
|
||||||
不说。
|
不说。
|
||||||
|
|
||||||
**兜底。** 规范形式不一致、一条具体说法都没有时,给一条 aspect="other" 的「两条 SQL 的写法不同,未能归纳成具体差异,
|
**兜底。** 规范形式不一致、一条具体说法都没有时,给一条 aspect="other" 的「两条 SQL 的写法不同,未能归纳成具体差异,
|
||||||
@@ -164,8 +171,9 @@ WHERE / GROUP BY 里用列别名的),而且只说四类:
|
|||||||
关联先后、按 WHERE 把外连接改成内连接、换成相等的列;没给表结构(或者类型不一样)时按关联条件相等的两列换着分组、
|
关联先后、按 WHERE 把外连接改成内连接、换成相等的列;没给表结构(或者类型不一样)时按关联条件相等的两列换着分组、
|
||||||
排序;PostgreSQL、MySQL 没给表结构时去重、分组、求和的一段调换关联先后、并子查询、去掉没用到的 LEFT JOIN、把只取
|
排序;PostgreSQL、MySQL 没给表结构时去重、分组、求和的一段调换关联先后、并子查询、去掉没用到的 LEFT JOIN、把只取
|
||||||
可空一侧聚合的 LEFT JOIN 写成内连接。
|
可空一侧聚合的 LEFT JOIN 写成内连接。
|
||||||
- 只给兜底的:不是简单形状的(CTE、子查询、UNION、窗口函数,规范化没把它们并掉的);四类以外的不同(同一列上的时间
|
- 只给兜底的:不是简单形状的(CTE、子查询、UNION、窗口函数,规范化没把它们并掉的);五类以外的不同(算出来的值上的
|
||||||
范围、同一列上换了筛选的值、关联条件、分组、HAVING、排序、取几行、选出来的列、聚合的参数);几张表、几列交错的条件;
|
条件改写——LOWER(status) = 'paid' 对 LOWER(status) IN (…)、和别的列交错的同一列条件、关联条件、分组、HAVING、排序、
|
||||||
|
取几行、选出来的列、聚合的参数);几张表、几列交错的条件;
|
||||||
自己关联自己、同名的表关联了几次的;换了表又有关联条件不同的;条件写在聚合里的一边对条件写在 WHERE 里的一边(SUM(status
|
自己关联自己、同名的表关联了几次的;换了表又有关联条件不同的;条件写在聚合里的一边对条件写在 WHERE 里的一边(SUM(status
|
||||||
= 'paid') 对 COUNT(*) … WHERE status = 'paid':真正的不同是计数为 0 的组、空集时是 0 还是 NULL,说不清);反连接;
|
= 'paid') 对 COUNT(*) … WHERE status = 'paid':真正的不同是计数为 0 的组、空集时是 0 还是 NULL,说不清);反连接;
|
||||||
可能是时间窗口、又认不准是不是时间列的条件(业务日期键、统计月份、分区,第几天这类范围)。
|
可能是时间窗口、又认不准是不是时间列的条件(业务日期键、统计月份、分区,第几天这类范围)。
|
||||||
@@ -5902,8 +5910,9 @@ class FacetDiff:
|
|||||||
set_op(几支查询怎么合并)/ group_by(分组键、去重)/ having / limit(取哪几行,含取行前的排序)/ order(只是最终
|
set_op(几支查询怎么合并)/ group_by(分组键、去重)/ having / limit(取哪几行,含取行前的排序)/ order(只是最终
|
||||||
结果的排序不同,不影响取到哪些行)/ parse(解析不了)/ other(兜底:未能归纳成具体差异,见 explain_sql_diff)。a、b
|
结果的排序不同,不影响取到哪些行)/ parse(解析不了)/ other(兜底:未能归纳成具体差异,见 explain_sql_diff)。a、b
|
||||||
是两边各自的写法(给人看,没有就是 None),text 是一句中文说明。detail 只在 time 上有:两边的时间列
|
是两边各自的写法(给人看,没有就是 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
|
aspect: str
|
||||||
a: Any
|
a: Any
|
||||||
@@ -6360,8 +6369,10 @@ class _Range:
|
|||||||
subject = n.this
|
subject = n.this
|
||||||
return self._truth(n, assign[_fingerprint(subject)])
|
return self._truth(n, assign[_fingerprint(subject)])
|
||||||
|
|
||||||
def compare(self, conds_a: list[Any], conds_b: list[Any], grouped: bool = False) -> bool | None:
|
def compare(self, conds_a: list[Any], conds_b: list[Any], grouped: bool = False,
|
||||||
"""两组条件(各自 AND 连,可以一边为空 = 不筛)留下的取值是不是一样:一样 True,不一样 False,认不出 None。"""
|
never_null: bool = False) -> bool | None:
|
||||||
|
"""两组条件(各自 AND 连,可以一边为空 = 不筛)留下的取值是不是一样:一样 True,不一样 False,认不出 None。
|
||||||
|
never_null:对象一定不为空(表结构里不为空的列),不拿空值比。"""
|
||||||
parsed = [self.subject(c) for c in conds_a + conds_b]
|
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:
|
if any(p is None for p in parsed) or len({p[0] for p in parsed}) != 1:
|
||||||
return None
|
return None
|
||||||
@@ -6373,7 +6384,7 @@ class _Range:
|
|||||||
from sqlglot import exp
|
from sqlglot import exp
|
||||||
|
|
||||||
values = self._pieces(subject, literals, ranged, grouped)
|
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)
|
values.append(None)
|
||||||
for v in values:
|
for v in values:
|
||||||
ta = all(self._truth(c, v) == "T" for c in conds_a)
|
ta = all(self._truth(c, v) == "T" for c in conds_a)
|
||||||
@@ -6745,7 +6756,7 @@ def _alias_named(root: Any) -> bool:
|
|||||||
class _Explainer:
|
class _Explainer:
|
||||||
"""在两边的规范形式上找有把握、又确实影响结果的不同,写成具体的中文句子,同时记下是不是所有不同都说到了。
|
"""在两边的规范形式上找有把握、又确实影响结果的不同,写成具体的中文句子,同时记下是不是所有不同都说到了。
|
||||||
|
|
||||||
只在两边都是简单形状时说(见 _simple_block),也只说下面四类;别的不同(同一列上的时间范围、同一列上换了筛选的值、
|
只在两边都是简单形状时说(见 _simple_block),也只说下面五类;别的不同(算出来的值上换了筛选的值、
|
||||||
关联条件、分组、HAVING、排序、取几行、选出来的列、聚合的参数……)一律算没说到,交给兜底说明:
|
关联条件、分组、HAVING、排序、取几行、选出来的列、聚合的参数……)一律算没说到,交给兜底说明:
|
||||||
|
|
||||||
- time:底层表上按哪一列筛时间不同(两边各在一列上按时间筛,列不同,另一边哪儿都没用这一列筛选,时间列不在 LEFT
|
- time:底层表上按哪一列筛时间不同(两边各在一列上按时间筛,列不同,另一边哪儿都没用这一列筛选,时间列不在 LEFT
|
||||||
@@ -6757,6 +6768,8 @@ class _Explainer:
|
|||||||
那一列(LOWER(x) <> 'PAID' 这类认不出取值范围、可能恒为真);按取值逐段算过不是恒为真的(见 _Range.always,一定
|
那一列(LOWER(x) <> 'PAID' 这类认不出取值范围、可能恒为真);按取值逐段算过不是恒为真的(见 _Range.always,一定
|
||||||
不为空的列不算空值,见 _never_null);不用到 LEFT JOIN 可空一侧的列(反连接,见 _on_nullable_side);不是认不准
|
不为空的列不算空值,见 _never_null);不用到 LEFT JOIN 可空一侧的列(反连接,见 _on_nullable_side);不是认不准
|
||||||
的时间窗口(见 _window_like)。
|
的时间窗口(见 _window_like)。
|
||||||
|
- filter / time_range(a、b 两边都有):同一列上的条件两边各写了一套、留下的取值确实不同(等号对 IN、取值集合不同、
|
||||||
|
范围的端点不同),说「A 对 X 的条件是 …,B 是 …」,见 _rewrites。
|
||||||
- aggregate / group_by:选出来的列只差在顶层的聚合函数(同一列做参数,SUM 对 AVG;COUNT(*) 对 SUM(x))或者去重上
|
- aggregate / group_by:选出来的列只差在顶层的聚合函数(同一列做参数,SUM 对 AVG;COUNT(*) 对 SUM(x))或者去重上
|
||||||
(COUNT / SUM / AVG 去不去重,COUNT(*) 对 COUNT(DISTINCT x),SELECT DISTINCT 对不去重),分组一样。参数在组里
|
(COUNT / SUM / AVG 去不去重,COUNT(*) 对 COUNT(DISTINCT x),SELECT DISTINCT 对不去重),分组一样。参数在组里
|
||||||
定死的不说(被 WHERE / ON 定成常量、等于分组键、本身是常量、它那张表的键在组里定死了,见 _fixed_in_group);去重
|
定死的不说(被 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],
|
def conditions(self, A: _Block, B: _Block, mapping: Mapping[str, str], skip_a: set[str],
|
||||||
skip_b: set[str]) -> bool:
|
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]
|
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]
|
xb = [c for c in B.where if self.fp(c) not in skip_b]
|
||||||
only_a, only_b = self._unmatched(xa, xb, mapping)
|
only_a, only_b = self._unmatched(xa, xb, mapping)
|
||||||
@@ -7322,6 +7335,7 @@ class _Explainer:
|
|||||||
if not self.visible:
|
if not self.visible:
|
||||||
return False
|
return False
|
||||||
only_a, only_b, covered = self._times(A, B, only_a, only_b)
|
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(A, B, xa, only_a, 0)
|
||||||
covered &= self._extra_filters(B, A, xb, only_b, 1)
|
covered &= self._extra_filters(B, A, xb, only_b, 1)
|
||||||
return covered
|
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)], \
|
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
|
[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:
|
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
|
"""block 这一边多出来的筛选条件(rest 是它没配上的 WHERE 项)。按用到的列连成一组(x > 5 和 x IS NOT NULL
|
||||||
是一组)一组一组看,说得准的说「A 只保留 … 的行,B 没有这个条件」「A 排除了 … 的行,B 没有」「A 按 X 筛时间,
|
是一组)一组一组看,说得准的说「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、换了名字的类型名,不知道是哪种库时套着的块注释、紧跟着字的 --、双引号括起来的名字……,见
|
一元加号、0x10、换了名字的类型名,不知道是哪种库时套着的块注释、紧跟着字的 --、双引号括起来的名字……,见
|
||||||
_lost_in_parsing、_misread)不在语法树上判等价,只给兜底和原文的逐行对照。
|
_lost_in_parsing、_misread)不在语法树上判等价,只给兜底和原文的逐行对照。
|
||||||
|
|
||||||
不等价时怎么说:两边的规范形式都是简单形状时(一段 SELECT、实表、普通的连接,见 _simple_block),只把四类有把握、
|
不等价时怎么说:两边的规范形式都是简单形状时(一段 SELECT、实表、普通的连接,见 _simple_block),只把五类有把握、
|
||||||
真会影响结果的不同写成具体的中文句子(按哪一列筛时间、一边多出来的筛选条件、顶层的聚合函数和去重、关联的表和关联
|
真会影响结果的不同写成具体的中文句子(按哪一列筛时间、一边多出来的筛选条件、同一列上的条件改写、顶层的聚合函数和
|
||||||
类型,见 _Explainer);具体说法一条都没有时给一条 aspect="other" 的兜底说明(「两条 SQL 的写法不同,未能归纳成具体
|
去重、关联的表和关联类型,见 _Explainer);具体说法一条都没有时给一条 aspect="other" 的兜底说明(「两条 SQL 的写法不同,未能归纳成具体
|
||||||
差异,请对照原文」),只覆盖了一部分时追加一条「另有未能归纳的差异」,fallback 为真;text_diff 给出规范化之后不同
|
差异,请对照原文」),只覆盖了一部分时追加一条「另有未能归纳的差异」,fallback 为真;text_diff 给出规范化之后不同
|
||||||
的行(没有规范形式可比时给原文的逐行对照)。
|
的行(没有规范形式可比时给原文的逐行对照)。
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,9 @@
|
|||||||
配方里认作日期的列(候选的 upload_dates):是的算日期列,不是的、读不出配方的说清日期是按文字保存的;数据源还没有
|
配方里认作日期的列(候选的 upload_dates):是的算日期列,不是的、读不出配方的说清日期是按文字保存的;数据源还没有
|
||||||
可用的表结构时说表结构不可用
|
可用的表结构时说表结构不可用
|
||||||
- 差了不止一处、有兜底说明、两条 SQL 规范化后一致、差在别的方面、被采纳的一边没有可记的写法、只有一边按时间筛:不写,
|
- 差了不止一处、有兜底说明、两条 SQL 规范化后一致、差在别的方面、被采纳的一边没有可记的写法、只有一边按时间筛:不写,
|
||||||
照实说原因。同一列上的时间范围不同、跨两张表的条件,差异说明只给兜底,同样不写;说明以后真把跨两张表的条件说成一条
|
照实说原因。同一列上的时间范围不同(差异说明说成 time_range)、同一列上换了筛选的值(filter,两边都有条件)不是业务日期、
|
||||||
筛选条件,derive 自己那道「只涉及一张表」的检查也拦得下
|
也不是一边多出来的有效记录条件,同样不写;跨两张表的条件差异说明只给兜底,说明以后真把它说成一条筛选条件,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 [i["aspect"] for i in joined["diff"]["items"]] == ["aggregate"]
|
||||||
assert "不只是去不去重" in derive(joined, SCHEMA)[1]
|
assert "不只是去不去重" in derive(joined, SCHEMA)[1]
|
||||||
|
|
||||||
# 同一列上的时间范围不同:差异说明只给兜底(说不清差的是哪一段),不写
|
# 同一列上的时间范围不同:差异说明说成 time_range(「原流程对 paid_date 的条件是 …,盲算是 …」),不是换了时间列,不写
|
||||||
ranged = candidate(RIGHT, RIGHT.replace("'2026-09-07'", "'2026-09-06'"), "b")
|
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 ranged["diff"]["fallback"] is False and [i["aspect"] for i in ranged["diff"]["items"]] == ["time_range"]
|
||||||
assert "未能归纳" in derive(ranged, SCHEMA)[1]
|
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 "
|
left = (f"SELECT SUM(o.amount) AS gmv FROM orders o LEFT JOIN customers c ON c.id = o.customer_id "
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ check-verify 在夹具上能过,真实数据上却一处都不高亮(2026-10
|
|||||||
差异都没有」,delta_pct 是 (盲算值 − 原流程值) ÷ |原流程值| × 100 保留两位。夹具的数据源当 SQLite 比(方言 sqlite),
|
差异都没有」,delta_pct 是 (盲算值 − 原流程值) ÷ |原流程值| × 100 保留两位。夹具的数据源当 SQLite 比(方言 sqlite),
|
||||||
两边的叫法是「原流程」「盲算」,同节点调用时一样。
|
两边的叫法是「原流程」「盲算」,同节点调用时一样。
|
||||||
|
|
||||||
**变体。** variants 换掉「销售额」的那一对 SQL,挑的是收尾版差异说明(只说时间列、多出来的筛选、聚合与去重、关联
|
**变体。** variants 换掉「销售额」的那一对 SQL,挑的是收尾版差异说明(只说时间列、多出来的筛选、同一列上的条件改写、
|
||||||
四类,别的只给兜底)下各有代表性的几种:按哪一列筛时间不同(time_column,两列都声明为日期类型,预告写业务日期)、
|
聚合与去重、关联五类,别的只给兜底)下各有代表性的几种:按哪一列筛时间不同(time_column,两列都声明为日期类型,预告写业务日期)、
|
||||||
只有一边按时间筛、一边多出来的筛选(排除无效记录的 filter_extra 预告写有效记录条件,指标自己范围的 filter_scope 不写)、
|
只有一边按时间筛、一边多出来的筛选(排除无效记录的 filter_extra 预告写有效记录条件,指标自己范围的 filter_scope 不写)、
|
||||||
差两个筛选条件、关联方式、只差一个关联条件(join_condition:LEFT JOIN 的 ON 里多一个条件,只给兜底,逐行对照里那一行
|
差两个筛选条件、关联方式、只差一个关联条件(join_condition:LEFT JOIN 的 ON 里多一个条件,只给兜底,逐行对照里那一行
|
||||||
条件前面带着它所在的 JOIN)、聚合只差去重(dedup,预告写去重规则)、说不清的(fallback:同一列上的时间范围不同,只给
|
条件前面带着它所在的 JOIN)、聚合只差去重(dedup,预告写去重规则)、说不清的(fallback:套着 DATE() 的时间范围不同——
|
||||||
兜底,审批卡默认展开逐行对照)、一边解析失败。
|
光一列上的范围不同如今说得出「对 paid_at 的条件是 …」,套着函数的认不出取值范围,只给兜底,审批卡默认展开逐行对照)、
|
||||||
|
一边解析失败。
|
||||||
|
|
||||||
**写目录的预告。** 服务端在摘要里另带每个指标的 catalog(采用原流程、采用盲算各会往数据目录里写什么,
|
**写目录的预告。** 服务端在摘要里另带每个指标的 catalog(采用原流程、采用盲算各会往数据目录里写什么,
|
||||||
dual_check_facts.preview,约定之外另加的),审批卡照它说;老服务端没有时卡上按差异的形状有条件地说,两种说法都按
|
dual_check_facts.preview,约定之外另加的),审批卡照它说;老服务端没有时卡上按差异的形状有条件地说,两种说法都按
|
||||||
|
|||||||
@@ -616,10 +616,10 @@ def test_specific_differences_are_said_in_one_sentence_each(a, b, expected):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("a,b", [
|
@pytest.mark.parametrize("a,b", [
|
||||||
# 同一列上的时间范围不同、同一列上换了筛选的值:不在能说的四类里
|
# 算出来的值上换了筛选的值:认不出取值范围,不在能说的五类里(光一列上的条件改写见
|
||||||
(BASE, "SELECT SUM(amount) FROM orders WHERE paid_at BETWEEN '2026-09-01' AND '2026-09-30'"),
|
# test_rewritten_conditions_on_one_column_say_both_sides)
|
||||||
("SELECT SUM(amount) FROM orders WHERE status = 'paid'",
|
("SELECT SUM(amount) FROM orders WHERE LOWER(status) = 'paid'",
|
||||||
"SELECT SUM(amount) FROM orders WHERE status IN ('paid', 'shipped')"),
|
"SELECT SUM(amount) FROM orders WHERE LOWER(status) IN ('paid', 'shipped')"),
|
||||||
# 聚合的参数不同、选出来的列换了先后
|
# 聚合的参数不同、选出来的列换了先后
|
||||||
("SELECT SUM(paid_amount) FROM orders", "SELECT SUM(amount) FROM orders"),
|
("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",
|
("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"),
|
"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)
|
], ids=lambda v: None)
|
||||||
def test_differences_outside_the_four_kinds_get_only_the_fallback(a, b):
|
def test_differences_outside_the_four_kinds_get_only_the_fallback(a, b):
|
||||||
"""具体说法只说四类(按哪一列筛时间、一边多出来的筛选条件、顶层的聚合函数和去重、关联的表和关联类型),而且只在
|
"""具体说法只说五类(按哪一列筛时间、一边多出来的筛选条件、同一列上的条件改写、顶层的聚合函数和去重、关联的表和
|
||||||
两边都是简单形状时说。别的不同(同一列上的范围、换了筛选的值、聚合的参数、分组、HAVING、取行、排序、关联条件、
|
关联类型),而且只在两边都是简单形状时说。别的不同(算出来的值上换了筛选的值、聚合的参数、分组、HAVING、取行、排序、关联条件、
|
||||||
UNION、CTE 和子查询里的不同)以前说得出具体的,复验在这些地方一再抓到不存在的差异(被 WHERE 定死的排序键、恒为真的
|
UNION、CTE 和子查询里的不同)以前说得出具体的,复验在这些地方一再抓到不存在的差异(被 WHERE 定死的排序键、恒为真的
|
||||||
HAVING、外层不在乎的子查询差异……),现在一律只给兜底说明,附规范化后不同的行。"""
|
HAVING、外层不在乎的子查询差异……),现在一律只给兜底说明,附规范化后不同的行。"""
|
||||||
for dialect in DIALECTS:
|
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)
|
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():
|
def test_a_difference_inside_a_cte_used_only_in_a_condition_subquery_is_never_missed():
|
||||||
"""第 5 轮回退的例子:只在 IN 子查询里用到的 CTE 里的条件不同,以前整个漏报(返回空列表)。现在条件里有子查询的不是
|
"""第 5 轮回退的例子:只在 IN 子查询里用到的 CTE 里的条件不同,以前整个漏报(返回空列表)。现在条件里有子查询的不是
|
||||||
简单形状,不说具体的,但一定不判等价、给兜底说明和规范化后不同的行。EXISTS、标量子查询一样。"""
|
简单形状,不说具体的,但一定不判等价、给兜底说明和规范化后不同的行。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():
|
def test_specific_items_that_cover_only_part_end_with_the_rest_note():
|
||||||
"""按哪一列筛时间说得准,同一列 status 上换了筛选的值说不了:说时间那一句,补一句「另有未能归纳的差异」。"""
|
"""按哪一列筛时间说得准,LOWER(status) 上换了筛选的值说不了:说时间那一句,补一句「另有未能归纳的差异」。"""
|
||||||
a = "SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-09-01' AND status = 'paid'"
|
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 status IN ('paid', 'done')"
|
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)
|
diff = explain_sql_diff(a, b, labels=LABELS)
|
||||||
check_shape(diff)
|
check_shape(diff)
|
||||||
assert diff.fallback and [d.text for d in diff.items] == ["原流程按 paid_at 筛时间,盲算按 created_at", FALLBACK_REST]
|
assert diff.fallback and [d.text for d in diff.items] == ["原流程按 paid_at 筛时间,盲算按 created_at", FALLBACK_REST]
|
||||||
|
|||||||
@@ -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():
|
def test_two_conditions_on_each_side_mark_only_the_one_that_differs():
|
||||||
"""同一列上的区间只差结束日期(这种只给兜底说明,审核人全靠逐行对照):开始日期那一行两边一样,不标。"""
|
"""同一列上的区间只差结束日期(差异说明说成 time_range,逐行对照照样给):开始日期那一行两边一样,不标。"""
|
||||||
a = f"SELECT SUM(fee) FROM parcels WHERE {WEEK}"
|
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'"
|
b = "SELECT SUM(fee) FROM parcels WHERE sent_on BETWEEN '2031-03-03' AND '2031-03-10'"
|
||||||
got = diff(a, b)
|
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'")
|
assert got.text_diff == (" WHERE", "- AND parcels.sent_on <= '2031-03-09'", "+ AND parcels.sent_on <= '2031-03-10'")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -843,8 +843,8 @@
|
|||||||
},
|
},
|
||||||
"fallback": {
|
"fallback": {
|
||||||
"sql": [
|
"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 DATE(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-09'"
|
||||||
],
|
],
|
||||||
"diff": {
|
"diff": {
|
||||||
"equivalent": false,
|
"equivalent": false,
|
||||||
@@ -857,8 +857,8 @@
|
|||||||
"fallback": true,
|
"fallback": true,
|
||||||
"text_diff": [
|
"text_diff": [
|
||||||
" WHERE",
|
" WHERE",
|
||||||
"- AND orders.paid_at < '2026-09-08'",
|
"- AND DATE(orders.paid_at) < '2026-09-08'",
|
||||||
"+ AND orders.paid_at < '2026-09-09'"
|
"+ AND DATE(orders.paid_at) < '2026-09-09'"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"catalog": {
|
"catalog": {
|
||||||
|
|||||||
@@ -450,9 +450,9 @@ await section('审批卡:没有预告时,「记入数据目录」只说必
|
|||||||
// 而避开本来对的一边。变体的 catalog 是服务端对这一对 SQL 的真实预告,按它核对
|
// 而避开本来对的一边。变体的 catalog 是服务端对这一对 SQL 的真实预告,按它核对
|
||||||
const { CATALOG_TABLE_FIELD_LABEL: LABEL } = await import(await load('/src/lib/terms.ts'))
|
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 = [
|
const cases = [
|
||||||
['fallback', '说不清的(同一列上的时间范围不同,只给兜底)'],
|
['fallback', '说不清的(套着 DATE() 的时间范围不同,只给兜底)'],
|
||||||
['two_filters', '差两个筛选条件'],
|
['two_filters', '差两个筛选条件'],
|
||||||
['join', '差在关联方式'],
|
['join', '差在关联方式'],
|
||||||
['parse', '一边解析失败'],
|
['parse', '一边解析失败'],
|
||||||
@@ -492,7 +492,7 @@ await section('审批卡:没有预告时,「记入数据目录」只说必
|
|||||||
&& note.includes('按不同的列筛时间') && note.includes('采用的一边所用的时间列'), note)
|
&& note.includes('按不同的列筛时间') && note.includes('采用的一边所用的时间列'), note)
|
||||||
}
|
}
|
||||||
if (name === 'fallback') {
|
if (name === 'fallback') {
|
||||||
// 收尾版差异说明归纳不了同一列上的时间范围:卡上写那句固定的兜底说明,默认展开对照,审核人逐行对照两条 SQL
|
// 收尾版差异说明归纳不了套着函数的时间范围:卡上写那句固定的兜底说明,默认展开对照,审核人逐行对照两条 SQL
|
||||||
check('……写那句固定的兜底说明,不当成一条具体差异', (await text(gmv.locator('[data-dual-fallback]'))) === '两条 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]')))
|
&& 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')
|
check('……默认展开对照', (await gmv.locator('[data-dual-compare-toggle]').getAttribute('aria-expanded')) === 'true')
|
||||||
@@ -532,7 +532,7 @@ await section('审批卡:没有预告时,「记入数据目录」只说必
|
|||||||
const marked = [...m.normDel, ...m.normAdd]
|
const marked = [...m.normDel, ...m.normAdd]
|
||||||
check('……两边相同的条件(开始日期)不标,只标不同的那一行', m.normDel.length === 1 && m.normAdd.length === 1
|
check('……两边相同的条件(开始日期)不标,只标不同的那一行', m.normDel.length === 1 && m.normAdd.length === 1
|
||||||
&& !marked.some((l) => l.includes(">= '2026-09-01'"))
|
&& !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') {
|
if (name === 'two_filters') {
|
||||||
check('……一边多出的两个条件各占一行', m.normDel.includes("orders.channel = 'web'")
|
check('……一边多出的两个条件各占一行', m.normDel.includes("orders.channel = 'web'")
|
||||||
|
|||||||
Reference in New Issue
Block a user