Files
AgentLab/backend/tests/test_sql_equivalence.py
T
ThearchyHelios 68ea3a7f47 口径账本内核第一步:sql_semantics 加参数绑定与等价证明(bind_template / match_template)
为什么:口径账本要认出「这次执行的 SQL 是不是某一版口径的取数」。只比 normalize_sql 的文本会错给信任(status = 'paid'
和 'refunded' 文本相同),拿模板原文直接比 explain_sql_diff 又永远不等价(参数不同)。做法照 next-round 02 做法 1:

- 绑定只是启发:模板里的 {{ input.<字段> }}(可带 trim / compact)按位置换成哨兵,依次在 optimizer、light、parsed 三级
  规范形式上抹掉字面量的值比结构,结构一样就按位置配字面量(半截字面量按正则取,写死的常量必须相等,同一槽位取同一个值);
  三级都配不上时退回「同一列、同一运算符」的启发式配对(只给说明和预填)。
- 等价只由按绑定 render_template(运行时同一个渲染器)渲染出的口径 SQL 交给 explain_sql_diff 判;有槽位既没取到值又没有
  缺省值时不证明(渲染出 EXISTS (SELECT  FROM …) 这种口径实际不会执行的 SQL,sqlglot 照样能解析)。
- 不可比照实给原因:引用 vars. / nodes. / file_text、参数写在名字里、别的过滤器。
- 等价时按位置给列映射(外层别名不计、列先后计,SELECT * 按表结构展开)。

测试:新增 test_sql_binding.py(认得出、不错给信任、不可比、列映射);test_sql_equivalence.py 加「口径模板」一节,语料里
每一对把 B 的头两个字面量换成槽位当模板拿 A 比,判了等价的在虚构库上执行结果必须一致、explain_sql_diff 也必须判等价。
去掉「槽位没值不证明」那一道回跑,这一节抓到 11 对执行报错。
2026-10-16 13:39:15 +08:00

