SQL 差异说明收尾(第 1 轮):不靠约束改写、解析保真守卫、具体说法只在简单形状上说四类

- 去掉所有依赖表约束的改写:凭主键 / 唯一键去掉 LEFT JOIN、去掉 DISTINCT,COUNT(列) 当 COUNT(*),凭 NOT NULL 去掉
  IS NOT NULL(_unique_key_bound、_constraint_rewrites、_not_null_source 删掉)。SQLite 的类型亲和、COLLATE、部分唯一
  索引、MySQL 的隐式转换都会让「唯一」不成立,宁可认不出;约束只拿来让说明少说(schema_cache 的外键也读进来)。
- 解析保真守卫(_misread,按方言切词、按括号深度分层):MySQL / MariaDB / 不知道方言时字符串里的反斜杠转义、1_000 和
  0o17 这类数字、挨着写的字符串常量、同一层里没加括号地混用比较运算和 IS / IN / LIKE / BETWEEN / 另一个比较、按位运算
  (含 COLLATE)和别的二元运算混用、XOR、不知道方言时的 ||、MySQL 以外的 &&:不在语法树上判等价,只给兜底,text_diff
  给原文的逐行对照;normalize_sql 逐词规范化时数字和紧跟着的名字算一个字面量。
- 具体说法重写(_Explainer):只在两边都是简单形状(一段 SELECT、实表、普通连接,没有派生表、合并、窗口函数)时说,
  只说四类——底层表上的时间列不同、一边多一个只涉及一张表的列和常量比的筛选条件(另一边对这些列没有任何条件、不是
  被蕴含的、按取值逐段算过不是恒为真)、顶层聚合函数或去重不同、关联的表或关联类型不同(后面没有内连接引用可空一侧);
  每句先确认真会影响结果(条件恒为假、结果看不见行、参数在组里定死、只取一行、表结构的键 / 外键说明不起作用时不说),
  别的一律兜底。
- 回归语料:第 3 轮补充之后的复验报的全部 blocker / major 收进 sql_pairs(_DIFF_R4、_SAME_HARD_R4、_SAME_SCHEMA_R4
  执行;REGRESSIONS_R4、DIALECT_SAME_R4、CONSTRAINT_TRAPS_R4 只查结论,约束陷阱另造小库执行),执行比对复验者列的 9 个
  改松后拦不住的反例也收进来;旧的具体说法测试按新规矩改写。
- 模块说明照实写:等价怎么判、不靠约束、保真守卫、只给兜底的情况、已知做不到的。

