mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:11:58 +00:00
合入 SQL 差异说明收尾:判等价只用保持语义的规范化(去掉依赖表约束的改写),解析保真守卫拦下已知会被读错的写法(MySQL 反斜杠转义、特殊数字写法、比较 / 按位 / 前缀 NOT 没加括号地混用、PostgreSQL 符号运算符与 || 混用等),具体说法只在简单形状上说四类,其余给兜底并附逐行对照;回归语料与执行比对守住等价结论
This commit is contained in:
+2658
-1136
File diff suppressed because it is too large
Load Diff
Vendored
+1050
-11
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user