431 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""SQL 差异说明的「等价」结论拿真实执行守门(data/sql_semantics.py 的 explain_sql_diff)。
explain_sql_diff 判两条 SQL 等价,当且仅当两边经过只保持语义的变换之后规范形式一字不差。规范化的每一步都必须
保持语义,可光看代码保证不了(sqlglot 自带的优化器里就有不保持语义的改写),所以这里在虚构的业务库上执行:
- 判了等价的两条,在每组数据上结果都必须一样(结果集按行排序比较);
- 在某组数据上结果不一样的两条,绝不能判等价;
- 语料里标 diff 的(语义不同,或者只在某个方言、某种排序规则下才等价),一律不能判等价;
- 标 same 的(已知等价),没认出来时只许给兜底说明,不许给具体说法(具体说法说的是两边确实不同的地方);要靠表结构
里的约束(主键唯一、不为空)才看得出等价的(Pair.needs_schema):规范化不靠约束改写,一律不判等价,给了表结构时
也只许给兜底(约束已经排除了的不同不许说),没给表结构时说得出具体的不同不算错;
- 等价改写(编号不以 hard: 开头的 same)记下认出来的比例:目标是大多数都认得出,但不强求全部。
库和语料见 tests/fixtures/sql_pairs.py:5 组按固定种子生成的数据加一组极端数据、一组专挑写法差异的数据(0.1 + 0.2、
像数字的文本、大小写不同的值),带空值、重复、边界值、已删除的行、退款;语料里有手写的等价改写和语义不同的对,也有
复验者以前造的、在这几组数据上结果不同的对,以及第 2、3、4 轮复验报的反例。每一对都在不给表结构和给表结构(数据源的
schema_cache)两种情况下各判一次,两种都要守住上面的规矩。
第 4 轮复验报的反例另有几组只查结论的(见文件末尾):只在 PostgreSQL / MySQL 才有的写法(sqlglot 读错了的结合顺序、
MySQL 字符串里的反斜杠转义、数字写法)一律不判等价、只给兜底;靠约束改写的陷阱(TEXT 主键对数值列、COLLATE、部分唯一
索引、MySQL 的隐式转换)在现造的小库上执行、一律不判等价;在 SQLite 上执行的那几对再拿 PostgreSQL / MySQL 的读法
各判一遍,意思一样的只许给兜底。
口径模板的绑定(bind_template / match_template,next-round 02 做法 1)也在这里守门(文件末尾「口径模板」一节):语料里每一对
把 B 的头两个字面量换成 {{ input.<字段> }} 当成口径模板,拿 A 去比;判了等价的,按绑定渲染的 SQL 在每组数据上和 A 的结果
必须一样,而且 explain_sql_diff(A, 渲染结果) 也必须判等价(等价只能由它判)。
第 5 轮的反例同样分两种:PostgreSQL 写成符号的 ~ / ~* / ~~ / ~~* / @@ 和 || 混用、前缀的 @、|/、COLLATE 后面跟 ::,
不知道是哪种库时套着的块注释、紧跟着字的 --、双引号括起来的名字,一律不判等价、只给兜底;具体说法说错了的(条件写在聚合里被说成「另一边
没有这个条件」、时间窗口被说成有效行过滤或者「没有按时间筛选」、反连接被说成有效行过滤、按主键定下来的列说 MAX 对
MIN、被规范化去掉的 LEFT JOIN 说成「另一边没有」)在 SQLite 上执行,再拿三种方言各判一遍。
"""
from __future__ import annotations
import pytest
from app.data.sql_semantics import explain_sql_diff
from tests.fixtures import sql_pairs
SCHEMA = sql_pairs.schema_cache()
@pytest.fixture(scope="module")
def databases():
conns = sql_pairs.build_databases()
yield conns
for c in conns:
c.close()
def _results(databases, sql: str) -> list[list[tuple]]:
return [sql_pairs.run(c, sql) for c in databases]
def _check_shape(diff, a: str, b: str) -> None:
"""SqlDiff 自己的承诺:等价才没有条目;不等价一定有条目,兜底时一定有 aspect=other 的那条,并给出不同的行。"""
if diff.equivalent:
assert diff.items == [] and not diff.fallback and not diff.text_diff, (a, b)
return
assert diff.items, (a, b)
if diff.fallback:
assert any(d.aspect == "other" for d in diff.items), (a, b, diff.items)
assert diff.text_diff, (a, b)
assert len(diff.text_diff) <= 40
@pytest.mark.parametrize("pair", sql_pairs.PAIRS, ids=[p.id for p in sql_pairs.PAIRS])
def test_equivalence_is_guarded_by_execution(databases, pair):
ra, rb = _results(databases, pair.a), _results(databases, pair.b)
differs = [i for i, (x, y) in enumerate(zip(ra, rb)) if x != y]
if pair.expect == "same":
assert not differs, f"语料标错了:{pair.id} 在第 {differs} 组数据上结果不同"
for schema in (None, SCHEMA):
diff = explain_sql_diff(pair.a, pair.b, "sqlite", schema=schema)
_check_shape(diff, pair.a, pair.b)
if differs:
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 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
def test_most_known_equivalent_rewrites_are_recognized():
"""已知等价的改写(别名、CTE 改名、关联先后、条件左右、子查询和 CTE 互换……)里认出来的比例。不强求全部认出来:
认不出的给的是兜底说明,不是错误的结论。要按语义推理才看得出的(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]
# 要靠约束才看得出的(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]
print(f"要按语义推理的 {len(hard)} 对里认出 {len(hard_hits)} 对(其余只给兜底说明)")
assert ratio >= 0.9 and bare_ratio >= 0.9
def test_the_corpus_is_large_and_varied_enough():
assert len(sql_pairs.PAIRS) >= 150 and len(sql_pairs.SEEDS) >= 5
text = " ".join(p.a + " " + p.b for p in sql_pairs.PAIRS).upper()
for construct in ("WITH ", "EXISTS", " IN (SELECT", "UNION", "OVER (", "DISTINCT", "LEFT JOIN", "HAVING",
"LIMIT", "ORDER BY", "RIGHT JOIN", "FULL JOIN", "NOT IN", "CROSS JOIN", "EXCEPT", "GROUP_CONCAT",
"USING (", "IIF("):
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]
pairs = {p.id: p for p in sql_pairs.PAIRS}
for pid in ("diff:r2-float-fold", "diff:r2-float-fold-chain", "diff:r2-double-negation", "diff:r2-in-single-column",
"diff:r2-cast-string", "diff:r2-hex-literal", "diff:r2-recursive-without-keyword",
"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)
# --------------------------------------------------------------------------
# 口径模板:绑定渲染后的 SQL 进同一套执行守门(next-round 02 做法 1)
# --------------------------------------------------------------------------
def _as_template(sql: str, slots: int = 2) -> tuple[str, int]:
"""把一条 SQL 的头几个字面量换成槽位,当成口径模板:字符串写成 '{{ input.pN }}',数写成裸的 {{ input.pN }}。"""
from sqlglot.dialects.dialect import Dialect
out: list[str] = []
pos = n = 0
for token in Dialect.get_or_raise("sqlite").tokenize(sql):
kind = token.token_type.name
text = sql[token.start:token.end + 1]
if n >= slots or kind not in ("STRING", "NUMBER") or (kind == "STRING" and not text.startswith("'")):
continue
out += [sql[pos:token.start], f"'{{{{ input.p{n} }}}}'" if kind == "STRING" else f"{{{{ input.p{n} }}}}"]
pos, n = token.end + 1, n + 1
out.append(sql[pos:])
return "".join(out), n
_TEMPLATED = [p for p in sql_pairs.PAIRS if _as_template(p.b)[1]]
@pytest.mark.parametrize("pair", _TEMPLATED, ids=[p.id for p in _TEMPLATED])
def test_bound_templates_are_guarded_by_execution(databases, pair):
"""B 的头两个字面量换成槽位当口径模板,A 当这次执行的 SQL:判了等价的(proven),按绑定渲染的口径 SQL 在每组数据上和
A 的结果一样;match_template 判等价、explain_sql_diff(A, 渲染结果) 却判不等价的情况一个都不许有。A、B 本来就不等价的对
(expect == diff),绑定之后照样不许判等价。"""
from app.data.sql_semantics import match_template
template, _ = _as_template(pair.b)
expected = _results(databases, pair.a)
for schema in (None, SCHEMA):
m = match_template(template, None, pair.a, "sqlite", schema=schema)
if not m.equivalent:
continue
assert explain_sql_diff(pair.a, m.rendered, "sqlite", schema=schema).equivalent, \
f"{pair.id}:match_template 判了等价,explain_sql_diff 却不判(schema={bool(schema)})"
assert _results(databases, m.rendered) == expected, \
f"{pair.id}:按绑定 {m.binding} 渲染的口径 SQL 在虚构库上结果不同,却判了等价(schema={bool(schema)})"
if pair.expect == "diff":
assert explain_sql_diff(pair.a, pair.b, "sqlite", schema=schema).equivalent is False
# 绑定换掉的只是 B 的字面量:B 原样不等价、换了参数之后等价,说明不同就在那几个字面量上(渲染结果已经执行验过)
def test_bound_templates_recognize_most_of_their_own_rewrites():
"""口径模板拿它自己(B)去比一定等价;拿已知等价的改写(A,expect == same、不靠约束)去比,大多数认得出。只打印比例,
不强求:认不出的给的是不等价和差在哪,不是错的结论。"""
from app.data.sql_semantics import match_template
for pair in _TEMPLATED:
template, _ = _as_template(pair.b)
m = match_template(template, None, pair.b, "sqlite", schema=SCHEMA)
assert m.equivalent or not m.binding, (pair.id, m.reason, m.diff and m.diff.items)
same = [p for p in _TEMPLATED if p.expect == "same" and not p.needs_schema and not p.id.startswith("hard:")]
hits = [p.id for p in same if match_template(_as_template(p.b)[0], None, p.a, "sqlite", schema=SCHEMA).equivalent]
print(f"\n口径模板(B 换成槽位)拿已知等价的改写去比:{len(same)} 对里认出 {len(hits)} 对")