mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:41:58 +00:00
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:
+1248
-1242
File diff suppressed because it is too large
Load Diff
Vendored
+373
-2
@@ -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}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user