合入 SQL 差异说明收尾:判等价只用保持语义的规范化(去掉依赖表约束的改写),解析保真守卫拦下已知会被读错的写法(MySQL 反斜杠转义、特殊数字写法、比较 / 按位 / 前缀 NOT 没加括号地混用、PostgreSQL 符号运算符与 || 混用等),具体说法只在简单形状上说四类,其余给兜底并附逐行对照;回归语料与执行比对守住等价结论

This commit is contained in:
ThearchyHelios
2026-10-06 09:18:35 +08:00
4 changed files with 4650 additions and 1266 deletions
File diff suppressed because it is too large Load Diff
+1050 -11
View File
File diff suppressed because it is too large Load Diff
+268 -6
View File
@@ -6,13 +6,25 @@ explain_sql_diff 判两条 SQL 等价,当且仅当两边经过只保持语义
- 判了等价的两条,在每组数据上结果都必须一样(结果集按行排序比较);
- 在某组数据上结果不一样的两条,绝不能判等价;
- 语料里标 diff 的(语义不同,或者只在某个方言、某种排序规则下才等价),一律不能判等价;
- 标 same 的(已知等价),没认出来时只许给兜底说明,不许给具体说法(具体说法说的是两边确实不同的地方);
- 标 same 的(已知等价),没认出来时只许给兜底说明,不许给具体说法(具体说法说的是两边确实不同的地方);要靠表结构
里的约束(主键唯一、不为空)才看得出等价的(Pair.needs_schema):规范化不靠约束改写,一律不判等价,给了表结构时
也只许给兜底(约束已经排除了的不同不许说),没给表结构时说得出具体的不同不算错;
- 等价改写(编号不以 hard: 开头的 same)记下认出来的比例:目标是大多数都认得出,但不强求全部。
库和语料见 tests/fixtures/sql_pairs.py:5 组按固定种子生成的数据加一组极端数据、一组专挑写法差异的数据(0.1 + 0.2、
像数字的文本、大小写不同的值),带空值、重复、边界值、已删除的行、退款;语料里有手写的等价改写和语义不同的对,也有
复验者以前造的、在这几组数据上结果不同的对,以及第 2 轮复验报的反例。每一对都在不给表结构和给表结构(数据源的
复验者以前造的、在这几组数据上结果不同的对,以及第 2、3、4 轮复验报的反例。每一对都在不给表结构和给表结构(数据源的
schema_cache)两种情况下各判一次,两种都要守住上面的规矩。
第 4 轮复验报的反例另有几组只查结论的(见文件末尾):只在 PostgreSQL / MySQL 才有的写法(sqlglot 读错了的结合顺序、
MySQL 字符串里的反斜杠转义、数字写法)一律不判等价、只给兜底;靠约束改写的陷阱(TEXT 主键对数值列、COLLATE、部分唯一
索引、MySQL 的隐式转换)在现造的小库上执行、一律不判等价;在 SQLite 上执行的那几对再拿 PostgreSQL / MySQL 的读法
各判一遍,意思一样的只许给兜底。
第 5 轮的反例同样分两种:PostgreSQL 写成符号的 ~ / ~* / ~~ / ~~* / @@ 和 || 混用、前缀的 @、|/、COLLATE 后面跟 ::,
不知道是哪种库时套着的块注释、紧跟着字的 --、双引号括起来的名字,一律不判等价、只给兜底;具体说法说错了的(条件写在聚合里被说成「另一边
没有这个条件」、时间窗口被说成有效行过滤或者「没有按时间筛选」、反连接被说成有效行过滤、按主键定下来的列说 MAX 对
MIN、被规范化去掉的 LEFT JOIN 说成「另一边没有」)在 SQLite 上执行,再拿三种方言各判一遍。
"""
from __future__ import annotations
@@ -61,9 +73,12 @@ def test_equivalence_is_guarded_by_execution(databases, pair):
assert not diff.equivalent, f"{pair.id}:第 {differs} 组数据上结果不同,却判了等价(schema={bool(schema)})"
if pair.expect == "diff":
assert not diff.equivalent, f"{pair.id} 不该判等价(schema={bool(schema)})"
if pair.expect == "same" and not diff.equivalent:
if pair.expect == "same" and not diff.equivalent and not (pair.needs_schema and schema is None):
specific = [d.text for d in diff.items if d.aspect != "other"]
assert not specific, f"{pair.id} 意思一样,却说了具体的不同(schema={bool(schema)}):{specific}"
if pair.needs_schema:
# 约束(主键唯一、不为空)不可靠,规范化不靠它改写:给不给表结构都不判等价
assert not diff.equivalent, f"{pair.id} 只有靠约束才看得出等价,不该判等价(schema={bool(schema)})"
# 换个先后问,结论一样
assert explain_sql_diff(pair.b, pair.a, "sqlite", schema=schema).equivalent == diff.equivalent, pair.id
@@ -73,9 +88,17 @@ def test_most_known_equivalent_rewrites_are_recognized():
认不出的给的是兜底说明,不是错误的结论。要按语义推理才看得出的(hard:)另记,不算进比例。"""
same = [p for p in sql_pairs.PAIRS if p.expect == "same" and not p.id.startswith("hard:")]
hits = [p.id for p in same if explain_sql_diff(p.a, p.b, "sqlite", schema=SCHEMA).equivalent]
bare = [p.id for p in same if explain_sql_diff(p.a, p.b, "sqlite").equivalent]
ratio, bare_ratio = len(hits) / len(same), len(bare) / len(same)
print(f"\n已知等价的改写 {len(same)} 对里认出 {len(hits)} 对({ratio:.0%}),不给表结构时 {len(bare)} 对({bare_ratio:.0%})")
# 要靠约束才看得出的(needs_schema)规范化不认,单列出来:按以前的口径(算进比例)和不算进比例的都打出来
keyed = [p for p in same if not p.needs_schema]
keyed_hits = [p.id for p in keyed if p.id in hits]
print(f"\n不靠约束的 {len(keyed)} 对里认出 {len(keyed_hits)} 对({len(keyed_hits) / len(keyed):.0%});"
f"要靠约束的 {len(same) - len(keyed)} 对一律不认(只给兜底)")
# 不给表结构那一项只算不靠表结构就该认得出的(列的类型、主键不为空这些得看表结构)
plain = [p for p in same if not p.typed and not p.needs_schema]
bare = [p.id for p in plain if explain_sql_diff(p.a, p.b, "sqlite").equivalent]
ratio, bare_ratio = len(hits) / len(same), len(bare) / len(plain)
print(f"\n已知等价的改写 {len(same)} 对里认出 {len(hits)} 对({ratio:.0%});不靠表结构的 {len(plain)} 对,"
f"不给表结构时认出 {len(bare)} 对({bare_ratio:.0%})")
print("没认出来的:", sorted(set(p.id for p in same) - set(hits)))
hard = [p for p in sql_pairs.PAIRS if p.id.startswith("hard:")]
hard_hits = [p.id for p in hard if explain_sql_diff(p.a, p.b, "sqlite", schema=SCHEMA).equivalent]
@@ -92,6 +115,19 @@ def test_the_corpus_is_large_and_varied_enough():
assert construct in text, construct
def test_the_datasets_expose_the_round_three_counterexamples(databases):
"""第 3 轮复验的反例在这几组数据上确实结果不同:外连接改内连接(NOT IN 空集)、列名表和 EXISTS 里的列别名、USING
之后 ORDER BY 的别名、同名的两列、SQLite 的 % 和 MOD()、遮住外层的别名、找不到列的双引号名字。"""
pairs = {p.id: p for p in sql_pairs.PAIRS}
for pid in ("diff:r3-left-not-in-subquery", "diff:r3-left-not-in-empty-list", "diff:r3-cte-column-list-where-alias",
"diff:r3-cte-column-list-union-where-alias", "diff:r3-exists-alias-where",
"diff:r3-using-order-by-alias", "diff:r3-using-order-by-alias-left", "diff:r3-duplicate-output-name",
"diff:r3-mod-vs-percent", "diff:r3-shadowed-alias", "diff:r3-double-quoted-not-in-scope",
"diff:r3-double-quoted-not-in-outer-scope", "diff:r3-left-coalesce-inside-sum"):
pair = pairs[pid]
assert any(sql_pairs.run(c, pair.a) != sql_pairs.run(c, pair.b) for c in databases), pid
def test_the_tricky_dataset_exposes_the_round_two_counterexamples(databases):
"""第 2 轮复验的反例在专挑写法差异的那组数据上确实结果不同(不然执行比对守不住它们)。"""
tricky = databases[-1]
@@ -101,3 +137,229 @@ def test_the_tricky_dataset_exposes_the_round_two_counterexamples(databases):
"diff:r2-cte-column-list-swap-2"):
pair = pairs[pid]
assert sql_pairs.run(tricky, pair.a) != sql_pairs.run(tricky, pair.b), pid
# --------------------------------------------------------------------------
# 第 4 轮复验的反例:只查结论的几组
# --------------------------------------------------------------------------
def _only_fallback(diff) -> bool:
return all(d.aspect == "other" for d in diff.items)
@pytest.mark.parametrize("name,dialect,a,b", sql_pairs.CONSTRAINT_TRAPS_R4,
ids=[f"{n}-{d}" for n, d, _, _ in sql_pairs.CONSTRAINT_TRAPS_R4])
def test_unique_keys_are_not_trusted_to_drop_joins_or_distinct(name, dialect, a, b):
"""第 4 轮 blocker:以前给了表结构就按主键 / 唯一键去掉没用到的 LEFT JOIN、去掉 DISTINCT,可 SQLite 的 TEXT 主键对
INTEGER 列按数值比('1'、'01' 都等于 1)、比较写了 COLLATE NOCASE、部分唯一索引(只管没删的行,探查照样记成
unique)、MySQL 拿字符串键和数值比,都能让一行关联上好几行。现在不靠约束改写:给不给表结构都不判等价。SQLite 的
几对在现造的小库上执行,确认结果真的不同。"""
if dialect == "sqlite":
conn = sql_pairs.build_coupon_database()
try:
assert sql_pairs.run(conn, a) != sql_pairs.run(conn, b), f"{name} 在小库上结果一样,反例没造好"
finally:
conn.close()
for schema in (None, sql_pairs.coupon_schema(dialect)):
for x, y in ((a, b), (b, a)):
diff = explain_sql_diff(x, y, dialect, schema=schema)
_check_shape(diff, x, y)
assert not diff.equivalent, f"{name} 不该判等价(schema={bool(schema)})"
@pytest.mark.parametrize("name,dialect,a,b", sql_pairs.REGRESSIONS_R4,
ids=[f"{n}-{d or 'unknown'}" for n, d, _, _ in sql_pairs.REGRESSIONS_R4])
def test_sql_the_parser_reads_wrongly_is_never_equivalent_and_gets_only_the_fallback(name, dialect, a, b):
"""第 4 轮 blocker(PostgreSQL / MySQL):sqlglot 读错了的写法——MySQL 字符串里的 \\Z、\\0(方言是 mysql、mariadb 或者
不知道时),比较运算和 IS / 另一个比较运算没加括号地混用,按位运算和别的运算混用,XOR,1_000、0o17 这类数字——两条
读成了同一棵树。原文里有这些写法就不在语法树上判等价、也不给具体说法,只给兜底,附原文的逐行对照。"""
for schema in (None, SCHEMA):
for x, y in ((a, b), (b, a)):
diff = explain_sql_diff(x, y, dialect or None, schema=schema)
_check_shape(diff, x, y)
assert not diff.equivalent, f"{name} 不该判等价(schema={bool(schema)})"
assert _only_fallback(diff), (name, diff.items)
assert diff.text_diff == (f"- {x}", f"+ {y}"), (name, diff.text_diff)
def _round_four_same() -> list[tuple[str, str, str, bool]]:
pairs = [sql_pairs.PAIRS_BY_ID[pid] for pid in sql_pairs.ROUND_FOUR_SAME]
return [(p.id, p.a, p.b, p.needs_schema) for p in pairs] + \
[(f"{n}-{d}", a, b, keyed) for n, d, a, b, keyed in sql_pairs.DIALECT_SAME_R4]
@pytest.mark.parametrize("pid,a,b,needs_schema", _round_four_same(), ids=[p[0] for p in _round_four_same()])
@pytest.mark.parametrize("dialect", ("sqlite", "postgres", "mysql"))
def test_round_four_equivalent_rewrites_get_at_most_the_fallback_in_every_dialect(pid, a, b, needs_schema, dialect):
"""第 4 轮 major:意思一样,以前却说出了具体的不同(ON 里多余的 IS NOT NULL、IN / COUNT(DISTINCT) / MAX 底下的
UNION 对 UNION ALL、被 WHERE 定成常量的排序键 / 分组键 / 选出来的列、LEFT JOIN 之后引用可空一侧的内连接、恒为真的
HAVING、外层不在乎的子查询差异、WHERE 里的列别名、TOTAL / FILTER 只取可空一侧,给了表结构时主键排除了的不同)。
SQLite 上执行过的这几对再按 PostgreSQL / MySQL 的读法各判一遍:认得出就判等价,认不出只许给兜底说明。要靠约束才
看得出的,没给表结构时说得出具体的不同不算错。"""
for schema in (None, SCHEMA):
if needs_schema and schema is None:
continue
for x, y in ((a, b), (b, a)):
diff = explain_sql_diff(x, y, dialect, schema=schema)
_check_shape(diff, x, y)
assert diff.equivalent or _only_fallback(diff), (pid, dialect, bool(schema), diff.items)
@pytest.mark.parametrize("pid", ["diff:r4-limit-differs-order-key-pinned", "diff:r4-distinct-date-vs-time",
"diff:r4-distinct-lower-vs-plain", "diff:r4-distinct-date-vs-time-in-derived"])
@pytest.mark.parametrize("dialect", ("sqlite", "postgres", "mysql"))
def test_real_differences_are_not_explained_with_differences_that_are_not_there(pid, dialect):
"""第 4 轮 major:两条确实不同,以前却多说了不存在的差异——user_id 被 WHERE 定死了还说「取行前按 user_id、id 排」;
两边都 DISTINCT,却说「A 没有去重」「B 没有」(真正的不同是第 2 列取不取日期)。这几处现在只给兜底。"""
pair = sql_pairs.PAIRS_BY_ID[pid]
for schema in (None, SCHEMA):
for x, y in ((pair.a, pair.b), (pair.b, pair.a)):
diff = explain_sql_diff(x, y, dialect, schema=schema)
_check_shape(diff, x, y)
assert not diff.equivalent and _only_fallback(diff), (pid, dialect, diff.items)
@pytest.mark.parametrize("pid", ["diff:r4-eq-then-is-null", "diff:r4-gt-then-is-not-null-in-cte", "diff:r4-lt-then-in",
"diff:r4-eq-then-in", "diff:r4-compared-booleans-then-is-null",
"diff:r4-digit-separator", "diff:r4-digit-separator-vs-one"])
def test_sqlite_sql_the_parser_reads_wrongly_gets_only_the_fallback(databases, pid):
"""第 4 轮 blocker(SQLite):IS、IN 和 = 同级从左往右、< 比它们紧,sqlglot 让 IS / IN 先结合;1_000 是一千,sqlglot
切成 1 和别名。这几对在夹具数据上结果都不同;原文里有这些写法就只给兜底和原文的逐行对照。"""
pair = sql_pairs.PAIRS_BY_ID[pid]
assert any(sql_pairs.run(c, pair.a) != sql_pairs.run(c, pair.b) for c in databases), pid
for schema in (None, SCHEMA):
diff = explain_sql_diff(pair.a, pair.b, "sqlite", schema=schema)
_check_shape(diff, pair.a, pair.b)
assert not diff.equivalent and _only_fallback(diff), (pid, diff.items)
assert diff.text_diff == (f"- {pair.a}", f"+ {pair.b}")
# --------------------------------------------------------------------------
# 第 5 轮复验的反例(收尾第 1 轮之后)
# --------------------------------------------------------------------------
def _raw_line_diff(diff, x: str, y: str) -> bool:
"""text_diff 是原文的逐行对照:「- 」开头的是 x 的原文行,「+ 」开头的是 y 的原文行。"""
lines_x = {line.rstrip() for line in x.strip().splitlines()}
lines_y = {line.rstrip() for line in y.strip().splitlines()}
return bool(diff.text_diff) and all(
(line[2:] in lines_x) if line.startswith("- ") else (line.startswith("+ ") and line[2:] in lines_y)
for line in diff.text_diff)
@pytest.mark.parametrize("name,dialect,a,b", sql_pairs.REGRESSIONS_R5,
ids=[f"{n}-{d or 'unknown'}" for n, d, _, _ in sql_pairs.REGRESSIONS_R5])
def test_round_five_parser_misreads_are_never_equivalent_and_get_only_the_fallback(name, dialect, a, b):
"""第 5 轮 blocker(PostgreSQL):~、~*、~~、~~*、@@ 这些写成符号的匹配运算符和 || 同级、从左往右结合,sqlglot 让 ||
先结合,status ~ 'p' || 'x' 和 status ~ ('p' || 'x') 读成了同一棵树(真实的 PostgreSQL 上一个是 'truex'、一个是
false);自己差分压测又找到前缀的 @(取绝对值,sqlglot 读成参数)、|/(双精度的平方根,sqlglot 写成 SQRT)、
COLLATE 后面跟 ::。不知道是哪种库时,块注释里再写 /*、紧跟着字的 --、双引号括起来的名字(MySQL 默认的 sql_mode 里
是字符串)各库读法不同。原文里有这些写法就不在语法树上判等价、也不给具体说法,只给兜底,附原文的逐行对照。"""
for schema in (None, SCHEMA):
for x, y in ((a, b), (b, a)):
diff = explain_sql_diff(x, y, dialect or None, schema=schema)
_check_shape(diff, x, y)
assert not diff.equivalent, f"{name} 不该判等价(schema={bool(schema)})"
assert _only_fallback(diff), (name, diff.items)
assert _raw_line_diff(diff, x, y), (name, diff.text_diff)
@pytest.mark.parametrize("pid", [p for p in sql_pairs.ROUND_FIVE_DIFF
if p != "diff:r5-dropped-left-join-vs-inner-join"])
@pytest.mark.parametrize("dialect", ("sqlite", "postgres", "mysql"))
def test_round_five_differences_are_not_explained_with_the_wrong_difference(pid, dialect):
"""第 5 轮 major:两条确实不同,以前却说错了不同在哪——条件、时间窗口写在聚合的参数里(SUM(status = 'paid')、
SUM(amount * (1 - is_deleted))、SUM(paid_at >= …))被说成「这一边没有这个条件」「没有按时间筛选」;反连接(LEFT
JOIN … WHERE 可空一侧 IS NULL)被说成有效行过滤。这几处现在只给兜底。"""
pair = sql_pairs.PAIRS_BY_ID[pid]
for schema in (None, SCHEMA):
for x, y in ((pair.a, pair.b), (pair.b, pair.a)):
diff = explain_sql_diff(x, y, dialect, schema=schema)
_check_shape(diff, x, y)
assert not diff.equivalent and _only_fallback(diff), (pid, dialect, bool(schema), diff.items)
@pytest.mark.parametrize("dialect", ("sqlite", "postgres", "mysql"))
def test_a_left_join_the_normalization_dropped_is_not_called_missing(dialect):
"""第 5 轮 minor:COUNT(DISTINCT …) 不怕重复,规范化去掉了没用到的 LEFT JOIN refunds;另一边内连接 refunds 时,以前
说「B 关联了 refunds 表,A 没有」,可 A 的原文明明写了 LEFT JOIN refunds。不许说「没有」(PostgreSQL、MySQL 没给表
结构时不去掉这次 LEFT JOIN,「A 用 LEFT JOIN 关联 refunds 表,B 用 INNER JOIN」和原文对得上,可以说)。"""
pair = sql_pairs.PAIRS_BY_ID["diff:r5-dropped-left-join-vs-inner-join"]
for schema in (None, SCHEMA):
for x, y in ((pair.a, pair.b), (pair.b, pair.a)):
diff = explain_sql_diff(x, y, dialect, schema=schema)
_check_shape(diff, x, y)
assert not diff.equivalent
assert not any(d.aspect in ("join", "tables") and "没有" in d.text for d in diff.items), diff.items
@pytest.mark.parametrize("pid", sorted(sql_pairs.ROUND_FIVE_TIME))
@pytest.mark.parametrize("dialect", ("sqlite", "postgres", "mysql"))
def test_round_five_time_windows_are_never_called_a_filter(pid, dialect):
"""第 5 轮 major:时间列上的日期范围是统计窗口,不是有效行过滤。以前在结果看行先后的一段里(按日期表达式分组、取前
N 行)BETWEEN 不拆,被说成两条「只保留 … 的行,另一边没有这个条件」,fallback 为假、正好够写进数据目录;LIKE
'2026-09%' 取月也被说成有效行过滤。现在不许说成 filter;说成 time 时两边的时间列得对(没有按时间筛选的一边是空的)。
SQLite 上一定说成 time。"""
pair = sql_pairs.PAIRS_BY_ID[pid]
cols_a, cols_b = sql_pairs.ROUND_FIVE_TIME[pid]
for schema in (None, SCHEMA):
for (x, y), (ca, cb) in (((pair.a, pair.b), (cols_a, cols_b)), ((pair.b, pair.a), (cols_b, cols_a))):
diff = explain_sql_diff(x, y, dialect, schema=schema)
_check_shape(diff, x, y)
assert not diff.equivalent
assert not any(d.aspect == "filter" for d in diff.items), (pid, dialect, diff.items)
times = [d for d in diff.items if d.aspect == "time"]
for item in times:
assert [c["column"] for c in item.detail["a"]] == ca and \
[c["column"] for c in item.detail["b"]] == cb, (pid, dialect, item)
if dialect == "sqlite":
assert len(times) == 1 and not diff.fallback, (pid, diff.items)
def _round_five_same() -> list[tuple[str, str, str, bool]]:
return [(p.id, p.a, p.b, p.needs_schema) for p in (sql_pairs.PAIRS_BY_ID[i] for i in sql_pairs.ROUND_FIVE_SAME)]
@pytest.mark.parametrize("pid,a,b,needs_schema", _round_five_same(), ids=[p[0] for p in _round_five_same()])
@pytest.mark.parametrize("dialect", ("sqlite", "postgres", "mysql"))
def test_round_five_equivalent_rewrites_get_at_most_the_fallback_in_every_dialect(pid, a, b, needs_schema, dialect):
"""第 5 轮 major:意思一样,以前却说出了具体的不同——反连接里被 u.id IS NULL 蕴含的 u.name IS NULL、两种反连接的
写法(查主键为空、查关联键为空)、按主键分组时 MAX 对 MIN、AVG(DISTINCT) 对 AVG(同一组里那张表的列只有一个值,
LEFT JOIN 把行复制了也一样)。认得出就判等价,认不出只许给兜底;要靠主键的,没给表结构时说得出具体的不同不算错。"""
for schema in (None, SCHEMA):
if needs_schema and schema is None:
continue
for x, y in ((a, b), (b, a)):
diff = explain_sql_diff(x, y, dialect, schema=schema)
_check_shape(diff, x, y)
assert diff.equivalent or _only_fallback(diff), (pid, dialect, bool(schema), diff.items)
@pytest.mark.parametrize("dialect", ("sqlite", "postgres", "mysql"))
def test_anti_joins_are_never_called_a_filter(dialect):
"""第 5 轮 major:LEFT JOIN … WHERE r.id IS NULL 和 … WHERE r.order_id IS NULL 是反连接最常见的两种写法。关联键为空
就是没关联上,蕴含 r.id 为空;r.id 是主键时两者一样,没有主键时查 r.id 的一边留下的行更多(关联上了、r.id 却为空的)。
以前说「A 只保留 refunds.id IS NULL 的行,B 没有这个条件」:把留得多的一边说成了严的一边,还当成有效行过滤。给不给
表结构都不许判等价(要靠主键),也只许给兜底。"""
pair = sql_pairs.PAIRS_BY_ID["hard:r5-anti-join-key-vs-join-column"]
for schema in (None, SCHEMA):
for x, y in ((pair.a, pair.b), (pair.b, pair.a)):
diff = explain_sql_diff(x, y, dialect, schema=schema)
_check_shape(diff, x, y)
assert not diff.equivalent and _only_fallback(diff), (dialect, bool(schema), diff.items)
@pytest.mark.parametrize("name,dialect,a,b,schema,bare", sql_pairs.EXPLAIN_TRAPS_R5,
ids=[f"{n}-{d or 'unknown'}" for n, d, *_ in sql_pairs.EXPLAIN_TRAPS_R5])
def test_round_five_traps_that_need_their_own_schema_get_only_the_fallback(name, dialect, a, b, schema, bare):
"""第 5 轮 major(要专门的表结构,不执行):数仓事实表按业务日期键(date_key BETWEEN 20260901 AND 20260930、
stat_month = 202609)筛的一边,以前被说成「没有按时间筛选」、日期键的范围被说成有效行过滤;反连接查主键、查 NOT NULL
的列、查关联键,以前被说成两条有效行过滤;经关联条件等于分组键的主键定下来的列,以前说「A 取最大值,B 取最小值」。
这些都只给兜底(经主键推出来的那几对,没给表结构时这么说按已知信息成立,不查)。"""
for given in ((schema, None) if bare else (schema,)):
for x, y in ((a, b), (b, a)):
diff = explain_sql_diff(x, y, dialect or None, schema=given)
_check_shape(diff, x, y)
assert not diff.equivalent and _only_fallback(diff), (name, dialect, bool(given), diff.items)
+674 -113
View File
@@ -3,9 +3,10 @@
- normalize_sql:字面量换成占位符后的规范文本 + 字面量。只有字面量不同的两条 SQL 规范文本相同(定时运行每期的日期
不同,不能算模板变了);关键字大小写、空白、注释也不算不同;结构变了的一定不同;解析不了的退回逐词规范化
- facets:把一条查询拆成表、连接、WHERE 合取式、时间条件、分组、聚合、HAVING、取哪几行(给调用方取结构用)
- explain_sql_diff:两边规范化后的形式一字不差才算等价(规范化的每一步都只做保持语义的变换);不等价时只把有把握
的不同写成具体的中文句子(时间列、筛选条件、聚合与去重、关联、分组、HAVING、取行、UNION),说不准的给兜底说明,
并给出规范化后不同的行。等价的结论另有执行比对守门:见 test_sql_equivalence.py
- explain_sql_diff:两边规范化后的形式一字不差才算等价(规范化的每一步都只做保持语义的变换);不等价时只在两边都是
简单形状(一段 SELECT、实表、普通的连接)时把有把握的四类不同写成具体的中文句子(按哪一列筛时间、一边多出来的
筛选条件、顶层的聚合函数和去重、关联的表和关联类型),别的都给兜底说明,并给出规范化后不同的行;原文里有 sqlglot
会读错的写法时只给兜底和原文的逐行对照。等价的结论另有执行比对守门:见 test_sql_equivalence.py
- diff_facets:照旧收两份 facets,交给 explain_sql_diff 比原文,返回它的条目(含兜底条目)
夹具全是虚构的表:订单 orders、退款 refunds、门店 stores、用户 users、付款 payments,以及一张中文表「订单」。
@@ -567,89 +568,44 @@ def test_time_column_differs_with_catalog_names():
@pytest.mark.parametrize("a,b,expected", [
# 时间
# 时间:按哪一列筛不同、一边按时间筛了另一边没有
(BASE, "SELECT SUM(amount) FROM orders", [("time", "A 按 paid_at 筛时间,B 没有按时间筛选")]),
("SELECT SUM(amount) FROM orders", BASE, [("time", "A 没有按时间筛选,B 按 paid_at 筛时间")]),
(BASE, "SELECT SUM(amount) FROM orders WHERE paid_at BETWEEN '2026-09-01' AND '2026-09-30'",
[("time_range", "A 的时间范围是 paid_at >= '2026-09-01' AND paid_at < '2026-10-01',"
"B 是 paid_at >= '2026-09-01' AND paid_at <= '2026-09-30'")]),
("WITH t AS (SELECT user_id, amount, DATE(paid_at) AS d FROM orders) SELECT SUM(amount) FROM t WHERE d >= '2026-09-01'",
"WITH t AS (SELECT user_id, amount, DATE(created_at) AS d FROM orders) SELECT SUM(amount) FROM t "
"WHERE d >= '2026-09-01'",
[("time", "A 按 paid_at 筛时间,B 按 created_at")]),
# 筛选
# 一边多出来的筛选条件
("SELECT SUM(amount) FROM orders WHERE is_deleted <> 1", "SELECT SUM(amount) FROM orders",
[("filter", "A 排除了 is_deleted = 1 的行,B 没有")]),
[("filter", "A 排除了 is_deleted = 1 的行和 is_deleted 为空的行,B 没有")]),
("SELECT SUM(amount) FROM orders", "SELECT SUM(amount) FROM orders WHERE refund_id IS NOT NULL",
[("filter", "B 排除了 refund_id 为空的行,A 没有")]),
("SELECT SUM(amount) FROM orders", "SELECT SUM(amount) FROM orders WHERE status = 'paid'",
[("filter", "B 只保留 status = 'paid' 的行,A 没有这个条件")]),
("SELECT SUM(amount) FROM orders WHERE status = 'paid'",
"SELECT SUM(amount) FROM orders WHERE status IN ('paid', 'shipped')",
[("filter", "A 按 status = 'paid' 筛选,B 按 status IN ('paid', 'shipped') 筛选")]),
("WITH paid AS (SELECT * FROM orders WHERE status = 'paid') SELECT SUM(amount) FROM paid",
"WITH paid AS (SELECT * FROM orders WHERE status = 'paid') SELECT SUM(amount) FROM orders",
[("filter", "A 只保留 status = 'paid' 的行,B 没有这个条件")]),
# 聚合、去重
# 顶层的聚合函数、去重
("SELECT COUNT(DISTINCT order_id) FROM orders", "SELECT COUNT(order_id) FROM orders",
[("aggregate", "A 对 order_id 去重计数,B 没去重")]),
("SELECT COUNT(order_id) FROM orders", "SELECT COUNT(DISTINCT order_id) FROM orders",
[("aggregate", "A 对 order_id 计数时没去重,B 去重计数")]),
("SELECT SUM(amount) FROM orders", "SELECT AVG(amount) FROM orders", [("aggregate", "A 对 amount 求和,B 求平均")]),
("SELECT SUM(paid_amount) FROM orders", "SELECT SUM(amount) FROM orders",
[("aggregate", "A 对 paid_amount 求和,B 对 amount 求和")]),
("SELECT COUNT(*) FROM orders", "SELECT COUNT(DISTINCT order_id) FROM orders",
[("aggregate", "A 数的是行数,B 对 order_id 去重计数")]),
("SELECT SUM(o.amount) FROM orders o JOIN refunds r ON r.order_id = o.id",
"SELECT SUM(r.amount) FROM orders o JOIN refunds r ON r.order_id = o.id",
[("aggregate", "A 对 orders.amount 求和,B 对 refunds.amount 求和")]),
("SELECT COUNT(*) FROM orders", "SELECT SUM(amount) FROM orders", [("aggregate", "A 数的是行数,B 对 amount 求和")]),
("SELECT DISTINCT region FROM orders", "SELECT region FROM orders", [("group_by", "A 对 region 去重,B 没有去重")]),
("SELECT user_id, SUM(amount) FROM orders GROUP BY user_id", "SELECT SUM(amount), user_id FROM orders GROUP BY user_id",
[("expression", "两边选出来的列一样,先后不同:A 是 user_id、SUM(amount),B 是 SUM(amount)、user_id")]),
# 分组、HAVING、取行
("SELECT region, SUM(amount) FROM orders GROUP BY region",
"SELECT region, city, SUM(amount) FROM orders GROUP BY region, city",
[("group_by", "A 按 region 分组,B 按 city、region 分组")]),
("SELECT SUM(amount) FROM orders", "SELECT region, SUM(amount) FROM orders GROUP BY region",
[("group_by", "A 没有分组,B 按 region 分组")]),
("SELECT region, SUM(amount) FROM orders GROUP BY region HAVING SUM(amount) > 100",
"SELECT region, SUM(amount) FROM orders GROUP BY region",
[("having", "A 分组后只保留 SUM(amount) > 100 的组,B 没有这个条件")]),
("SELECT amount FROM orders LIMIT 10", "SELECT amount FROM orders", [("limit", "A 只取前 10 行,B 不限行数")]),
("SELECT amount FROM orders LIMIT 10", "SELECT amount FROM orders LIMIT 100",
[("limit", "A 只取前 10 行,B 只取前 100 行")]),
("SELECT amount FROM orders LIMIT 10 OFFSET 0", "SELECT amount FROM orders LIMIT 10 OFFSET 10",
[("limit", "A 不跳过行,B 跳过前 10 行")]),
("SELECT amount FROM orders ORDER BY 1 DESC LIMIT 10", "SELECT amount FROM orders ORDER BY 1 ASC LIMIT 10",
[("limit", "A 取行前按 amount 降序排,B 按 amount 升序排")]),
("SELECT id, amount FROM orders ORDER BY amount DESC", "SELECT id, amount FROM orders",
[("order", "A 按 amount 降序排,B 没有排序")]),
# 连接、表
("SELECT DISTINCT region FROM orders ORDER BY region LIMIT 5", "SELECT region FROM orders ORDER BY region LIMIT 5",
[("group_by", "A 用 SELECT DISTINCT 去掉了重复的行,B 没有")]),
# 关联的表、关联类型
("SELECT SUM(o.amount) FROM orders o LEFT 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",
[("join", "A 用 LEFT JOIN 关联 refunds 表,B 用 INNER JOIN")]),
("SELECT SUM(o.amount) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id",
"SELECT SUM(o.amount) FROM orders o LEFT JOIN refunds r ON r.user_id = o.user_id",
[("join", "A 关联 refunds 表的条件是 orders.id = refunds.order_id,B 是 orders.user_id = refunds.user_id")]),
("SELECT SUM(o.amount) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id", "SELECT SUM(amount) FROM orders",
[("join", "A 用 LEFT JOIN 关联 refunds 表,B 没有")]),
("SELECT SUM(o.amount) FROM orders o WHERE o.status = 'paid'",
"SELECT SUM(o.amount) FROM stores s JOIN orders o ON o.store_id = s.id WHERE o.status = 'paid'",
[("join", "B 关联了 stores 表(关联的条件是 orders.store_id = stores.id),A 没有")]),
("SELECT COUNT(*) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id",
"SELECT COUNT(*) FROM refunds r LEFT JOIN orders o ON o.id = r.order_id",
[("join", "A 以 orders 为主表外连接 refunds,B 以 refunds 为主表外连接 orders")]),
("SELECT SUM(amount) FROM orders WHERE is_deleted = 0 AND paid_at >= '2026-09-01'",
"SELECT SUM(amount) FROM orders_archive WHERE is_deleted = 1 AND paid_at >= '2026-09-01'",
[("tables", "A 查的是 orders 表,B 查的是 orders_archive 表"),
("filter", "A 按 is_deleted = 0 筛选,B 按 is_deleted = 1 筛选")]),
# UNION
("SELECT user_id FROM orders UNION SELECT user_id FROM refunds",
"SELECT user_id FROM orders UNION ALL SELECT user_id FROM refunds",
[("set_op", "A 用 UNION 合并(去掉重复的行),B 用 UNION ALL(重复的行都留着)")]),
("SELECT user_id FROM orders WHERE status = 'paid' UNION SELECT referrer_id FROM orders WHERE status = 'paid'",
"SELECT user_id FROM orders WHERE status = 'paid' UNION SELECT referrer_id FROM orders",
[("filter", "A 在合并的一支里只保留 status = 'paid' 的行,B 在合并的一支里没有这个条件")]),
], ids=lambda v: None)
def test_specific_differences_are_said_in_one_sentence_each(a, b, expected):
diff = explain_sql_diff(a, b, "sqlite")
@@ -657,44 +613,83 @@ def test_specific_differences_are_said_in_one_sentence_each(a, b, expected):
assert not diff.fallback and [(d.aspect, d.text) for d in diff.items] == expected
def test_a_difference_inside_a_cte_used_only_in_a_condition_subquery_is_reported():
"""第 5 轮回退的例子:只在 IN 子查询里用到的 CTE 里的条件不同,以前整个漏报(返回空列表)。现在 CTE 并进了 IN
子查询,往里比,说得出是哪个条件,位置带上 IN 左边的列(两个 IN 子查询时才分得清)。EXISTS、标量子查询一样。"""
@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')"),
# 聚合的参数不同、选出来的列换了先后
("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(r.amount) FROM orders o JOIN refunds r ON r.order_id = o.id"),
("SELECT user_id, SUM(amount) FROM orders GROUP BY user_id", "SELECT SUM(amount), user_id FROM orders GROUP BY user_id"),
# COUNT(x) 对 COUNT(*):只差在 x 为空的行,不算聚合函数不同
("SELECT COUNT(order_id) FROM orders", "SELECT COUNT(*) FROM orders"),
# 分组、HAVING、取行、排序
("SELECT region, SUM(amount) FROM orders GROUP BY region",
"SELECT region, city, SUM(amount) FROM orders GROUP BY region, city"),
("SELECT SUM(amount) FROM orders", "SELECT region, SUM(amount) FROM orders GROUP BY region"),
("SELECT region, SUM(amount) FROM orders GROUP BY region HAVING SUM(amount) > 100",
"SELECT region, SUM(amount) FROM orders GROUP BY region"),
("SELECT amount FROM orders LIMIT 10", "SELECT amount FROM orders"),
("SELECT amount FROM orders LIMIT 10", "SELECT amount FROM orders LIMIT 100"),
("SELECT amount FROM orders LIMIT 10 OFFSET 0", "SELECT amount FROM orders LIMIT 10 OFFSET 10"),
("SELECT amount FROM orders ORDER BY 1 DESC LIMIT 10", "SELECT amount FROM orders ORDER BY 1 ASC LIMIT 10"),
("SELECT id, amount FROM orders ORDER BY amount DESC", "SELECT id, amount FROM orders"),
# 关联条件不同、外连接的主表对调
("SELECT SUM(o.amount) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id",
"SELECT SUM(o.amount) FROM orders o LEFT JOIN refunds r ON r.user_id = o.user_id"),
("SELECT COUNT(*) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id",
"SELECT COUNT(*) FROM refunds r LEFT JOIN orders o ON o.id = r.order_id"),
# UNION、合并的一支里的条件、CTE 和子查询里的不同:不是简单形状
("SELECT user_id FROM orders UNION SELECT user_id FROM refunds",
"SELECT user_id FROM orders UNION ALL SELECT user_id FROM refunds"),
("SELECT user_id FROM orders WHERE status = 'paid' UNION SELECT referrer_id FROM orders WHERE status = 'paid'",
"SELECT user_id FROM orders WHERE status = 'paid' UNION SELECT referrer_id FROM orders"),
("WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders WHERE is_deleted = 0 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)
def test_differences_outside_the_four_kinds_get_only_the_fallback(a, b):
"""具体说法只说四类(按哪一列筛时间、一边多出来的筛选条件、顶层的聚合函数和去重、关联的表和关联类型),而且只在
两边都是简单形状时说。别的不同(同一列上的范围、换了筛选的值、聚合的参数、分组、HAVING、取行、排序、关联条件、
UNION、CTE 和子查询里的不同)以前说得出具体的,复验在这些地方一再抓到不存在的差异(被 WHERE 定死的排序键、恒为真的
HAVING、外层不在乎的子查询差异……),现在一律只给兜底说明,附规范化后不同的行。"""
for dialect in DIALECTS:
diff = explain_sql_diff(a, b, dialect)
check_shape(diff)
assert not diff.equivalent and diff.items == [FacetDiff("other", None, None, FALLBACK)], (dialect, diff.items)
def test_a_difference_inside_a_cte_used_only_in_a_condition_subquery_is_never_missed():
"""第 5 轮回退的例子:只在 IN 子查询里用到的 CTE 里的条件不同,以前整个漏报(返回空列表)。现在条件里有子查询的不是
简单形状,不说具体的,但一定不判等价、给兜底说明和规范化后不同的行。EXISTS、标量子查询一样。"""
head = "WITH p AS (SELECT * FROM orders WHERE status = '{}') SELECT COUNT(*) FROM users u WHERE "
for tail, place in (("u.id IN (SELECT user_id FROM p)", "在 id IN 的子查询里"),
("EXISTS (SELECT 1 FROM p WHERE p.user_id = u.id)", "在 EXISTS 子查询里"),
("(SELECT COUNT(*) FROM p WHERE p.user_id = u.id) > 0", "在条件的子查询里")):
for tail in ("u.id IN (SELECT user_id FROM p)", "EXISTS (SELECT 1 FROM p WHERE p.user_id = u.id)",
"(SELECT COUNT(*) FROM p WHERE p.user_id = u.id) > 0"):
a, b = head.format("paid") + tail, head.format("open") + tail
for schema in (None, sql_pairs.schema_cache()):
diff = explain_sql_diff(a, b, "sqlite", schema=schema, labels=LABELS)
check_shape(diff)
assert [(d.aspect, d.text) for d in diff.items] == [
("filter", f"原流程{place}按 status = 'paid' 筛选,盲算{place}按 status = 'open' 筛选")]
assert not diff.equivalent and diff.items == [FacetDiff("other", None, None, FALLBACK)]
assert any("open" in line for line in diff.text_diff)
assert diff_facets(facets(a), facets(b), labels=LABELS) == diff.items
def test_a_difference_inside_an_aggregating_cte_names_the_cte_on_each_side():
a = ("WITH user_totals AS (SELECT user_id, SUM(amount) AS total FROM orders WHERE is_deleted = 0 GROUP BY user_id) "
"SELECT COUNT(*) FROM user_totals WHERE total > 100")
b = ("WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
"SELECT COUNT(*) FROM t WHERE total > 100")
diff = explain_sql_diff(a, b, labels=LABELS)
def test_an_outer_join_turned_inner_by_where_is_not_called_inner_join():
"""原文写的是 LEFT / RIGHT JOIN、WHERE 里的条件把没关联上的行筛掉了(按内连接比):说「B 用 INNER JOIN」和原文
对不上,从 RIGHT JOIN 改写来的说「用 LEFT JOIN」也对不上,关联类型这一处不说,交给兜底;说得准的筛选条件照说。"""
a = "SELECT COUNT(o.id) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id WHERE o.status = 'paid'"
b = "SELECT COUNT(o.id) FROM refunds r LEFT JOIN orders o ON o.id = r.order_id WHERE o.status = 'paid'"
diff = explain_sql_diff(a, b, "postgres")
check_shape(diff)
assert [d.text for d in diff.items] == ["原流程在 user_totals 里只保留 is_deleted = 0 的行,盲算在 t 里没有这个条件"]
def test_an_outer_join_turned_inner_by_where_says_what_was_written():
"""原文写的是 LEFT / RIGHT JOIN、WHERE 里的条件把没关联上的行筛掉了:按内连接比,说法照实写「写的是 LEFT JOIN,
实际是 INNER JOIN」,不说成「用 INNER JOIN」。从 RIGHT JOIN 改写来的外连接说以哪一侧为主表,不说 LEFT JOIN。"""
a = "SELECT SUM(o.amount) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id WHERE o.status = 'paid'"
b = "SELECT SUM(o.amount) FROM refunds r LEFT JOIN orders o ON o.id = r.order_id WHERE o.status = 'paid'"
assert texts(a, b, "postgres") == [
"A 用 LEFT JOIN 关联 refunds 表,B 写的是 LEFT JOIN,但 WHERE 里的条件把关联不上 orders 的行筛掉了,实际是 INNER JOIN"]
assert diff.fallback and not any(d.aspect == "join" for d in diff.items), diff.items
a = "SELECT COUNT(*) FROM orders o RIGHT JOIN refunds r ON r.order_id = o.id"
b = a + " WHERE o.status = 'paid'"
assert texts(a, b, "postgres") == [
"A 以 refunds 为主表外连接 orders 表,B 写的是 RIGHT JOIN,但 WHERE 里的条件把关联不上 orders 的行筛掉了,"
"实际是 INNER JOIN", "B 只保留 orders.status = 'paid' 的行,A 没有这个条件"]
diff = explain_sql_diff(a, b, "postgres")
check_shape(diff)
assert [(d.aspect, d.text) for d in diff.items] == [
("filter", "B 只保留 orders.status = 'paid' 的行,A 没有这个条件"), ("other", FALLBACK_REST)]
# 本来就是内连接、和被 WHERE 变成内连接的 LEFT JOIN 是同一个意思
assert explain_sql_diff("SELECT COUNT(*) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id WHERE r.amount > 10",
"SELECT COUNT(*) FROM refunds r JOIN orders o ON o.id = r.order_id WHERE r.amount > 10"
@@ -707,7 +702,9 @@ def test_each_dialect_gives_the_same_three_findings(dialect):
a = (f"SELECT COUNT(DISTINCT o.order_id) AS {q}订单数{q} FROM orders o "
"WHERE o.paid_at >= '2026-09-01' AND o.is_deleted <> 1")
b = f"SELECT COUNT(o.order_id) AS {q}订单数{q} FROM orders o WHERE o.created_at >= '2026-09-01'"
assert texts(a, b, dialect) == ["A 按 paid_at 筛时间,B 按 created_at", "A 排除了 is_deleted = 1 的行,B 没有",
# <> 1 也把 is_deleted 为空的行筛掉了(第 3 轮复验:以前只说「排除了 is_deleted = 1 的行」)
assert texts(a, b, dialect) == ["A 按 paid_at 筛时间,B 按 created_at",
"A 排除了 is_deleted = 1 的行和 is_deleted 为空的行,B 没有",
"A 对 order_id 去重计数,B 没去重"]
@@ -727,9 +724,10 @@ def test_filters_on_real_columns_carry_the_columns_for_the_catalog():
def test_a_displayed_condition_means_what_the_sql_means():
"""给人看的条件按方言生成;方言生成的写法读回来不是同一个意思时(SQLite 把 CAST(x AS DATE) 生成 DATE(x))改用
通用写法,不拿 DATE(paid_at) 冒充原文。几个条件连起来时 OR 带上括号。"""
[item] = explain_sql_diff("SELECT COUNT(*) FROM orders WHERE CAST(paid_at AS DATE) >= '2026-09-01'",
"SELECT COUNT(*) FROM orders WHERE CAST(paid_at AS DATE) >= '2026-10-01'", "sqlite").items
assert item.aspect == "time_range" and "CAST(paid_at AS DATE)" in item.text and "DATE(paid_at)" not in item.text
[item] = explain_sql_diff("SELECT COUNT(*) FROM orders o",
"SELECT COUNT(*) FROM orders o JOIN payments p ON p.order_id = o.id "
"AND CAST(p.paid_at AS DATE) >= '2026-09-01'", "sqlite").items
assert item.aspect == "join" and "CAST(payments.paid_at AS DATE)" in item.text and "DATE(payments" not in item.text
a = "SELECT SUM(amount) FROM orders"
b = "SELECT SUM(o.amount) FROM orders o JOIN stores s ON s.id = o.store_id AND (s.city = 'SH' OR s.city = 'BJ')"
[item] = explain_sql_diff(a, b).items
@@ -754,15 +752,16 @@ 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():
"""一边多关联了 refunds(会把订单行按退款数复制),另一边只在 EXISTS 里用它:关联那一句说得准,EXISTS 那处说不准,
补一句「另有未能归纳的差异」。"""
a = "SELECT SUM(o.amount) FROM orders o WHERE EXISTS (SELECT 1 FROM refunds r WHERE r.order_id = o.id)"
b = "SELECT SUM(o.amount) FROM orders o JOIN refunds r ON r.order_id = o.id"
"""按哪一列筛时间说得准,同一列 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')"
diff = explain_sql_diff(a, b, labels=LABELS)
check_shape(diff)
assert diff.fallback and [d.text for d in diff.items] == [
"盲算关联了 refunds 表(关联的条件是 orders.id = refunds.order_id),原流程没有关联它(只在子查询里用到了 refunds)",
FALLBACK_REST]
assert diff.fallback and [d.text for d in diff.items] == ["原流程按 paid_at 筛时间,盲算按 created_at", FALLBACK_REST]
# 一边只在 EXISTS 里用到 refunds:条件里有子查询,不是简单形状,整条只给兜底
a = "SELECT SUM(o.amount) FROM orders o WHERE EXISTS (SELECT 1 FROM refunds r WHERE r.order_id = o.id)"
b = "SELECT SUM(o.amount) FROM orders o JOIN refunds r ON r.order_id = o.id"
assert explain_sql_diff(a, b, labels=LABELS).items == [FacetDiff("other", None, None, FALLBACK)]
def test_columns_inside_a_cte_are_not_compared_because_canonical_forms_prune_them():
@@ -978,25 +977,17 @@ def test_subqueries_swapped_between_in_and_not_in_are_not_explained_both_ways(a,
assert all(d.aspect == "other" for d in diff.items), diff.items
def test_a_difference_inside_a_not_in_subquery_says_not_in():
"""NOT IN 子查询里的条件不同,说的是「NOT IN 的子查询」(以前说成「IN 子查询」,读的人会以为是保留这些用户)。子查询
里不带表名的列,没给表结构时分不清是子查询里的表的还是外层的(users 也可能有 status),不说具体的;给了表结构、
或者写了表名,就说得出。"""
def test_a_difference_inside_a_not_in_subquery_gets_only_the_fallback():
"""NOT IN 子查询里的条件不同:以前说成「在 id NOT IN 的子查询里按 … 筛选」(再往前还错说成「IN 子查询」)。条件里有
子查询的不是简单形状,现在给不给表结构都只给兜底,不会把 NOT IN 说成 IN。"""
a = ("SELECT COUNT(*) FROM users WHERE id NOT IN (SELECT user_id FROM orders WHERE status = 'void' "
"AND user_id IS NOT NULL)")
b = a.replace("'void'", "'open'")
expected = [("filter", "原流程在 id NOT IN 的子查询里按 status = 'void' 筛选,盲算在 id NOT IN 的子查询里按 "
"status = 'open' 筛选")]
diff = explain_sql_diff(a, b, "sqlite", labels=LABELS, schema=sql_pairs.schema_cache())
check_shape(diff)
assert [(d.aspect, d.text) for d in diff.items] == expected
assert explain_sql_diff(a, b, "sqlite", labels=LABELS).items == [FacetDiff("other", None, None, FALLBACK)]
qa, qb = a.replace("FROM orders WHERE status", "FROM orders o WHERE o.status"), \
b.replace("FROM orders WHERE status", "FROM orders o WHERE o.status")
assert [(d.aspect, d.text) for d in explain_sql_diff(qa, qb, "sqlite", labels=LABELS).items] == expected
diff = explain_sql_diff(a.replace("NOT IN", "IN"), b.replace("NOT IN", "IN"), "sqlite", labels=LABELS,
schema=sql_pairs.schema_cache())
assert "id IN 的子查询" in diff.items[0].text and "NOT IN" not in diff.items[0].text
for schema in (None, sql_pairs.schema_cache()):
for x, y in ((a, b), (a.replace("NOT IN", "IN"), b.replace("NOT IN", "IN"))):
diff = explain_sql_diff(x, y, "sqlite", labels=LABELS, schema=schema)
check_shape(diff)
assert diff.items == [FacetDiff("other", None, None, FALLBACK)]
def test_a_swapped_table_keeps_each_sides_own_columns():
@@ -1012,13 +1003,19 @@ def test_a_swapped_table_keeps_each_sides_own_columns():
("time", "原流程按支付时间 paid_at 筛时间,盲算按注册时间 created_at")]
assert diff.items[1].detail == {"a": [{"table": "orders", "column": "paid_at"}],
"b": [{"table": "users", "column": "created_at"}]}
# 换了关联的表,关联条件也跟着不同:关联条件不在能说的四类里,补一句兜底
diff = explain_sql_diff("SELECT SUM(o.amount) FROM orders o JOIN stores s ON s.id = o.store_id WHERE s.city = 'SH'",
"SELECT SUM(o.amount) FROM orders o JOIN users u ON u.id = o.user_id WHERE u.city = 'SH'",
"sqlite")
check_shape(diff)
assert [d.text for d in diff.items] == [
"A 查的是 stores 表,B 查的是 users 表",
"A 关联的条件是 orders.store_id = stores.id,B 是 orders.user_id = users.id"]
assert [d.text for d in diff.items] == ["A 查的是 stores 表,B 查的是 users 表", FALLBACK_REST]
# 换了一张表,同一个意思的列在两边各属各的表:A 的 is_deleted = 0 对 B 的 is_deleted = 1 是换了值,不能说成
# 「A 只保留 is_deleted = 0 的行,B 没有这个条件」
diff = explain_sql_diff("SELECT SUM(amount) FROM orders WHERE is_deleted = 0 AND paid_at >= '2026-09-01'",
"SELECT SUM(amount) FROM orders_archive WHERE is_deleted = 1 AND paid_at >= '2026-09-01'",
"sqlite")
check_shape(diff)
assert [d.text for d in diff.items] == ["A 查的是 orders 表,B 查的是 orders_archive 表", FALLBACK_REST]
def test_ordering_that_decides_which_row_distinct_on_keeps_is_not_just_ordering():
@@ -1099,3 +1096,567 @@ def test_only_literals_changed_stays_the_same_template_after_the_rewrite(dialect
def test_a_structural_change_is_still_a_different_template(dialect, a, b):
"""结构变了的(列名表的先后、IN 和 NOT IN、常量写成一个数、连接类型)照旧不是同一个模板。"""
assert not normalize_sql(a, dialect).same_shape(normalize_sql(b, dialect))
# --------------------------------------------------------------------------
# 第 3 轮复验的反例:等价的结论、具体说法的可靠性
# --------------------------------------------------------------------------
@pytest.mark.parametrize("name,dialect,a,b", sql_pairs.REGRESSIONS_R3,
ids=[f"{n}-{d}" for n, d, _, _ in sql_pairs.REGRESSIONS_R3])
def test_round_three_counterexamples_are_never_equivalent(name, dialect, a, b):
"""第 3 轮复验报的 blocker(PostgreSQL / MySQL 的写法、执行起来每次都不同的 RANDOM()):外连接改内连接时 NOT IN、
ALL、NOT … ANY、数组拼接其实留得下没关联上的行;ORDER BY 里单独一个名字先认列别名;没有 GROUP BY 的 HAVING 让
语句只出一行;MySQL 的 /*! … */、!、--1、类型不同的 BETWEEN;不确定的函数;解析时换成同一个节点的写法。不管
给不给表结构、谁先谁后,一律不判等价。"""
for schema in (None, sql_pairs.schema_cache()):
for x, y in ((a, b), (b, a)):
diff = explain_sql_diff(x, y, dialect, schema=schema)
check_shape(diff)
assert not diff.equivalent, name
@pytest.mark.parametrize("name,dialect,a,b", sql_pairs.DIALECT_SAME_R3,
ids=[f"{n}-{d}" for n, d, _, _ in sql_pairs.DIALECT_SAME_R3])
def test_round_three_equivalent_rewrites_get_at_most_the_fallback(name, dialect, a, b):
"""第 3 轮复验报的 major(意思一样,却说了具体的不同)在 PostgreSQL / MySQL 上的写法:认得出就判等价,认不出只给
兜底说明,不说「A 按 X 分组,B 按 Y 分组」「A 用 LEFT JOIN,B 用 INNER JOIN」这类其实不影响结果的话。"""
for schema in (None, sql_pairs.schema_cache()):
for x, y in ((a, b), (b, a)):
diff = explain_sql_diff(x, y, dialect, schema=schema)
check_shape(diff)
assert diff.equivalent or all(d.aspect == "other" for d in diff.items), (name, diff.items)
@pytest.mark.parametrize("dialect", DIALECTS)
def test_irrelevant_differences_inside_exists_and_in_are_never_described(dialect):
"""EXISTS 只看有没有行、IN 只看在不在:子查询里去重、分组、UNION 还是 UNION ALL 都不影响。以前说「A 在 EXISTS
子查询里对 amount 去重,B 没有去重」「A 在 id IN 的子查询里用 UNION 合并」,还标成说全了。"""
ids = ["same:r3-exists-distinct", "same:r3-not-exists-distinct", "same:r3-exists-group-by",
"same:r3-exists-union-vs-union-all", "same:r3-in-union-vs-union-all", "same:r3-in-group-by",
"same:r3-in-group-by-two-keys", "same:r3-in-group-by-having-count-noop"]
for pid in ids:
pair = sql_pairs.PAIRS_BY_ID[pid]
for schema in (None, sql_pairs.schema_cache()):
diff = explain_sql_diff(pair.a, pair.b, dialect, schema=schema)
check_shape(diff)
assert diff.equivalent or all(d.aspect == "other" for d in diff.items), (pid, diff.items)
def test_only_the_time_column_is_reported_when_the_group_key_is_equal_by_join():
"""真正的差异只有时间列;分组键一边写 users.id、一边写 orders.user_id,被关联条件定成相等。给了表结构(两列都声明
INTEGER,相等就是一样)只说时间列、说全了(只差一个方面,选择才能写进数据目录);没给表结构时类型不知道(SQLite 里
1 和 '1' 也相等),只说时间列,另附兜底,不说「A 按 users.id 分组,B 按 orders.user_id 分组」。"""
pair = sql_pairs.PAIRS_BY_ID["diff:r3-time-plus-group-key-equal"]
expected = ("time", "A 按 paid_at 筛时间,B 按 created_at")
diff = explain_sql_diff(pair.a, pair.b, "sqlite", schema=sql_pairs.schema_cache())
check_shape(diff)
assert [(d.aspect, d.text) for d in diff.items] == [expected] and not diff.fallback
diff = explain_sql_diff(pair.a, pair.b, "sqlite")
check_shape(diff)
assert (diff.items[0].aspect, diff.items[0].text) == expected
assert all(d.aspect in ("time", "other") for d in diff.items)
def test_only_the_time_column_is_reported_when_the_left_join_cannot_matter():
"""结果只是可空一侧的聚合(COALESCE(SUM(r.amount), 0)):LEFT JOIN 和 INNER JOIN 结果一样,真正的差异只有时间列。
以前多说一句「A 用 LEFT JOIN 关联 refunds 表,B 用 INNER JOIN」,还标成说全了。"""
pair = sql_pairs.PAIRS_BY_ID["diff:r3-time-plus-left-coalesce"]
for schema in (None, sql_pairs.schema_cache()):
diff = explain_sql_diff(pair.a, pair.b, "sqlite", schema=schema)
check_shape(diff)
assert [(d.aspect, d.text) for d in diff.items] == [("time", "A 按 paid_at 筛时间,B 按 created_at")]
for dialect in ("postgres", "mysql"):
diff = explain_sql_diff(pair.a, pair.b, dialect)
check_shape(diff)
assert not any(d.aspect == "join" for d in diff.items), (dialect, diff.items)
@pytest.mark.parametrize("dialect", DIALECTS)
def test_a_self_join_direction_gets_only_the_fallback(dialect):
"""自己关联自己,关联的方向不同:两个别名都是 orders,以前说成「A 关联 orders 表的条件是 orders.parent_id =
orders.id,B 是 orders.id = orders.parent_id」,读起来是同一个等式、自相矛盾。分不清哪个是哪个,只给兜底说明。"""
pair = sql_pairs.PAIRS_BY_ID["diff:r3-self-join-roles"]
for schema in (None, sql_pairs.schema_cache()):
diff = explain_sql_diff(pair.a, pair.b, dialect, schema=schema)
check_shape(diff)
assert not diff.equivalent and all(d.aspect == "other" for d in diff.items), diff.items
@pytest.mark.parametrize("dialect", DIALECTS)
def test_excluding_by_not_equal_also_mentions_the_null_rows(dialect):
"""<>、NOT IN 也把这一列为空的行筛掉了:以前只说「A 排除了 is_deleted = 1 的行」,读的人会以为空值的行还在。
NOT 套着几个条件的,照原样说「只保留 NOT (…) 的行」。"""
diff = explain_sql_diff("SELECT SUM(amount) FROM orders WHERE is_deleted <> 1", "SELECT SUM(amount) FROM orders",
dialect)
check_shape(diff)
assert [d.text for d in diff.items] == ["A 排除了 is_deleted = 1 的行和 is_deleted 为空的行,B 没有"]
diff = explain_sql_diff("SELECT COUNT(*) FROM orders WHERE status NOT IN ('void', 'open')",
"SELECT COUNT(*) FROM orders", dialect)
check_shape(diff)
assert [d.text for d in diff.items] == ["A 排除了 status IN ('open', 'void') 的行和 status 为空的行,B 没有"]
# NOT 往里推成 status <> 'void' OR is_deleted <> 1(三值逻辑下一样),照原样说「只保留 … 的行」
diff = explain_sql_diff("SELECT COUNT(*) FROM orders WHERE NOT (status = 'void' AND is_deleted = 1)",
"SELECT COUNT(*) FROM orders", dialect)
check_shape(diff)
assert [d.text for d in diff.items] == ["A 只保留 status <> 'void' OR is_deleted <> 1 的行,B 没有这个条件"]
@pytest.mark.parametrize("pid", ["hard:r3-disjoint-union-all-vs-union", "hard:r3-order-single-group",
"hard:r3-distinct-join-vs-in", "hard:r3-count-distinct-join-vs-in",
"hard:r3-join-vs-exists-distinct", "hard:r3-shadowed-alias-written-out"])
@pytest.mark.parametrize("dialect", DIALECTS)
def test_equivalent_rewrites_needing_reasoning_get_only_the_fallback(pid, dialect):
"""意思一样、要按语义推理才看得出的(各支本身去重、互不相交的 UNION ALL 和 UNION;只有一组时的排序;关联一张表和
IN / EXISTS 子查询;被遮住的别名写明白):以前说「A 用 UNION ALL(重复的行都留着)」「A 按 user_id 升序排,B 没有
排序」「A 关联了 users 表,B 没有关联它」「A 在 EXISTS 子查询里只保留 status = 'paid' 的行」。认不出只给兜底。"""
pair = sql_pairs.PAIRS_BY_ID[pid]
for schema in (None, sql_pairs.schema_cache()):
diff = explain_sql_diff(pair.a, pair.b, dialect, schema=schema)
check_shape(diff)
assert diff.equivalent or all(d.aspect == "other" for d in diff.items), diff.items
def test_identical_text_is_equivalent_even_when_the_parser_would_lose_something():
"""一字不差的两条就是同一条语句:带 MySQL 的 /*! … */、RANDOM() 这些不在语法树上判等价的写法,原文一样也算等价。"""
for sql, dialect in (("SELECT COUNT(*) FROM orders /*! WHERE status = 'paid' */", "mysql"),
("SELECT COUNT(*) FROM orders WHERE RANDOM() % 2 = 0", "sqlite"),
("SELECT COUNT(*) FROM orders WHERE MOD(amount, 2) = 0", "sqlite")):
diff = explain_sql_diff(sql, " " + sql + "\n", dialect)
check_shape(diff)
assert diff.equivalent, sql
@pytest.mark.parametrize("dialect,a,b", [
("mysql", "SELECT COUNT(*) FROM orders /*! WHERE status = 'paid' */", "SELECT COUNT(*) FROM orders"),
("mysql", "SELECT COUNT(*) FROM orders WHERE ! status = 'paid'", "SELECT COUNT(*) FROM orders WHERE NOT status = 'paid'"),
("mysql", "SELECT id, amount --1 FROM orders", "SELECT id, amount FROM orders"),
("sqlite", "SELECT COUNT(*) FROM orders WHERE MOD(amount, 2) = 0", "SELECT COUNT(*) FROM orders WHERE amount % 2 = 0"),
("sqlite", "SELECT CHAR_LENGTH(name) FROM users", "SELECT LENGTH(name) FROM users"),
], ids=["executable-comment", "bang", "double-dash", "mod-function", "char-length"])
def test_a_structure_the_parser_loses_is_never_the_same_template(dialect, a, b):
"""解析时丢了的结构(MySQL 会执行的 /*! … */ 注释、比比较结合得紧的 !、--1 是减负一,SQLite 的 MOD() 和 %、
SQLite 没有的 CHAR_LENGTH)不能被 normalize_sql 当成「只改了参数」。"""
assert not normalize_sql(a, dialect).same_shape(normalize_sql(b, dialect))
@pytest.mark.parametrize("dialect,a,b", [
("mysql", "SELECT COUNT(*) FROM orders /*! WHERE status = 'paid' */", "SELECT COUNT(*) FROM orders /*! WHERE status = 'void' */"),
("mysql", "SELECT COUNT(*) FROM orders WHERE ! status = 'paid'", "SELECT COUNT(*) FROM orders WHERE ! status = 'void'"),
("sqlite", "SELECT COUNT(*) FROM orders WHERE MOD(amount, 2) = 0", "SELECT COUNT(*) FROM orders WHERE MOD(amount, 3) = 1"),
("sqlite", "SELECT o.id FROM orders o WHERE EXISTS (SELECT DISTINCT r.amount FROM refunds r WHERE r.order_id = o.id "
"AND r.refunded_at >= '2026-09-01')",
"SELECT o.id FROM orders o WHERE EXISTS (SELECT DISTINCT r.amount FROM refunds r WHERE r.order_id = o.id "
"AND r.refunded_at >= '2026-10-01')"),
("sqlite", "SELECT id FROM orders ORDER BY id LIMIT -1", "SELECT id FROM orders ORDER BY id LIMIT -20"),
("postgres", "SELECT COALESCE(SUM(r.amount), 0) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id "
"WHERE o.paid_at >= '2026-09-01'",
"SELECT COALESCE(SUM(r.amount), 1) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id "
"WHERE o.paid_at >= '2026-10-01'"),
], ids=["executable-comment", "bang", "mod-function", "exists-distinct", "limit", "left-coalesce"])
def test_only_literals_changed_is_still_the_same_template_in_round_three(dialect, a, b):
"""这一轮动过的写法(会执行的注释、!、MOD()、EXISTS 里去重、负数 LIMIT、LEFT JOIN 的聚合):只改字面量的照旧是
同一个模板,字面量各是各的。"""
na, nb = normalize_sql(a, dialect), normalize_sql(b, dialect)
assert na.same_shape(nb) and na.literals != nb.literals
def test_a_join_against_an_in_subquery_gets_only_the_fallback():
"""按主键关联 users 和 user_id IN (SELECT id FROM users) 是一回事(每张订单最多关联上一个用户):以前没给表结构时
说「A 关联了 users 表,B 没有关联它」。条件里有子查询的不是简单形状,给不给表结构都只给兜底。"""
a = "SELECT o.id, o.amount FROM orders o JOIN users u ON u.id = o.user_id"
b = "SELECT o.id, o.amount FROM orders o WHERE o.user_id IN (SELECT id FROM users)"
for schema in (None, sql_pairs.schema_cache()):
diff = explain_sql_diff(a, b, "sqlite", schema=schema)
check_shape(diff)
assert diff.items == [FacetDiff("other", None, None, FALLBACK)], diff.items
@pytest.mark.parametrize("dialect", DIALECTS)
@pytest.mark.parametrize("a,b", [
("SELECT DISTINCT o.user_id FROM orders o LEFT JOIN refunds r ON r.order_id = o.id",
"SELECT o.user_id FROM orders o LEFT JOIN refunds r ON r.order_id = o.id"),
("SELECT SUM(r.amount) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id",
"SELECT SUM(IFNULL(r.amount, NULL)) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id"),
("SELECT SUM(o.amount), COUNT(*) FROM orders o LEFT JOIN stores s ON s.id = o.store_id",
"SELECT SUM(o.amount), COUNT(*) FROM orders o LEFT JOIN stores s ON s.id IN (o.store_id)"),
], ids=["dropped-left-join", "null-blind-left-join", "primary-key-spelled-otherwise"])
def test_a_join_rewritten_on_one_side_only_is_not_described_as_missing(dialect, a, b):
"""一边的 LEFT JOIN 被规范化去掉了(DISTINCT 之后不怕重复、按主键关联)或者按内连接比(只取可空一侧的聚合),另一边
写法不同没认出来:原文两边都关联了这张表、都写的 LEFT JOIN,不能说「A 没有关联 refunds 表」「A 用 INNER JOIN」。"""
for schema in (None, sql_pairs.schema_cache()):
for x, y in ((a, b), (b, a)):
diff = explain_sql_diff(x, y, dialect, schema=schema)
check_shape(diff)
assert not any(d.aspect == "join" for d in diff.items), (dialect, diff.items)
# --------------------------------------------------------------------------
# 第 4 轮:解析保真守卫、具体说法只说真会影响结果的不同
# --------------------------------------------------------------------------
@pytest.mark.parametrize("dialect,a,b", [
("sqlite", "SELECT COUNT(*) FROM orders WHERE status = 'paid' IS NULL",
"SELECT COUNT(*) FROM orders WHERE status = ('paid' IS NULL)"),
("sqlite", "SELECT COUNT(*) FROM orders WHERE amount < 100 IN (0, 1)",
"SELECT COUNT(*) FROM orders WHERE amount < (100 IN (0, 1))"),
("sqlite", "SELECT COUNT(*) FROM orders WHERE amount = 1 BETWEEN 0 AND 1",
"SELECT COUNT(*) FROM orders WHERE amount = (1 BETWEEN 0 AND 1)"),
("sqlite", "SELECT COUNT(*) FROM orders WHERE status = region NOT NULL",
"SELECT COUNT(*) FROM orders WHERE status = (region NOT NULL)"),
("postgres", "SELECT COUNT(*) FROM orders WHERE amount > ALL (SELECT amount FROM refunds) IS NULL",
"SELECT COUNT(*) FROM orders WHERE amount > ALL ((SELECT amount FROM refunds) IS NULL)"),
("mysql", "SELECT COUNT(*) FROM orders WHERE amount = CASE WHEN tax > 0 THEN 1 END IS NULL",
"SELECT COUNT(*) FROM orders WHERE amount = (CASE WHEN tax > 0 THEN 1 END IS NULL)"),
("mysql", "SELECT COUNT(*) FROM orders WHERE status = 'paid' LIKE 'p%'",
"SELECT COUNT(*) FROM orders WHERE status = ('paid' LIKE 'p%')"),
("mysql", "SELECT id, id | 1 ^ 2 FROM orders", "SELECT id, (id | 1) ^ 2 FROM orders"),
("mysql", "SELECT 'pa' 'id' FROM orders", "SELECT 'pa' FROM orders"),
("sqlite", "SELECT id, amount * 2_0 FROM orders", "SELECT id, amount * 2 FROM orders"),
(None, "SELECT id FROM orders WHERE status = 'a\\Z'", "SELECT id FROM orders WHERE status = 'a\\\\Z'"),
(None, "SELECT id FROM orders WHERE status || 'x' = 'paidx'", "SELECT id FROM orders WHERE status = 'paid'"),
], ids=["is-after-eq", "in-after-lt", "between-after-eq", "postfix-not-null", "all-then-is", "case-then-is",
"like-after-eq", "mysql-or-xor-bits", "adjacent-strings", "digit-separator", "unknown-backslash",
"unknown-pipes"])
def test_sql_the_parser_would_read_wrongly_gets_only_the_fallback_and_the_original_lines(dialect, a, b):
"""原文里有 sqlglot 会读错的写法(各方言的结合顺序和 sqlglot 不一样、字符串里的反斜杠、数字写法、挨着写的字符串常量、
方言不知道时的 ||):不在语法树上判等价,也不给具体说法,只给兜底,text_diff 是原文的逐行对照。"""
for x, y in ((a, b), (b, a)):
diff = explain_sql_diff(x, y, dialect)
check_shape(diff)
assert not diff.equivalent and diff.items == [FacetDiff("other", None, None, FALLBACK)], diff.items
assert diff.text_diff == (f"- {x}", f"+ {y}")
# 一字不差照样算等价
assert explain_sql_diff(a, " " + a + "\n", dialect).equivalent
def test_the_original_lines_are_compared_line_by_line():
"""原文有好几行时,原文的逐行对照只列不同的行。"""
a = "SELECT COUNT(*)\nFROM orders\nWHERE status = 'paid' IS NULL"
b = "SELECT COUNT(*)\nFROM orders\nWHERE status = ('paid' IS NULL)"
diff = explain_sql_diff(a, b, "sqlite")
check_shape(diff)
assert diff.text_diff == ("- WHERE status = 'paid' IS NULL", "+ WHERE status = ('paid' IS NULL)")
@pytest.mark.parametrize("dialect,sql", [
("sqlite", "SELECT o.id, SUM(o.amount) FROM orders o JOIN users u ON u.id = o.user_id AND u.city IS NOT NULL "
"WHERE o.amount BETWEEN 1 AND 10 AND o.status NOT IN ('void') AND o.paid_at IS NOT NULL "
"GROUP BY o.id HAVING COUNT(*) > 1 ORDER BY 2 DESC LIMIT 5"),
("postgres", "SELECT CASE WHEN status = 'paid' THEN amount ELSE 0 END, status IS DISTINCT FROM region "
"FROM orders WHERE amount > ALL (SELECT amount FROM refunds) AND flags & 4 = 4"),
("mysql", "SELECT id FROM orders WHERE LEFT(status, 1) = 'p' AND status = 'pa''id' || amount > 3 && tax < 1"),
("sqlite", "SELECT LEFT(status, 2) = 'pa', amount << 2, 'a' || status FROM orders WHERE x NOT NULL"),
], ids=["ordinary-sqlite", "ordinary-postgres", "ordinary-mysql", "ordinary-functions"])
def test_ordinary_sql_is_not_held_back_by_the_parser_guard(dialect, sql):
"""常见的写法(AND 分开的几个比较、BETWEEN 自己的 AND、IS DISTINCT FROM、> ALL (…)、flags & 4 = 4、MySQL 的
ISNULL(x) 函数和 || / &&、LEFT(…) 函数、单独的 <<、拼接)不算会读错的写法:照常规范化,加个空格照样认得出等价。"""
from app.data.sql_semantics import _lost_in_parsing, _misread, _parse, _read, _tokens
read = _read(dialect)
tree, error = _parse(sql, read)
assert error is None and not _lost_in_parsing(sql, read, tree)
assert explain_sql_diff(sql, sql.replace(" FROM ", " FROM "), dialect).equivalent
# MySQL 的 ISNULL(x) 是函数,不算和 = 混用的谓词
text = "SELECT id FROM orders WHERE ISNULL(status) = 1"
assert _misread(_tokens(text, "mysql"), text, "mysql") is None
@pytest.mark.parametrize("dialect,a,b", [
("sqlite", "SELECT id, amount * 1_000 FROM orders", "SELECT id, amount * 2_000 FROM orders"),
("postgres", "SELECT id, amount + 0o17 FROM orders", "SELECT id, amount + 0o20 FROM orders"),
("mysql", "SELECT COUNT(*) FROM orders WHERE status = 'pa\\Zid'", "SELECT COUNT(*) FROM orders WHERE status = 'pa\\\\Zid'"),
], ids=["digit-separator", "octal", "backslash"])
def test_only_literals_changed_in_misread_sql_is_still_the_same_template(dialect, a, b):
"""sqlglot 会读错的写法改按原文逐词规范化:1_000、0o17 这类数字和后面紧跟着的名字算一个字面量,只改了字面量的照旧
是同一个模板,字面量照原文列出('pa\\Zid' 和 'pa\\\\Zid' 是两个不同的值)。"""
na, nb = normalize_sql(a, dialect), normalize_sql(b, dialect)
assert na.same_shape(nb) and na.literals != nb.literals
assert not normalize_sql(a, dialect).same_shape(normalize_sql(a.replace(" FROM", ", 1 FROM", 1), dialect))
@pytest.mark.parametrize("a,b", [
# 只筛掉空值的条件,另一边的聚合本来就跳过空值
("SELECT SUM(amount) FROM orders WHERE amount > 0 OR amount <= 0", "SELECT SUM(amount) FROM orders"),
("SELECT MIN(amount) FROM orders WHERE amount > 5 OR amount <= 5", "SELECT MIN(amount) FROM orders"),
("SELECT COUNT(*) FROM orders WHERE amount > 0 OR amount <= 0 OR amount IS NULL", "SELECT COUNT(*) FROM orders"),
# 结果只有一行、值又和行无关:多一个条件、少一张表都看不出来
("SELECT COUNT(*) > -1 FROM orders WHERE status = 'paid'", "SELECT COUNT(*) > -1 FROM orders"),
("SELECT CASE WHEN COUNT(*) >= 0 THEN 1 END FROM orders WHERE status = 'paid'",
"SELECT CASE WHEN COUNT(*) >= 0 THEN 1 END FROM orders"),
("SELECT COUNT(*) > -1 FROM orders", "SELECT COUNT(*) > -1 FROM orders_archive"),
("SELECT MAX(o.amount), COUNT(*) > -1 FROM orders o LEFT JOIN refunds r ON r.order_id = o.id",
"SELECT MAX(o.amount), COUNT(*) > -1 FROM orders o"),
# 多 LEFT JOIN 一张表只会让行重复:只取第一行时重复出来的和原来那行一样
("SELECT o.id, o.amount FROM orders o LEFT JOIN refunds r ON r.order_id = o.id ORDER BY o.id LIMIT 1",
"SELECT o.id, o.amount FROM orders o ORDER BY o.id LIMIT 1"),
# 聚合的参数在组里是定死的:被 WHERE 定成常量、等于分组键、本身是常量
("SELECT MIN(amount) FROM orders WHERE amount = 5", "SELECT MAX(amount) FROM orders WHERE amount = 5"),
("SELECT user_id, MIN(user_id) FROM orders GROUP BY user_id", "SELECT user_id, MAX(user_id) FROM orders GROUP BY user_id"),
("SELECT user_id, AVG(DISTINCT user_id) FROM orders GROUP BY user_id",
"SELECT user_id, AVG(user_id) FROM orders GROUP BY user_id"),
("SELECT user_id, COUNT(*) FROM orders GROUP BY user_id", "SELECT user_id, SUM(1) FROM orders GROUP BY user_id"),
("SELECT MIN(5) FROM orders", "SELECT MAX(5) FROM orders"),
# 去重不起作用:只出一行、只取一行
("SELECT DISTINCT COUNT(*) FROM orders", "SELECT COUNT(*) FROM orders"),
("SELECT DISTINCT amount FROM orders ORDER BY amount LIMIT 1", "SELECT amount FROM orders ORDER BY amount LIMIT 1"),
# 条件恒为假:两边都没有行
("SELECT COUNT(*) FROM orders WHERE amount > 5 AND amount < 3 AND status = 'paid'",
"SELECT COUNT(*) FROM orders WHERE amount > 5 AND amount < 3"),
("SELECT SUM(amount) FROM orders WHERE 1 = 0 AND status = 'paid'", "SELECT AVG(amount) FROM orders WHERE 1 = 0"),
# LEFT JOIN 对内连接:结果只取可空一侧跳过空值的聚合、FILTER,或者后面的内连接引用了可空一侧
("SELECT COUNT(*) FILTER (WHERE r.amount > 0) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id",
"SELECT COUNT(*) FILTER (WHERE r.amount > 0) FROM orders o JOIN refunds r ON r.order_id = o.id"),
("SELECT MAX(r.amount), COUNT(*) > -1 FROM orders o LEFT JOIN refunds r ON r.order_id = o.id",
"SELECT MAX(r.amount), COUNT(*) > -1 FROM orders o JOIN refunds r ON r.order_id = o.id"),
("SELECT COUNT(*) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id JOIN payments p ON p.order_id = r.order_id",
"SELECT COUNT(*) FROM orders o JOIN refunds r ON r.order_id = o.id JOIN payments p ON p.order_id = r.order_id"),
], ids=lambda v: None)
def test_differences_that_cannot_change_the_result_are_not_described(a, b):
"""第 4 轮之后自己压测的反例:两条结果一样(这几组数据上执行也一样),以前会说出不存在的不同——只筛掉空值的条件对
跳过空值的聚合、只出一行又和行无关的结果、在组里定死的聚合参数、只出一行的去重、恒为假的条件、看不见没关联上的行的
LEFT JOIN。给不给表结构都只许给兜底(或者认出等价)。"""
databases = sql_pairs.build_databases()
try:
assert all(sql_pairs.run(c, a) == sql_pairs.run(c, b) for c in databases), "反例没造好:结果不一样"
finally:
for c in databases:
c.close()
for schema in (None, sql_pairs.schema_cache()):
for x, y in ((a, b), (b, a)):
diff = explain_sql_diff(x, y, "sqlite", schema=schema)
check_shape(diff)
assert diff.equivalent or all(d.aspect == "other" for d in diff.items), (x, y, diff.items)
@pytest.mark.parametrize("a,b", [
("SELECT COUNT(*) FROM orders WHERE id IS NOT NULL", "SELECT COUNT(*) FROM orders"),
("SELECT id, SUM(amount) FROM orders GROUP BY id", "SELECT id, AVG(amount) FROM orders GROUP BY id"),
("SELECT DISTINCT id, status FROM orders", "SELECT id, status FROM orders"),
("SELECT DISTINCT status FROM orders WHERE id = 5", "SELECT status FROM orders WHERE id = 5"),
("SELECT COUNT(DISTINCT status) FROM orders WHERE id = 5", "SELECT COUNT(status) FROM orders WHERE id = 5"),
("SELECT o.id FROM orders o LEFT JOIN users u ON u.id = o.user_id", "SELECT o.id FROM orders o"),
("SELECT COUNT(*) FROM orders o JOIN users u ON u.id = o.user_id", "SELECT COUNT(*) FROM orders o"),
# 第 5 轮:按主键分组(或者经关联条件等于主键),同一组里这张表的列只有一个值——LEFT JOIN 把行复制了也一样
("SELECT u.id, MAX(u.city), COUNT(o.id) FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id",
"SELECT u.id, MIN(u.city), COUNT(o.id) FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id"),
("SELECT o.id, AVG(DISTINCT o.amount), COUNT(r.id) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id "
"GROUP BY o.id",
"SELECT o.id, AVG(o.amount), COUNT(r.id) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id GROUP BY o.id"),
("SELECT o.user_id, MAX(u.name), SUM(o.amount) FROM orders o JOIN users u ON u.id = o.user_id GROUP BY o.user_id",
"SELECT o.user_id, MIN(u.name), SUM(o.amount) FROM orders o JOIN users u ON u.id = o.user_id GROUP BY o.user_id"),
], ids=lambda v: None)
def test_constraints_only_make_the_explanation_say_less(a, b):
"""表结构里的约束不拿来判等价,只拿来少说:主键不为空(id IS NOT NULL 恒为真)、主键定下来每组只有一行(SUM 和
AVG 一样)、选出来的列含主键或者 WHERE 把主键定死(去重不起作用)、按主键关联(不会重复)、有外键指过去又按它的
键关联(每行都关联得上)——给了表结构时都只给兜底;没给表结构时不知道有这些约束,说得出具体的不同。"""
schema = sql_pairs.schema_cache()
schema["tables"]["orders"]["foreign_keys"] = [{"columns": ["user_id"], "to_table": "users", "to_columns": ["id"]}]
for x, y in ((a, b), (b, a)):
diff = explain_sql_diff(x, y, "sqlite", schema=schema)
check_shape(diff)
assert not diff.equivalent and all(d.aspect == "other" for d in diff.items), (x, y, diff.items)
diff = explain_sql_diff(x, y, "sqlite")
check_shape(diff)
assert any(d.aspect != "other" for d in diff.items), (x, y, diff.items)
# --------------------------------------------------------------------------
# 第 5 轮:PostgreSQL 写成符号的运算符、各库读法不同的注释;条件写在聚合里、时间窗口、反连接、经主键定下来的列
# --------------------------------------------------------------------------
@pytest.mark.parametrize("dialect,sql", [
("postgres", "SELECT id, status ~ 'p' || 'x' FROM orders"),
("postgres", "SELECT id, status ~* 'p' || 'x' FROM orders"),
("postgres", "SELECT id, status ~~ 'p%' || 'x' FROM orders"),
("postgres", "SELECT id, status ~~* 'P%' || 'x' FROM orders"),
("postgres", "SELECT id, to_tsvector(status) @@ 'paid' || '' FROM orders"),
("postgres", "SELECT id, tags @> 'a' || 'b' FROM orders"),
("postgres", "SELECT id, @ amount - 20 FROM orders"),
("postgres", "SELECT id, |/ amount FROM orders"),
("postgres", "SELECT id, status COLLATE \"C\"::text FROM orders"),
(None, "SELECT id /* a /* b */ + 1 -- */\nFROM orders"),
(None, "SELECT id, amount--1\nFROM orders"),
(None, "SELECT COUNT(*) FROM orders WHERE \"status\" = 'paid'"),
], ids=["regex-concat", "iregex-concat", "like-op-concat", "ilike-op-concat", "text-search-concat", "contains-concat",
"absolute-value", "square-root", "collate-cast", "unknown-nested-comment", "unknown-dashes",
"unknown-double-quotes"])
def test_operators_postgres_reads_differently_from_sqlglot_are_held_back(dialect, sql):
"""PostgreSQL 里 ~、~*、~~、~~*、@@、@> 这些写成符号的运算符和 ||、&、# 同级(「别的运算符」)、从左往右结合,sqlglot
把它们当成 LIKE、IN 那一级、比 || 松:status ~ 'p' || 'x' 在 PostgreSQL 里是 (status ~ 'p') || 'x'。前缀的 @ 是取
绝对值(sqlglot 读成参数),|/ 是双精度的平方根(sqlglot 写成 SQRT),x COLLATE "C"::text 的 :: 套在整个 COLLATE 上。
不知道是哪种库时,块注释里的 /*、紧跟着字的 --、双引号括起来的名字(MySQL 默认的 sql_mode 里是字符串)各库读法不同。
这些都不在语法树上判等价。"""
from app.data.sql_semantics import _lost_in_parsing, _parse, _read
read = _read(dialect)
tree, error = _parse(sql, read)
assert error is None and _lost_in_parsing(sql, read, tree)
@pytest.mark.parametrize("dialect,sql", [
("postgres", "SELECT id FROM orders WHERE status LIKE 'p' || '%'"),
("postgres", "SELECT id FROM orders WHERE status ILIKE 'p' || '%' AND amount > 1"),
("postgres", "SELECT id FROM orders WHERE status ~ ('p' || 'x')"),
("postgres", "SELECT id FROM orders WHERE status ~ '^p' AND amount + 1 > 2"),
("postgres", "SELECT id, status::text || 'x' FROM orders WHERE status COLLATE \"C\" > 'a'"),
("sqlite", "SELECT id /* a /* b */ + 1 -- */\nFROM orders"),
("postgres", "SELECT id /* a /* b */ + 1 -- */\nFROM orders"),
("mysql", "SELECT id /* a /* b */ + 1 -- */\nFROM orders"),
("sqlite", "SELECT id, amount--1\nFROM orders"),
("postgres", "SELECT id, amount--1\nFROM orders"),
("postgres", "SELECT COUNT(*) FROM orders WHERE \"status\" = 'paid'"),
("mysql", "SELECT COUNT(*) FROM orders WHERE status = \"paid\""),
], ids=["pg-like-concat", "pg-ilike-concat", "pg-regex-parenthesized", "pg-regex-own-segment", "pg-cast-collate-apart",
"sqlite-comment", "pg-comment", "mysql-comment", "sqlite-dashes", "pg-dashes", "pg-double-quotes",
"mysql-double-quotes"])
def test_writings_each_engine_reads_like_sqlglot_are_not_held_back(dialect, sql):
"""写成关键字的 LIKE / ILIKE 本来就比 || 松(PostgreSQL 和 sqlglot 读得一样);加了括号、AND 隔开的不算混用;知道是
SQLite、PostgreSQL、MySQL 时,注释怎么嵌套、-- 后面要不要空白、双引号括起来的是名字还是字符串,sqlglot 和库读得一样:
照常规范化,加个空格照样认得出等价。"""
from app.data.sql_semantics import _lost_in_parsing, _parse, _read
read = _read(dialect)
tree, error = _parse(sql, read)
assert error is None and not _lost_in_parsing(sql, read, tree)
assert explain_sql_diff(sql, sql.replace(" FROM", " FROM"), dialect).equivalent
@pytest.mark.parametrize("pid,expected", [
("diff:r5-between-on-two-time-columns-grouped-by-date", "A 按 paid_at 筛时间,B 按 created_at"),
("diff:r5-between-on-two-time-columns-top-n", "A 按 paid_at 筛时间,B 按 created_at"),
("diff:r5-between-window-on-one-side-grouped-by-date", "A 没有按时间筛选,B 按 paid_at 筛时间"),
("diff:r5-like-month-vs-no-window", "A 没有按时间筛选,B 按 paid_at 筛时间"),
("diff:r5-range-vs-like-month-on-another-column",
"A 按 paid_at 筛时间,B 按 created_at(A 是 paid_at >= '2026-09-01' AND paid_at < '2026-10-01',"
"B 是 created_at LIKE '2026-09%')"),
])
def test_time_windows_written_as_between_or_like_are_told_as_time_columns(pid, expected):
"""时间列上的 BETWEEN、LIKE '2026-09%'(SQLite 里日期存成文字,取月常这么写)是统计窗口:说成按哪一列筛时间,不说成
「只保留 … 的行」(那是有效行过滤,会写进数据目录)。结果看行先后的一段(按日期表达式分组、取前 N 行)里 BETWEEN
不拆成两个比较,照样认得出。"""
pair = sql_pairs.PAIRS_BY_ID[pid]
for schema in (None, sql_pairs.schema_cache()):
diff = explain_sql_diff(pair.a, pair.b, "sqlite", schema=schema)
check_shape(diff)
assert [(d.aspect, d.text) for d in diff.items] == [("time", expected)], diff.items
@pytest.mark.parametrize("a,b,expected", [
# 聚合的参数就是那一列(不是写在参数里的条件):另一边确实没有按 status 筛
("SELECT region, SUM(amount) FROM orders GROUP BY region",
"SELECT region, SUM(amount) FROM orders WHERE status = 'paid' GROUP BY region",
[("filter", "B 只保留 status = 'paid' 的行,A 没有这个条件")]),
("SELECT SUM(amount) FROM orders", "SELECT SUM(amount) FROM orders WHERE amount > 0",
[("filter", "B 只保留 amount > 0 的行,A 没有这个条件")]),
# 只是选出来给人看的标记(不聚合,行都在结果里):另一边确实把别的行也列出来了
("SELECT id, status = 'paid' AS is_paid FROM orders",
"SELECT id, status = 'paid' AS is_paid FROM orders WHERE status = 'paid'",
[("filter", "B 只保留 status = 'paid' 的行,A 没有这个条件")]),
# 判空、按状态筛这类和日期无关的条件照说;时间列上的判空是有效行过滤,不是时间窗口
("SELECT COUNT(*) FROM orders", "SELECT COUNT(*) FROM orders WHERE paid_at IS NOT NULL",
[("filter", "B 排除了 paid_at 为空的行,A 没有")]),
("SELECT COUNT(*) FROM orders", "SELECT COUNT(*) FROM orders WHERE grade = 3",
[("filter", "B 只保留 grade = 3 的行,A 没有这个条件")]),
], ids=lambda v: None)
def test_filters_the_other_side_really_does_not_have_are_still_told(a, b, expected):
"""收窄之后的对照:另一边确实没有这个条件时照说。"""
diff = explain_sql_diff(a, b, "sqlite")
check_shape(diff)
assert [(d.aspect, d.text) for d in diff.items] == expected, diff.items
@pytest.mark.parametrize("a,b", [
# 条件、时间窗口写在聚合的参数里:这一边也只算满足条件的行
("SELECT region, SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) FROM orders GROUP BY region",
"SELECT region, COUNT(*) FROM orders WHERE status = 'paid' GROUP BY region"),
("SELECT region, SUM(IIF(status = 'paid', amount, 0)) FROM orders GROUP BY region",
"SELECT region, SUM(amount) FROM orders WHERE status = 'paid' GROUP BY region"),
("SELECT region, AVG(status = 'paid') FROM orders GROUP BY region",
"SELECT region, AVG(status = 'paid') FROM orders WHERE status = 'paid' GROUP BY region"),
("SELECT region, SUM(amount * is_paid) FROM orders GROUP BY region",
"SELECT region, SUM(amount) FROM orders WHERE is_paid = 1 GROUP BY region"),
("SELECT SUM(amount) FILTER (WHERE created_at >= '2026-09-01') FROM orders",
"SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-09-01'"),
# 分不清是不是日期的列上的窗口(业务日期键、统计月份、分区):既不说成有效行过滤,也不说另一边「没有按时间筛选」
("SELECT SUM(amount) FROM sales WHERE stat_month = 202609", "SELECT SUM(amount) FROM sales"),
("SELECT SUM(amount) FROM sales WHERE pt = '20260901'", "SELECT SUM(amount) FROM sales"),
("SELECT SUM(amount) FROM sales WHERE biz_key BETWEEN 20260901 AND 20260930",
"SELECT SUM(amount) FROM sales WHERE created_at >= '2026-09-01' AND created_at < '2026-10-01'"),
("SELECT SUM(amount) FROM sales WHERE fiscal_year = 2026",
"SELECT SUM(amount) FROM sales WHERE created_at >= '2026-01-01'"),
# 另一边对别的列有范围(BETWEEN、上下界一对):可能是认不出的时间窗口,不说「没有按时间筛选」
("SELECT SUM(amount) FROM sales WHERE day_no BETWEEN 20697 AND 20726",
"SELECT SUM(amount) FROM sales WHERE created_at >= '2026-09-01' AND created_at < '2026-10-01'"),
# 反连接:可空一侧的列为空说的是「没关联上」
("SELECT COUNT(*) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id WHERE r.state IS NULL",
"SELECT COUNT(*) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id"),
("SELECT COUNT(*) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id WHERE r.refunded_at IS NULL",
"SELECT COUNT(*) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id WHERE r.id IS NULL"),
], ids=lambda v: None)
@pytest.mark.parametrize("dialect", DIALECTS)
def test_conditions_the_other_side_has_in_another_form_are_not_called_missing(a, b, dialect):
"""第 5 轮:另一边其实有这个条件(写在聚合的参数里、写在 CASE / IIF / FILTER 里、乘上 0 / 1 的标记),或者这个条件
可能是认不出的时间窗口,或者是反连接——说「另一边没有这个条件」「另一边没有按时间筛选」就说错了,只给兜底。"""
for x, y in ((a, b), (b, a)):
diff = explain_sql_diff(x, y, dialect)
check_shape(diff)
assert not diff.equivalent and diff.items == [FacetDiff("other", None, None, FALLBACK)], (x, y, diff.items)
def test_time_columns_on_different_tables_are_written_with_their_tables():
"""两边的时间列在不同的表上:光写「B 按 created_at」读起来像订单的下单时间,其实是用户的注册时间。写成「表.列」;
说「另一边没有按时间筛选」的一边关联了好几张表时也写表名。detail 照旧给各自的表。"""
a = "SELECT COUNT(*) FROM orders o JOIN users u ON u.id = o.user_id WHERE o.paid_at >= '2026-09-01'"
b = "SELECT COUNT(*) FROM orders o JOIN users u ON u.id = o.user_id WHERE u.created_at >= '2026-09-01'"
plain = "SELECT COUNT(*) FROM orders o JOIN users u ON u.id = o.user_id"
[item] = explain_sql_diff(a, b, "sqlite", names={"users.created_at": "注册时间"}).items
assert item.text == "A 按 orders.paid_at 筛时间,B 按注册时间 users.created_at"
assert item.detail == {"a": [{"table": "orders", "column": "paid_at"}],
"b": [{"table": "users", "column": "created_at"}]}
[item] = explain_sql_diff(b, plain, "sqlite").items
assert (item.aspect, item.text) == ("time", "A 按 users.created_at 筛时间,B 没有按时间筛选")
# ---------------------------------------------------------------- 前缀的 NOT 和谓词、算术混用(解析保真守卫)
_NOT_PREDICATES = ["status IN ('void', 'refunded')", "status NOT IN ('void')", "paid_at IS NULL", "paid_at IS NOT NULL",
"user_id IN (SELECT user_id FROM refunds)", "paid_at NOT NULL"]
_NOT_OPERATORS = ["*", "+", "-", "/", "%", "||", "&", "|"]
@pytest.mark.parametrize("predicate", _NOT_PREDICATES)
@pytest.mark.parametrize("operator", _NOT_OPERATORS)
@pytest.mark.parametrize("dialect", ["sqlite", None])
def test_a_prefix_not_mixed_with_a_predicate_and_arithmetic_is_never_equivalent(predicate, operator, dialect):
"""SQLite 里 NOT 最松:NOT status IN (…) * amount 是 NOT ((status IN (…)) * amount);sqlglot 的 NOT 只取等值那一级,
读成 (NOT status IN (…)) * amount,和加了括号的写法成了同一棵树。这类写法不在语法树上判等价,只给兜底。"""
a = f"SELECT id, NOT {predicate} {operator} amount FROM orders"
b = f"SELECT id, (NOT {predicate}) {operator} amount FROM orders"
diff = explain_sql_diff(a, b, dialect)
assert not diff.equivalent and diff.fallback, (a, diff)
@pytest.mark.parametrize(("a", "b"), [
("SELECT user_id, SUM(NOT status IN ('void', 'refunded') * amount) AS net FROM orders GROUP BY user_id",
"SELECT user_id, SUM((NOT status IN ('void', 'refunded')) * amount) AS net FROM orders GROUP BY user_id"),
("SELECT user_id, SUM(NOT paid_at IS NULL * amount) AS paid FROM orders GROUP BY user_id",
"SELECT user_id, SUM((NOT paid_at IS NULL) * amount) AS paid FROM orders GROUP BY user_id"),
])
def test_the_reported_prefix_not_pairs_are_not_equivalent(a, b):
for dialect in ("sqlite", None):
assert not explain_sql_diff(a, b, dialect).equivalent
@pytest.mark.parametrize(("a", "b"), [
("SELECT COUNT(*) FROM orders WHERE NOT status IN ('void') AND amount > 0",
"SELECT COUNT(*) FROM orders o WHERE NOT o.status IN ('void') AND o.amount > 0"),
("SELECT SUM(amount) FROM orders WHERE NOT status = 'void'",
"SELECT SUM(o.amount) FROM orders AS o WHERE NOT o.status = 'void'"),
])
def test_ordinary_prefix_not_is_still_recognised_as_equivalent(a, b):
"""守卫只拦同一段里 NOT、谓词、算术三样都有的;常见的 NOT … IN、NOT … = 照样认得出等价改写。"""
assert explain_sql_diff(a, b, "sqlite").equivalent