mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:21:58 +00:00
f16860f704
为什么:第 2 轮复验抓到第 1 版规范化里一批不保持语义的改写(15 个 blocker),说明里一批假话、矛盾话(10 多个 major)。根子是借用了 sqlglot simplify 等现成改写、又把「相等」当成「一样」、没管结果看行出来先后的查询;说明这边 是先改名再比、只看这一段有没有那个条件。这一轮把改写收窄到每一步都讲得清为什么保持语义,说明这边每句话都先验证。 规范化(backend/app/data/sql_semantics.py): - 去掉 sqlglot 的 simplify(Decimal 折叠 0.1 + 0.2、-(-x) 化成 x、三项加乘拍平合常量、BETWEEN SYMMETRIC 丢了 SYMMETRIC)、pushdown_projections、HAVING 挪进 WHERE(MySQL 不分大小写时同组的值各不一样)、只有一列的 IN 列表 写成 =(SQLite 里 a IN (b) 是 a = +b)。只留自己写的几步:AND / OR、比较两边、一个节点的加乘两边排序,NOT 套 比较翻过来,BETWEEN 拆开,IN 常量列表去重排序,COUNT(1)、MIN/MAX(DISTINCT)、IIF / IF 写成 CASE、CASE ELSE NULL、 恒真的 1 = 1 去掉,IS NOT NULL / NOT LIKE 各方言统一成 NOT (…)。 - 新增 _Sensitivity:取行(含有 ORDER BY 并列)、DISTINCT ON、窗口函数、GROUP_CONCAT 这类聚合、聚合里的散列、当成标量 的子查询,以及去重、分组、MIN/MAX 留下的值「相等却不一样」(MySQL 字符串、PG numeric、算出来的表达式)的一段, 连同给它供行的子查询、CTE,都不调换来源、不排 GROUP BY 键、不改 DISTINCT、不并子查询。SQLite 的逗号连接和 CROSS JOIN 按原文分开(SQLite 不调换 CROSS JOIN 两边)。 - 列名表(CTE、子查询)写进里面那段的别名,实表别名带列名、递归 CTE(含 SQLite 不写 RECURSIVE、CTE 引用自己或后面的)、 BETWEEN SYMMETRIC、FETCH WITH TIES 不走规范化;FETCH FIRST 写成 LIMIT;USING 只在简单写法下写成 ON。 - EXISTS 改写前先查聚合(含 ORDER BY 里的)和 OFFSET;合并只调换最外层不取行的 UNION ALL(PG/MySQL 另加按位置排序、 各支列名一样的嵌套);后面有 RIGHT / FULL JOIN 时内连接不挪进 WHERE、开头的来源不调换。 - 子查询并进外层自己写(_merge_derived:只并内连接一侧、只筛行选列的),引用多次的简单 CTE 每处写回一份后整条语句 的别名重新改成互不相同的(压测撞到的:同一个 CTE 并进外层和相关子查询两处,里层别名遮住外层);外层 WHERE 只挪进 分组子查询的 HAVING(同一个值)。几个来源的 SELECT * 先写成各来源的 t.*(sqlglot 展开 * 时把实表排在子查询前面)。 - 解析丢了写法的不在语法树上判等价:0x10 和 x'10' 这类字面量、换了名字的类型名(SQLite 的 STRING 是数值亲和); SQLite 不带表名、带双引号、表结构里没有的列名保留大小写和引号(找不到这一列时被当成字符串)。 差异说明: - 换了表的一边照它自己的表说(以前把 B 改名成 A 的表,中文名、detail 归错表)。 - 新增 _Range:同一个对象只和常量比的条件按取值逐段算,留下的行一样的不说(IN 和 OR、冗余 IS NOT NULL、拆成两段的 时间、COUNT(*) > 1 和 >= 2);关联条件比相等关系的分组;聚合只在参数是列时说。 - 新增 _Usage:说「另一边没有这个条件 / 没有按时间筛选」之前,看另一边整条语句(含相关子查询、CTE、CASE、判空时连 聚合参数)是否用这一列筛选;条件子查询里不带表名的列没给表结构时不拿来说具体的。 - 子查询要外面写法一模一样(含 NOT、IN 左边的列)才配对,位置照实说「id NOT IN 的子查询」;EXCEPT 按先后逐支比; order 只在最外层、没取行、没 DISTINCT ON / 窗口函数时说;LEFT 对 INNER 只在最外层、可空一侧没进 WHERE、结果不只是 可空一侧的聚合时说;DISTINCT 对同列 GROUP BY、MySQL 表名只差大小写不说成不同;互相矛盾的一对不说。 测试:tests/fixtures/sql_pairs.py 加 nums 表和一组专挑写法差异的数据(0.1 + 0.2、像数字的文本、'SH' / 'sh'), 第 2 轮反例收进语料(_SAME_HARD 20 对、_SAME_R2 11 对、_DIFF_R2 36 对进执行比对;REGRESSIONS_R2 21 对、 DIALECT_SAME_R2 6 对、KNOWN_UNRECOGNIZED 4 对各方言只查结论);执行比对共 7 组数据、372 对,标 same 而没认出的 不许有具体说法;test_sql_semantics.py 补第 2 轮的说法用例和 normalize_sql「只改参数」的用例。这些新用例在上一版 上有 109 处失败。 验证:test_sql_semantics + test_verify_agent + test_sql_equivalence 973 passed;全量 -n 8:7853 passed、11 skipped。 等价改写 124 对认出 120 对(97%),不给表结构 117 对(94%)。复验者的脚本:vsprobe 变异压测 6 个种子约 4.3 万对无 误判等价;在私起的 PostgreSQL 18、MySQL 8.0 上重跑 vsadv 全部语料无 blocker;advfp 的 180 对等价对上具体说法 0 条; 自己的压测 3 个种子各约 1.5 万条可执行,规范形式相同的组执行结果全一致。
104 lines
6.1 KiB
Python
104 lines
6.1 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:
|
||
specific = [d.text for d in diff.items if d.aspect != "other"]
|
||
assert not specific, f"{pair.id} 意思一样,却说了具体的不同(schema={bool(schema)}):{specific}"
|
||
# 换个先后问,结论一样
|
||
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]
|
||
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%})")
|
||
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_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
|