认出等价的比例(给表结构):169 对里 163 → 157(96% → 93%),少的 6 对都是要靠约束才成立的;不给表结构 148 / 156 不变。
This commit is contained in:
ThearchyHelios
2026-10-05 17:35:51 +08:00
parent 3e5fc7bc84
commit 6767b5f2fc
4 changed files with 2009 additions and 1369 deletions
File diff suppressed because it is too large Load Diff
+373 -2
View File
@@ -15,7 +15,9 @@ OR 里的标量子查询改成 CROSS JOIN,都是复验时真踩到的),所
某个方言、某种排序规则下才等价的)。第 2 轮复验的反例在 _SAME_HARD、_SAME_R2、_DIFF_R2(执行)和 REGRESSIONS_R2、
DIALECT_SAME_R2、KNOWN_UNRECOGNIZED(各方言,只查结论)里;第 3 轮的在 _DIFF_R3、_SAME_R3、_SAME_TYPED_R3(要给表结构才认得出,Pair.typed)、_SAME_SCHEMA_R3(要靠
表结构里的主键才看得出等价,Pair.needs_schema)、_SAME_HARD_R3(执行)和 REGRESSIONS_R3、DIALECT_SAME_R3(只查
结论)里。LIMIT 都带能定出唯一顺序的排序,没有 ORDER BY 的 LIMIT 不进语料(EXISTS 里的 LIMIT 1 OFFSET 1 只看有没有
结论)里;第 4 轮的在 _DIFF_R4、_SAME_HARD_R4、_SAME_SCHEMA_R4(执行)和 REGRESSIONS_R4、DIALECT_SAME_R4、
CONSTRAINT_TRAPS_R4(只查结论,靠约束改写的陷阱另造一个小库执行)里。规范化不再靠约束改写,needs_schema 的对一律
不判等价,给了表结构时只许给兜底。LIMIT 都带能定出唯一顺序的排序,没有 ORDER BY 的 LIMIT 不进语料(EXISTS 里的 LIMIT 1 OFFSET 1 只看有没有
第二行,不看先后)。过去复验报过的反例都收进来了:第 5 轮回退的「只在 IN 子查询里用到的 CTE」在 PAIRS 里;复验者留在 /private/tmp 的脚本里、在这几组数据上
结果不同的对是 VALIDATOR_DIFF(也进 PAIRS 执行);旧测试(五轮复核修正按复验报告补的用例,各方言,用到的列这个库里
不全有)里报过不同的对是 REGRESSIONS、意思一样的对是 DIALECT_SAME,这两份不执行,只查判没判等价。
@@ -2651,6 +2653,368 @@ DIALECT_SAME_R3: list[tuple[str, str, str, str]] = [
]
# ---------------------------------------------------------------------------- 第 4 轮复验的反例
# 第 3 轮补充之后的复验(漏报、错报、执行比对三位复验者)报的全部 blocker / major。复验者的借阅库改写成这里的表:
# loans → orders(lid → id,mid → user_id,fee → amount,state → status),fines → refunds(lid → order_id,
# fid → id),members → users(mid → id,inviter_id → referrer_id,level → grade),books → stores,payments 照旧
# (lid → order_id);复验者的 customers / coupons 改写成 users / 下面 CONSTRAINT_TRAPS_R4 里现造的表。只在
# PostgreSQL / MySQL 才有的写法在 REGRESSIONS_R4,要专门的表结构(TEXT 主键、部分唯一索引)才出错的在
# CONSTRAINT_TRAPS_R4。
#: 不许判等价的(SQLite 上执行)。blocker:sqlglot 读错了结合顺序(SQLite 里 IS、IN、LIKE、BETWEEN 和 = 同级、从左往右,
#: < > 比它们紧;sqlglot 一律让 IS / IN 先结合)、数字写法(1_000 是一千,sqlglot 切成 1 和别名 _000);另有两对
#: 真不同、以前却说出了不存在的差异的(多说一句排序、两边都 DISTINCT 却说「一边没去重」)。执行比对复验者在副本上把
#: 判定改松、没有任何测试变红的 9 个反例也收在这里(每一对都不许判等价)。
_DIFF_R4 = [
("r4-eq-then-is-null", "SELECT COUNT(*) FROM orders WHERE status = 'paid' IS NULL",
"SELECT COUNT(*) FROM orders WHERE status = ('paid' IS NULL)"),
("r4-gt-then-is-not-null-in-cte", "WITH k AS (SELECT user_id FROM orders WHERE amount > 50 IS NOT NULL) "
"SELECT COUNT(*) FROM users WHERE id IN (SELECT user_id FROM k)",
"WITH k AS (SELECT user_id FROM orders WHERE amount > (50 IS NOT NULL)) "
"SELECT COUNT(*) FROM users WHERE id IN (SELECT user_id FROM k)"),
("r4-lt-then-in", "SELECT COUNT(*) FROM orders WHERE amount < 100 IN (0, 1)",
"SELECT COUNT(*) FROM orders WHERE amount < (100 IN (0, 1))"),
("r4-eq-then-in", "SELECT COUNT(*) FROM orders WHERE amount = 100 IN (0, 1)",
"SELECT COUNT(*) FROM orders WHERE amount = (100 IN (0, 1))"),
("r4-compared-booleans-then-is-null", "SELECT COUNT(*) FROM orders WHERE (amount > 0) = (amount > 10) IS NULL",
"SELECT COUNT(*) FROM orders WHERE (amount > 0) = ((amount > 10) IS NULL)"),
("r4-digit-separator", "SELECT id, amount * 1_000 FROM orders", "SELECT id, amount * 1_500 FROM orders"),
("r4-digit-separator-vs-one", "SELECT user_id, SUM(amount) * 1_000 FROM orders GROUP BY user_id",
"SELECT user_id, SUM(amount) * 1 FROM orders GROUP BY user_id"),
# 真不同,但以前多说了一句不存在的排序差异(user_id 被 WHERE 定死了)
("r4-limit-differs-order-key-pinned", "SELECT id FROM orders WHERE user_id = 3 ORDER BY user_id, id LIMIT 2",
"SELECT id FROM orders WHERE user_id = 3 ORDER BY id LIMIT 3"),
# 两边都 DISTINCT,真正的不同是第 2 列取不取日期:以前说「A 没有去重」「B 没有」,自相矛盾
("r4-distinct-date-vs-time", "SELECT DISTINCT user_id, date(paid_at) FROM orders",
"SELECT DISTINCT user_id, paid_at FROM orders"),
("r4-distinct-lower-vs-plain", "SELECT DISTINCT lower(status) FROM orders", "SELECT DISTINCT status FROM orders"),
("r4-distinct-date-vs-time-in-derived", "SELECT COUNT(*) FROM (SELECT DISTINCT user_id, date(paid_at) FROM orders) t",
"SELECT COUNT(*) FROM (SELECT DISTINCT user_id, paid_at FROM orders) t"),
# LEFT JOIN 之后的内连接引用的是保留的一侧(o),这次 LEFT JOIN 确实留下了没有退款的订单
("r4-left-then-inner-on-preserved", "SELECT COUNT(*) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id "
"JOIN payments p ON p.order_id = o.id",
"SELECT COUNT(*) FROM orders o JOIN refunds r ON r.order_id = o.id JOIN payments p ON p.order_id = o.id"),
# 执行比对复验者改松判定后没有任何测试变红的反例
("r4-null-blind-with-count-star", "SELECT COUNT(r.amount), COUNT(*) FROM orders o LEFT JOIN refunds r "
"ON r.order_id = o.id",
"SELECT COUNT(r.amount), COUNT(*) FROM orders o JOIN refunds r ON r.order_id = o.id"),
("r4-exists-limit-zero", "SELECT COUNT(*) FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id "
"LIMIT 0)",
"SELECT COUNT(*) FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)"),
("r4-in-group-by-other-key", "SELECT COUNT(*) FROM users WHERE id IN (SELECT r.user_id FROM refunds r "
"GROUP BY r.order_id)",
"SELECT COUNT(*) FROM users WHERE id IN (SELECT r.user_id FROM refunds r)"),
("r4-false-literal-equality", "SELECT COUNT(*) FROM orders WHERE status = 'paid' AND 1 = 0",
"SELECT COUNT(*) FROM orders WHERE status = 'paid'"),
("r4-count-null-literal", "SELECT COUNT(NULL) FROM orders", "SELECT COUNT(*) FROM orders"),
("r4-not-not-value", "SELECT NOT NOT amount FROM orders", "SELECT amount FROM orders"),
("r4-and-of-same-value", "SELECT amount AND amount FROM orders", "SELECT amount FROM orders"),
("r4-left-join-on-key-or-other", "SELECT o.id, o.amount FROM orders o LEFT JOIN users u "
"ON u.id = o.user_id OR u.id = o.referrer_id",
"SELECT o.id, o.amount FROM orders o"),
("r4-equal-columns-of-different-types", "SELECT o.status FROM orders o JOIN users u ON u.id = o.status",
"SELECT u.id FROM orders o JOIN users u ON u.id = o.status"),
]
#: 意思一样、要按语义推理才看得出(规范化不保证认得出)的:认不出时只许给兜底说明。以前都说出了具体的不同——ON 里多余的
#: IS NOT NULL(看行出来先后的一段)、UNION 对 UNION ALL(使用方是 IN、COUNT(DISTINCT)、DISTINCT、MAX)、被 WHERE
#: 定成常量的排序键 / 分组键 / 选出来的列、LEFT JOIN 之后引用可空一侧的内连接、恒为真的 HAVING、FROM 里的子查询有外层
#: 不在乎的不同、条件挪出 CTE、内连接的条件写在 ON 还是 WHERE(排在 LEFT JOIN 后面)、WHERE 里用列别名、TOTAL 和
#: FILTER 只取可空一侧
_SAME_HARD_R4 = [
("r4-left-then-inner-on-nullable", "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"),
("r4-left-then-inner-on-nullable-where", "SELECT COUNT(*) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id "
"JOIN users u ON u.id = r.user_id WHERE o.status = 'open'",
"SELECT COUNT(*) FROM orders o JOIN refunds r ON r.order_id = o.id JOIN users u ON u.id = r.user_id "
"WHERE o.status = 'open'"),
("r4-left-then-inner-on-nullable-sum", "SELECT SUM(o.amount) FROM orders o LEFT JOIN refunds r "
"ON r.order_id = o.id JOIN payments p ON p.order_id = r.order_id",
"SELECT SUM(o.amount) FROM orders o JOIN refunds r ON r.order_id = o.id JOIN payments p ON p.order_id = r.order_id"),
("r4-left-on-redundant-not-null-limit", "SELECT o.id, r.id FROM orders o LEFT JOIN refunds r ON r.order_id = o.id "
"AND r.order_id IS NOT NULL ORDER BY o.id, r.id LIMIT 7",
"SELECT o.id, r.id FROM orders o LEFT JOIN refunds r ON r.order_id = o.id ORDER BY o.id, r.id LIMIT 7"),
("r4-left-on-redundant-not-null-window", "SELECT o.id, r.id, ROW_NUMBER() OVER (PARTITION BY o.id ORDER BY r.id) "
"AS rn FROM orders o LEFT JOIN refunds r ON r.order_id = o.id AND r.order_id IS NOT NULL",
"SELECT o.id, r.id, ROW_NUMBER() OVER (PARTITION BY o.id ORDER BY r.id) AS rn FROM orders o LEFT JOIN refunds r "
"ON r.order_id = o.id"),
("r4-left-on-not-null-implied-by-comparison-limit", "SELECT o.id, r.id FROM orders o LEFT JOIN refunds r "
"ON r.order_id = o.id AND r.amount > o.amount AND r.amount IS NOT NULL ORDER BY o.id, r.id LIMIT 8",
"SELECT o.id, r.id FROM orders o LEFT JOIN refunds r ON r.order_id = o.id AND r.amount > o.amount "
"ORDER BY o.id, r.id LIMIT 8"),
("r4-union-in-cte-used-by-in", "WITH u AS (SELECT user_id FROM orders UNION SELECT user_id FROM refunds) "
"SELECT COUNT(*) FROM users WHERE id IN (SELECT user_id FROM u)",
"WITH u AS (SELECT user_id FROM orders UNION ALL SELECT user_id FROM refunds) "
"SELECT COUNT(*) FROM users WHERE id IN (SELECT user_id FROM u)"),
("r4-union-in-derived-count-distinct", "SELECT COUNT(DISTINCT user_id) FROM (SELECT user_id FROM orders UNION "
"SELECT user_id FROM refunds) t",
"SELECT COUNT(DISTINCT user_id) FROM (SELECT user_id FROM orders UNION ALL SELECT user_id FROM refunds) t"),
("r4-union-in-derived-distinct", "SELECT DISTINCT t.user_id FROM (SELECT user_id FROM orders UNION "
"SELECT user_id FROM refunds) t",
"SELECT DISTINCT t.user_id FROM (SELECT user_id FROM orders UNION ALL SELECT user_id FROM refunds) t"),
("r4-union-in-derived-max", "SELECT MAX(t.amount) FROM (SELECT amount FROM orders UNION SELECT amount FROM refunds) t",
"SELECT MAX(t.amount) FROM (SELECT amount FROM orders UNION ALL SELECT amount FROM refunds) t"),
("r4-union-in-cte-count-distinct", "WITH t AS (SELECT user_id FROM orders UNION SELECT user_id FROM refunds) "
"SELECT COUNT(DISTINCT user_id) FROM t",
"WITH t AS (SELECT user_id FROM orders UNION ALL SELECT user_id FROM refunds) SELECT COUNT(DISTINCT user_id) FROM t"),
("r4-union-in-cte-used-by-two-ins", "WITH u AS (SELECT user_id FROM orders UNION SELECT user_id FROM refunds) "
"SELECT COUNT(*) FROM users WHERE id IN (SELECT user_id FROM u) OR referrer_id IN (SELECT user_id FROM u)",
"WITH u AS (SELECT user_id FROM orders UNION ALL SELECT user_id FROM refunds) "
"SELECT COUNT(*) FROM users WHERE id IN (SELECT user_id FROM u) OR referrer_id IN (SELECT user_id FROM u)"),
("r4-union-in-cte-used-by-two-exists", "WITH u AS (SELECT user_id FROM orders UNION SELECT user_id FROM refunds) "
"SELECT COUNT(*) FROM users s WHERE EXISTS (SELECT 1 FROM u WHERE u.user_id = s.id) "
"OR EXISTS (SELECT 1 FROM u WHERE u.user_id = s.referrer_id)",
"WITH u AS (SELECT user_id FROM orders UNION ALL SELECT user_id FROM refunds) "
"SELECT COUNT(*) FROM users s WHERE EXISTS (SELECT 1 FROM u WHERE u.user_id = s.id) "
"OR EXISTS (SELECT 1 FROM u WHERE u.user_id = s.referrer_id)"),
("r4-order-key-pinned-limit", "SELECT id, amount FROM orders WHERE status = 'paid' ORDER BY status, id LIMIT 5",
"SELECT id, amount FROM orders WHERE status = 'paid' ORDER BY id LIMIT 5"),
("r4-order-key-pinned-by-number-limit", "SELECT id, amount FROM orders WHERE user_id = 3 ORDER BY user_id, id LIMIT 5",
"SELECT id, amount FROM orders WHERE user_id = 3 ORDER BY id LIMIT 5"),
("r4-order-key-pinned-by-in-limit", "SELECT id, amount FROM orders WHERE user_id IN (3) ORDER BY user_id, id LIMIT 5",
"SELECT id, amount FROM orders WHERE user_id IN (3) ORDER BY id LIMIT 5"),
("r4-order-key-pinned", "SELECT id, amount FROM orders WHERE user_id = 3 ORDER BY user_id DESC, id",
"SELECT id, amount FROM orders WHERE user_id = 3 ORDER BY id"),
("r4-group-key-pinned", "SELECT status, COUNT(*) FROM orders WHERE user_id = 3 GROUP BY user_id, status",
"SELECT status, COUNT(*) FROM orders WHERE user_id = 3 GROUP BY status"),
("r4-group-keys-pinned", "SELECT status, COUNT(*) FROM orders WHERE user_id = 2 AND is_deleted = 0 "
"GROUP BY user_id, status, is_deleted",
"SELECT status, COUNT(*) FROM orders WHERE user_id = 2 AND is_deleted = 0 GROUP BY status"),
("r4-order-key-pinned-through-join", "SELECT o.id, r.id FROM orders o JOIN refunds r ON r.order_id = o.id "
"WHERE o.id = 4 ORDER BY r.order_id, r.id",
"SELECT o.id, r.id FROM orders o JOIN refunds r ON r.order_id = o.id WHERE o.id = 4 ORDER BY r.id"),
("r4-having-count-not-zero", "SELECT user_id FROM orders GROUP BY user_id HAVING COUNT(*) <> 0",
"SELECT user_id FROM orders GROUP BY user_id"),
("r4-having-not-count-zero", "SELECT user_id FROM orders GROUP BY user_id HAVING NOT COUNT(*) = 0",
"SELECT user_id FROM orders GROUP BY user_id"),
("r4-having-sum-one", "SELECT user_id FROM orders GROUP BY user_id HAVING SUM(1) > 0",
"SELECT user_id FROM orders GROUP BY user_id"),
("r4-having-count-not-zero-with-sum", "SELECT user_id, SUM(amount) FROM orders GROUP BY user_id HAVING COUNT(*) <> 0",
"SELECT user_id, SUM(amount) FROM orders GROUP BY user_id"),
("r4-exists-group-having-not-zero", "SELECT COUNT(*) FROM orders o WHERE EXISTS (SELECT 1 FROM refunds r "
"WHERE r.order_id = o.id GROUP BY r.order_id HAVING COUNT(*) <> 0)",
"SELECT COUNT(*) FROM orders o WHERE EXISTS (SELECT 1 FROM refunds r WHERE r.order_id = o.id)"),
("r4-in-group-having-not-zero", "SELECT COUNT(*) FROM users WHERE id IN (SELECT user_id FROM refunds "
"GROUP BY user_id HAVING COUNT(*) <> 0)",
"SELECT COUNT(*) FROM users WHERE id IN (SELECT user_id FROM refunds)"),
("r4-outputs-pinned-to-same-constant", "SELECT o.id, o.user_id FROM orders o JOIN refunds r ON r.order_id = o.id "
"WHERE o.user_id = 3 AND r.user_id = 3",
"SELECT o.id, r.user_id FROM orders o JOIN refunds r ON r.order_id = o.id WHERE o.user_id = 3 AND r.user_id = 3"),
("r4-max-of-columns-pinned-to-same-constant", "SELECT MAX(o.user_id) FROM orders o JOIN refunds r "
"ON r.order_id = o.id WHERE o.user_id = 3 AND r.user_id = 3",
"SELECT MAX(r.user_id) FROM orders o JOIN refunds r ON r.order_id = o.id WHERE o.user_id = 3 AND r.user_id = 3"),
("r4-derived-limit-under-max", "SELECT MAX(t.amount) FROM (SELECT amount FROM orders ORDER BY amount DESC, id "
"LIMIT 3) t",
"SELECT MAX(t.amount) FROM (SELECT amount FROM orders ORDER BY amount DESC, id LIMIT 5) t"),
("r4-derived-group-under-count-distinct", "SELECT COUNT(DISTINCT t.user_id) FROM (SELECT user_id, SUM(amount) AS s "
"FROM orders GROUP BY user_id) t",
"SELECT COUNT(DISTINCT t.user_id) FROM (SELECT user_id, SUM(amount) AS s FROM orders GROUP BY user_id, status) t"),
("r4-filter-moved-out-of-cte", "WITH t AS (SELECT user_id, COUNT(*) AS n FROM orders WHERE status = 'paid' "
"GROUP BY user_id) SELECT COUNT(*) FROM t WHERE n >= 2",
"WITH t AS (SELECT user_id, status, COUNT(*) AS n FROM orders GROUP BY user_id, status) "
"SELECT COUNT(*) FROM t WHERE n >= 2 AND status = 'paid'"),
("r4-inner-on-vs-where-after-left", "SELECT COUNT(*) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id "
"JOIN stores s ON s.id = o.store_id AND s.city = 'SH'",
"SELECT COUNT(*) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id JOIN stores s ON s.id = o.store_id "
"WHERE s.city = 'SH'"),
("r4-inner-on-vs-where-after-rejected-left", "SELECT COUNT(*) FROM orders o LEFT JOIN refunds r "
"ON r.order_id = o.id JOIN stores s ON s.id = o.store_id AND s.region = 'east' WHERE r.amount > 0",
"SELECT COUNT(*) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id JOIN stores s ON s.id = o.store_id "
"WHERE r.amount > 0 AND s.region = 'east'"),
("r4-preserved-side-condition-in-later-on", "SELECT COUNT(*) FROM orders o LEFT JOIN refunds r "
"ON r.order_id = o.id JOIN users u ON u.id = o.user_id AND o.status = 'paid'",
"SELECT COUNT(*) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id JOIN users u ON u.id = o.user_id "
"WHERE o.status = 'paid'"),
("r4-where-uses-time-alias", "SELECT id, date(paid_at) AS d FROM orders WHERE d >= '2026-09-01' AND d < '2026-10-01'",
"SELECT id, date(paid_at) AS d FROM orders WHERE date(paid_at) >= '2026-09-01' AND date(paid_at) < '2026-10-01'"),
("r4-where-uses-computed-alias", "SELECT id, amount - discount AS net FROM orders WHERE net > 50",
"SELECT id, amount - discount AS net FROM orders WHERE amount - discount > 50"),
("r4-double-quoted-column-case", "SELECT COUNT(*) FROM orders WHERE \"STATUS\" = 'paid'",
"SELECT COUNT(*) FROM orders WHERE status = 'paid'"),
("r4-left-total-vs-inner", "SELECT TOTAL(r.amount) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id",
"SELECT TOTAL(r.amount) FROM orders o JOIN refunds r ON r.order_id = o.id"),
("r4-left-count-filter-vs-inner", "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"),
]
#: 意思一样、要靠表结构里的约束(主键唯一、不为空)才看得出的。约束不可靠(SQLite 的类型亲和、COLLATE、部分唯一
#: 索引、MySQL 的隐式转换都能让「唯一」不成立),规范化不再靠约束改写,这些一律不判等价;给了表结构时也不许说出约束
#: 已经排除了的不同(只给兜底),没给表结构时说得出具体的不同不算错(那时确实不知道有约束)
_SAME_SCHEMA_R4 = [
("r4-not-null-primary-key-limit", "SELECT id, amount FROM orders WHERE id IS NOT NULL ORDER BY id LIMIT 3",
"SELECT id, amount FROM orders ORDER BY id LIMIT 3"),
("r4-count-primary-key-limit", "SELECT user_id, COUNT(id) AS n FROM orders GROUP BY user_id ORDER BY n DESC, user_id "
"LIMIT 3",
"SELECT user_id, COUNT(*) AS n FROM orders GROUP BY user_id ORDER BY n DESC, user_id LIMIT 3"),
("r4-count-primary-key-window", "SELECT user_id, COUNT(id), RANK() OVER (ORDER BY user_id) FROM orders "
"GROUP BY user_id",
"SELECT user_id, COUNT(*), RANK() OVER (ORDER BY user_id) FROM orders GROUP BY user_id"),
("r4-distinct-with-key-through-key-join", "SELECT DISTINCT o.id, s.name FROM orders o JOIN stores s "
"ON s.id = o.store_id",
"SELECT o.id, s.name FROM orders o JOIN stores s ON s.id = o.store_id"),
("r4-distinct-with-key-through-left-key-join", "SELECT DISTINCT o.id, u.name FROM orders o LEFT JOIN users u "
"ON u.id = o.user_id",
"SELECT o.id, u.name FROM orders o LEFT JOIN users u ON u.id = o.user_id"),
("r4-count-distinct-primary-key", "SELECT COUNT(DISTINCT id) FROM orders", "SELECT COUNT(*) FROM orders"),
("r4-count-distinct-primary-key-vs-count", "SELECT COUNT(DISTINCT id) FROM orders", "SELECT COUNT(id) FROM orders"),
("r4-order-after-primary-key-limit", "SELECT id, amount FROM orders ORDER BY id, amount LIMIT 5",
"SELECT id, amount FROM orders ORDER BY id LIMIT 5"),
("r4-group-by-key-and-dependent", "SELECT u.id, u.name, COUNT(*) FROM users u JOIN orders o ON o.user_id = u.id "
"GROUP BY u.id, u.name",
"SELECT u.id, u.name, COUNT(*) FROM users u JOIN orders o ON o.user_id = u.id GROUP BY u.id"),
("r4-union-of-disjoint-keys", "SELECT id FROM users WHERE grade = 1 UNION SELECT id FROM users WHERE grade = 2",
"SELECT id FROM users WHERE grade = 1 UNION ALL SELECT id FROM users WHERE grade = 2"),
("r4-left-join-count-vs-scalar-count", "SELECT o.id, COUNT(r.id) FROM orders o LEFT JOIN refunds r "
"ON r.order_id = o.id GROUP BY o.id",
"SELECT o.id, (SELECT COUNT(*) FROM refunds r WHERE r.order_id = o.id) FROM orders o"),
]
#: 只在 PostgreSQL / MySQL 才有的写法(不执行,只查不判等价、只给兜底):复验者在真实的 PostgreSQL 18.6、MySQL 8.0.46
#: 上跑出结果不同。MySQL 字符串里的 \Z 是 Ctrl+Z、\0 是 NUL,sqlglot 原样留着反斜杠,和写成 \\Z 的撞成同一个值;
#: PostgreSQL 的 IS 比 = 结合得松,0o17 是八进制的 15、1_000 是一千;MySQL 的比较运算同级从左往右,& 比 | 紧、<< 比
#: & 紧、^ 比 * 紧,AND 比 XOR 紧
REGRESSIONS_R4: list[tuple[str, str, str, str]] = [
("mysql_backslash_z", "mysql", "SELECT COUNT(*) FROM orders WHERE status = 'pa\\Zid'",
"SELECT COUNT(*) FROM orders WHERE status = 'pa\\\\Zid'"),
("mysql_backslash_z_in_cte_used_by_in", "mysql",
"WITH p AS (SELECT user_id FROM orders WHERE status <> 'x\\Z') SELECT COUNT(*) FROM users "
"WHERE id IN (SELECT user_id FROM p)",
"WITH p AS (SELECT user_id FROM orders WHERE status <> 'x\\\\Z') SELECT COUNT(*) FROM users "
"WHERE id IN (SELECT user_id FROM p)"),
("mysql_backslash_zero", "mysql", "SELECT COUNT(*) FROM orders WHERE status IN ('a\\0', 'paid')",
"SELECT COUNT(*) FROM orders WHERE status IN ('a\\\\0', 'paid')"),
("mariadb_backslash_z", "mariadb", "SELECT COUNT(*) FROM orders WHERE status = 'pa\\Zid'",
"SELECT COUNT(*) FROM orders WHERE status = 'pa\\\\Zid'"),
("unknown_dialect_backslash_z", "", "SELECT COUNT(*) FROM orders WHERE status = 'pa\\Zid'",
"SELECT COUNT(*) FROM orders WHERE status = 'pa\\\\Zid'"),
("pg_compared_booleans_then_is_null", "postgres",
"SELECT COUNT(*) FROM orders WHERE (amount > 0) = (amount > 10) IS NULL",
"SELECT COUNT(*) FROM orders WHERE (amount > 0) = ((amount > 10) IS NULL)"),
("pg_is_distinct_from_then_eq", "postgres", "SELECT COUNT(*) FROM orders WHERE status IS DISTINCT FROM region = 'x'",
"SELECT COUNT(*) FROM orders WHERE (status IS DISTINCT FROM region) = 'x'"),
("mysql_comparisons_left_to_right", "mysql", "SELECT COUNT(*) FROM orders WHERE amount = 100 < 200",
"SELECT COUNT(*) FROM orders WHERE amount = (100 < 200)"),
("mysql_and_binds_tighter_than_or_bits", "mysql", "SELECT id, 4 | id & 2 FROM orders",
"SELECT id, (4 | id) & 2 FROM orders"),
("mysql_caret_binds_tighter_than_times", "mysql", "SELECT id, 2 ^ id * 3 FROM orders",
"SELECT id, 2 ^ (id * 3) FROM orders"),
("mysql_shift_binds_tighter_than_and_bits", "mysql", "SELECT id, 3 & id << 2 FROM orders",
"SELECT id, (3 & id) << 2 FROM orders"),
("mysql_and_binds_tighter_than_xor", "mysql",
"SELECT COUNT(*) FROM orders WHERE amount > 0 XOR amount > 10 AND amount > 50",
"SELECT COUNT(*) FROM orders WHERE (amount > 0 XOR amount > 10) AND amount > 50"),
("mysql_eq_then_is_null", "mysql", "SELECT COUNT(*) FROM orders WHERE status = 'paid' IS NULL",
"SELECT COUNT(*) FROM orders WHERE status = ('paid' IS NULL)"),
("pg_digit_separator", "postgres", "SELECT user_id, SUM(amount) * 1_000 FROM orders GROUP BY user_id",
"SELECT user_id, SUM(amount) * 1 FROM orders GROUP BY user_id"),
("pg_octal_literal", "postgres", "SELECT id, amount + 0o17 FROM orders", "SELECT id, amount + 0 FROM orders"),
("pg_tilde_binds_looser_than_plus", "postgres", "SELECT id, ~ id + 1 FROM orders", "SELECT id, (~ id) + 1 FROM orders"),
]
#: 第 4 轮:意思一样、只在 PostgreSQL / MySQL 才报的(不执行):认不出时只许给兜底说明。每一项 (编号, 方言, A, B, 要不要
#: 表结构):给了表结构(主键)时 COUNT(DISTINCT id) 和 COUNT(id) 一样,不许说「A 对 id 去重计数,B 没去重」;没给表结构
#: 时这么说不算错(那时不知道 id 不重复)
DIALECT_SAME_R4: list[tuple[str, str, str, str, bool]] = [
("count_distinct_primary_key_vs_count", "postgres", "SELECT COUNT(DISTINCT id) FROM orders",
"SELECT COUNT(id) FROM orders", True),
("left_on_implied_not_null_group_concat", "mysql",
"SELECT o.id, GROUP_CONCAT(r.state ORDER BY r.id) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id "
"AND r.amount > o.amount AND r.amount IS NOT NULL GROUP BY o.id",
"SELECT o.id, GROUP_CONCAT(r.state ORDER BY r.id) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id "
"AND r.amount > o.amount GROUP BY o.id", False),
]
#: 第 4 轮:靠约束改写的陷阱(要专门的表结构)。唯一键并不保证「最多关联一行」:SQLite 里 TEXT 主键和 INTEGER 列比较
#: 时按数值比('1' 和 '01' 都等于 1)、比较写了 COLLATE NOCASE('paid' 和 'Paid' 都对得上)、部分唯一索引(WHERE
#: deleted_at IS NULL,探查结果照样记成了 unique);MySQL 拿字符串键和数值比时都转成数值。下面现造一个小库(SQLite 的
#: 几对在上面执行,结果确实不同)和对应的 schema_cache(coupon_schema);每一项 (编号, 方言, A, B)
_COUPON_ROWS = {
"coupons": [("1",), ("01",), ("2",), ("paid",), ("Paid",)],
"shop_orders": [(1, 1, "paid", 100.0, 1), (2, 2, "Paid", 250.0, 1), (3, None, "open", 50.0, 2),
(4, 1, "void", 200.0, 3)],
"customers": [(1,), (2,), (3,)],
"coupons2": [("SPRING", None), ("SPRING", "2026-01-01"), ("FALL", None)],
"orders2": [(1, "SPRING", 10.0), (2, "FALL", 20.0), (3, None, 30.0)],
"coupons3": [("SPRING", None), ("SPRING", "2026-01-01"), ("FALL", None)],
}
_COUPON_DDL = {
"coupons": "code TEXT PRIMARY KEY",
"shop_orders": "id INTEGER PRIMARY KEY, coupon INTEGER, status TEXT, amount REAL, customer_id INTEGER",
"customers": "id INTEGER PRIMARY KEY",
"coupons2": "code TEXT, deleted_at TEXT",
"orders2": "id INTEGER PRIMARY KEY, coupon_code TEXT, amount REAL",
"coupons3": "code TEXT NOT NULL, deleted_at TEXT",
}
_COUPON_INDEXES = ["CREATE UNIQUE INDEX ux_coupons2 ON coupons2(code) WHERE deleted_at IS NULL",
"CREATE UNIQUE INDEX ux_coupons3 ON coupons3(code) WHERE deleted_at IS NULL"]
def _coupon_schema(*, mysql: bool = False) -> dict:
"""上面几张表的 schema_cache(和 data/introspect.py 探查出来的形状一样:部分唯一索引也记进了 unique)。"""
text = "VARCHAR(20)" if mysql else "TEXT"
integer = "INT" if mysql else "INTEGER"
return {"tables": {
"coupons": {"columns": [{"name": "code", "type": text, "nullable": False}], "primary_key": ["code"]},
"shop_orders": {"columns": [{"name": "id", "type": integer, "nullable": False},
{"name": "coupon", "type": integer, "nullable": True},
{"name": "status", "type": text, "nullable": True},
{"name": "amount", "type": "REAL", "nullable": True},
{"name": "customer_id", "type": integer, "nullable": True}],
"primary_key": ["id"]},
"customers": {"columns": [{"name": "id", "type": integer, "nullable": False}], "primary_key": ["id"]},
"coupons2": {"columns": [{"name": "code", "type": text, "nullable": True},
{"name": "deleted_at", "type": text, "nullable": True}],
"primary_key": [], "unique": [["code"]]},
"orders2": {"columns": [{"name": "id", "type": integer, "nullable": False},
{"name": "coupon_code", "type": text, "nullable": True},
{"name": "amount", "type": "REAL", "nullable": True}], "primary_key": ["id"]},
"coupons3": {"columns": [{"name": "code", "type": text, "nullable": False},
{"name": "deleted_at", "type": text, "nullable": True}],
"primary_key": [], "unique": [["code"]]},
}}
CONSTRAINT_TRAPS_R4: list[tuple[str, str, str, str]] = [
("text_key_vs_integer_column", "sqlite",
"SELECT COUNT(*) FROM shop_orders o LEFT JOIN coupons c ON c.code = o.coupon",
"SELECT COUNT(*) FROM shop_orders o"),
("text_key_vs_integer_column_in_scalar_subquery", "sqlite",
"SELECT c.id, (SELECT SUM(o.amount) FROM shop_orders o LEFT JOIN coupons cp ON cp.code = o.coupon "
"WHERE o.customer_id = c.id) FROM customers c",
"SELECT c.id, (SELECT SUM(o.amount) FROM shop_orders o WHERE o.customer_id = c.id) FROM customers c"),
("key_compared_with_nocase", "sqlite",
"SELECT COUNT(*) FROM shop_orders o LEFT JOIN coupons c ON c.code = o.status COLLATE NOCASE",
"SELECT COUNT(*) FROM shop_orders o"),
("partial_unique_index_left_join", "sqlite",
"SELECT COUNT(*) FROM orders2 o LEFT JOIN coupons2 c ON c.code = o.coupon_code", "SELECT COUNT(*) FROM orders2 o"),
("partial_unique_index_left_join_sum", "sqlite",
"SELECT SUM(o.amount) FROM orders2 o LEFT JOIN coupons2 c ON c.code = o.coupon_code",
"SELECT SUM(o.amount) FROM orders2 o"),
("partial_unique_index_distinct", "sqlite", "SELECT DISTINCT code FROM coupons3", "SELECT code FROM coupons3"),
("mysql_string_key_vs_number", "mysql",
"SELECT COUNT(*) FROM shop_orders o LEFT JOIN coupons c ON c.code = o.coupon", "SELECT COUNT(*) FROM shop_orders o"),
]
def coupon_schema(dialect: str) -> dict:
return _coupon_schema(mysql=dialect == "mysql")
def build_coupon_database() -> sqlite3.Connection:
"""CONSTRAINT_TRAPS_R4 的小库(SQLite,内存):TEXT 主键里同时有 '1' 和 '01'、'paid' 和 'Paid',部分唯一索引下同一个
code 有一行已删、一行未删。"""
conn = sqlite3.connect(":memory:")
for name, ddl in _COUPON_DDL.items():
conn.execute(f"CREATE TABLE {name} ({ddl})")
rows = _COUPON_ROWS[name]
conn.executemany(f"INSERT INTO {name} VALUES ({', '.join('?' * len(rows[0]))})", rows)
for ddl in _COUPON_INDEXES:
conn.execute(ddl)
return conn
PAIRS: list[Pair] = [Pair(f"same:{i}", a, b, "same") for i, a, b in _SAME] + \
[Pair(f"diff:{i}", a, b, "diff") for i, a, b in _DIFF] + \
[Pair(f"validator:{n}:{k}", a, b, "diff") for k, (n, a, b) in enumerate(VALIDATOR_DIFF)] + \
@@ -2661,7 +3025,14 @@ PAIRS: list[Pair] = [Pair(f"same:{i}", a, b, "same") for i, a, b in _SAME] + \
[Pair(f"same:{i}", a, b, "same") for i, a, b in _SAME_R3] + \
[Pair(f"same:{i}", a, b, "same", typed=True) for i, a, b in _SAME_TYPED_R3] + \
[Pair(f"same:{i}", a, b, "same", needs_schema=True) for i, a, b in _SAME_SCHEMA_R3] + \
[Pair(f"hard:{i}", a, b, "same") for i, a, b in _SAME_HARD_R3]
[Pair(f"hard:{i}", a, b, "same") for i, a, b in _SAME_HARD_R3] + \
[Pair(f"diff:{i}", a, b, "diff") for i, a, b in _DIFF_R4] + \
[Pair(f"hard:{i}", a, b, "same") for i, a, b in _SAME_HARD_R4] + \
[Pair(f"hard:{i}", a, b, "same", needs_schema=True) for i, a, b in _SAME_SCHEMA_R4]
#: 第 4 轮复验报的、在 SQLite 上执行的那几对的编号(测试里按类别引用)
ROUND_FOUR_DIFF = tuple(f"diff:{i}" for i, _, _ in _DIFF_R4)
ROUND_FOUR_SAME = tuple(f"hard:{i}" for i, _, _ in _SAME_HARD_R4 + _SAME_SCHEMA_R4)
#: 按编号找语料里的一对(测试里引用第 2 轮的反例用)
PAIRS_BY_ID: dict[str, Pair] = {p.id: p for p in PAIRS}
+113 -4
View File
@@ -6,13 +6,20 @@ 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 的读法
各判一遍,意思一样的只许给兜底。
"""
from __future__ import annotations
@@ -64,8 +71,9 @@ def test_equivalence_is_guarded_by_execution(databases, pair):
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]}"
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
@@ -75,6 +83,11 @@ 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]
# 要靠约束才看得出的(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]
@@ -119,3 +132,99 @@ 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}")
+275 -121
View File
@@ -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 的行和 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,46 +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)
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。"""
# 用 COUNT:PostgreSQL 里对不知道是不是精确数的值求和,结果看行出来的先后(见 _Sensitivity),这样的一段不按 WHERE
# 把外连接改成内连接(SQLite 上降成内连接之后扫描的先后真的变了),说不出这一句
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'"
assert texts(a, b, "postgres") == [
"A 用 LEFT JOIN 关联 refunds 表,B 写的是 LEFT JOIN,但 WHERE 里的条件把关联不上 orders 的行筛掉了,实际是 INNER JOIN"]
diff = explain_sql_diff(a, b, "postgres")
check_shape(diff)
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"
@@ -731,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
@@ -758,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():
@@ -982,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():
@@ -1016,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():
@@ -1269,17 +1262,15 @@ def test_only_literals_changed_is_still_the_same_template_in_round_three(dialect
assert na.same_shape(nb) and na.literals != nb.literals
def test_a_join_on_a_primary_key_against_an_in_subquery_is_not_described_with_the_schema():
"""按主键关联 users(每张订单最多关联上一个用户)和 user_id IN (SELECT id FROM users) 是一回事:给了表结构(知道
users.id 是主键)就不说「A 关联了 users 表,B 没有关联它」;没给表结构时关联可能把行复制,这句话是对的。"""
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)"
diff = explain_sql_diff(a, b, "sqlite", schema=sql_pairs.schema_cache())
check_shape(diff)
assert all(d.aspect == "other" for d in diff.items), diff.items
diff = explain_sql_diff(a, b, "sqlite")
check_shape(diff)
assert diff.fallback and diff.items[0].text.startswith("A 关联了 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)
@@ -1299,3 +1290,166 @@ def test_a_join_rewritten_on_one_side_only_is_not_described_as_missing(dialect,
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"),
], 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)