mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 10:41:59 +00:00
08f75d4002
为什么:第 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'。
122 lines
7.7 KiB
Python
122 lines
7.7 KiB
Python
"""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
|