Files
AgentLab/backend/tests/test_sql_equivalence.py
T
ThearchyHelios 08f75d4002 SQL 差异说明重做(第 3 轮):堵上第 2 轮复验报的不保持语义的规范化,认出复验指出的等价改写,具体说法只说真会影响结果的不同
为什么:第 2 轮复验报了 14 个 blocker(判了等价、结果却不同)和 11 个 major(意思一样,却说了具体的不同;说法
自相矛盾)。等价判定的框架(两边各自规范化、语法树一字不差才算等价)不变,这一轮逐个查清是哪一步不保持语义、
哪一处说法没先确认「真的不同」,按类堵上,并在执行比对语料里收进全部反例。

等价判定(backend/app/data/sql_semantics.py):
- 外连接改内连接(_rejects)分「一定是空值」和「一定不成立」两种:NOT 只能套前者,NOT IN 子查询、NOT IN ()、
  x op ALL (…)、NOT (x = ANY …)(含解析成函数的 ALL (ARRAY[…]))、PostgreSQL 的 || 都留得下没关联上的行。
- 改列别名、EXISTS 写成 SELECT 1、去掉列名、CTE 的列改名之前,先看本段的 WHERE、ON、相关子查询还按名字引用不引用
  这些别名(_alias_refs,SQLite 的 WHERE、ON、相关子查询都认列别名,真实的 SQLite 上试过);qualify 遇到子查询引用
  外层列别名时不走认列那一级。
- USING 的列和列别名同名不改写(ORDER BY 先认别名);派生来源的列同名不走认列那一级(SQLite 认第一个);没有
  GROUP BY、别处也没有聚合的 HAVING TRUE 留着(PostgreSQL 里只出一行)。
- RANDOM() 这类不确定的函数轻量一级也不走;SQLite 双引号里的名字只看这条语句里看得见的表(_column_in_scope);
  子查询的别名遮住外层、又认不准是哪一个的不走规范化(SQLite 里层找不到列就往外层找)。
- MySQL:/*! … */ 会执行、! 比比较结合得紧,不在语法树上判等价;-- 后面不跟空白是两个减号,先拆开再解析;
  BETWEEN 上下界类型不同或不是常量时不改写。
- 函数节点记下原文写的名字(_mark_surfaces):SQLite 的 MOD() 和 %、PostgreSQL 的 LOG10(x) 和 LOG(10, x) 解析成
  同一个节点,记下名字就不会判等价;同义写法按方言文档列在 _SAFE_RESPELLINGS。
- 自己压测找到的老问题:结果看行出来先后的一段(GROUP_CONCAT 等)按 WHERE 把 LEFT JOIN 改成内连接,SQLite 的
  扫描先后真的会变(EXPLAIN QUERY PLAN 一个先扫 refunds、一个先扫 orders),这样的一段不再改写。

认出复验指出的等价改写(每一步都写了为什么保持语义,执行比对守门):
- EXISTS 里的去重、分组、UNION / UNION ALL,IN 里不影响取值集合的 GROUP BY、DISTINCT、UNION;分组后 COUNT(*) >= 1。
- 不分组、只取可空一侧跳过空值的聚合时 LEFT JOIN 写成内连接(COALESCE(SUM(r.amount), 0) 等);ON 之外没用到、
  结果不怕重复的行的 LEFT JOIN 去掉;同一组 AND 里已经保证、或表结构里确定不为空(nullable、主键)的 IS NOT NULL
  去掉;给了表结构、类型一样时按关联条件相等的两列换成同一列(分组、排序、选出来的列)。
- 一模一样的分组键、排序键;SQLite 的负数 LIMIT、PostgreSQL 的 LIMIT NULL、LIMIT 的整数算式。

具体说法:被关联条件定成相等的列、IN / EXISTS 里的去重分组、只取可空一侧的聚合、没用到又不怕重复的 LEFT JOIN、
各支自己已经去重的 UNION ALL、只出一行时的排序、自己关联自己的关联条件,都不再说成具体的不同(只给兜底);
<>、NOT IN、NOT LIKE 照实说「和 X 为空的行」也筛掉了,NOT 套几个条件的说「只保留 NOT (…) 的行」。

normalize_sql:解析时会丢掉写法的(会执行的注释、!、MOD() 这类重新生成换了名字的函数)改按原文逐词规范化,
会执行的注释里的代码算结构;只改字面量的照旧同一个模板(补了测试)。

测试:fixtures/sql_pairs.py 收进第 3 轮的反例(执行的 _DIFF_R3 / _SAME_R3 / _SAME_TYPED_R3 / _SAME_SCHEMA_R3 /
_SAME_HARD_R3,只查结论的 REGRESSIONS_R3 / DIALECT_SAME_R3),schema_cache 带上 nullable 和 primary_key(和探查
出来的一样);专挑写法差异的那组数据加了 'State'。
2026-10-05 14:19:55 +08:00

122 lines
7.7 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 的(已知等价),没认出来时只许给兜底说明,不许给具体说法(具体说法说的是两边确实不同的地方);
- 等价改写(编号不以 hard: 开头的 same)记下认出来的比例:目标是大多数都认得出,但不强求全部。
库和语料见 tests/fixtures/sql_pairs.py:5 组按固定种子生成的数据加一组极端数据、一组专挑写法差异的数据(0.1 + 0.2、
像数字的文本、大小写不同的值),带空值、重复、边界值、已删除的行、退款;语料里有手写的等价改写和语义不同的对,也有
复验者以前造的、在这几组数据上结果不同的对,以及第 2 轮复验报的反例。每一对都在不给表结构和给表结构(数据源的
schema_cache)两种情况下各判一次,两种都要守住上面的规矩。
"""
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 and schema is not None:
assert diff.equivalent, f"{pair.id} 给了表结构(主键不为空)应认得出等价:{[d.text for d in diff.items]}"
# 换个先后问,结论一样
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]
# 不给表结构那一项只算不靠表结构就该认得出的(列的类型、主键不为空这些得看表结构)
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