mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:41:58 +00:00
f16860f704
为什么:第 2 轮复验抓到第 1 版规范化里一批不保持语义的改写(15 个 blocker),说明里一批假话、矛盾话(10 多个 major)。根子是借用了 sqlglot simplify 等现成改写、又把「相等」当成「一样」、没管结果看行出来先后的查询;说明这边 是先改名再比、只看这一段有没有那个条件。这一轮把改写收窄到每一步都讲得清为什么保持语义,说明这边每句话都先验证。 规范化(backend/app/data/sql_semantics.py): - 去掉 sqlglot 的 simplify(Decimal 折叠 0.1 + 0.2、-(-x) 化成 x、三项加乘拍平合常量、BETWEEN SYMMETRIC 丢了 SYMMETRIC)、pushdown_projections、HAVING 挪进 WHERE(MySQL 不分大小写时同组的值各不一样)、只有一列的 IN 列表 写成 =(SQLite 里 a IN (b) 是 a = +b)。只留自己写的几步:AND / OR、比较两边、一个节点的加乘两边排序,NOT 套 比较翻过来,BETWEEN 拆开,IN 常量列表去重排序,COUNT(1)、MIN/MAX(DISTINCT)、IIF / IF 写成 CASE、CASE ELSE NULL、 恒真的 1 = 1 去掉,IS NOT NULL / NOT LIKE 各方言统一成 NOT (…)。 - 新增 _Sensitivity:取行(含有 ORDER BY 并列)、DISTINCT ON、窗口函数、GROUP_CONCAT 这类聚合、聚合里的散列、当成标量 的子查询,以及去重、分组、MIN/MAX 留下的值「相等却不一样」(MySQL 字符串、PG numeric、算出来的表达式)的一段, 连同给它供行的子查询、CTE,都不调换来源、不排 GROUP BY 键、不改 DISTINCT、不并子查询。SQLite 的逗号连接和 CROSS JOIN 按原文分开(SQLite 不调换 CROSS JOIN 两边)。 - 列名表(CTE、子查询)写进里面那段的别名,实表别名带列名、递归 CTE(含 SQLite 不写 RECURSIVE、CTE 引用自己或后面的)、 BETWEEN SYMMETRIC、FETCH WITH TIES 不走规范化;FETCH FIRST 写成 LIMIT;USING 只在简单写法下写成 ON。 - EXISTS 改写前先查聚合(含 ORDER BY 里的)和 OFFSET;合并只调换最外层不取行的 UNION ALL(PG/MySQL 另加按位置排序、 各支列名一样的嵌套);后面有 RIGHT / FULL JOIN 时内连接不挪进 WHERE、开头的来源不调换。 - 子查询并进外层自己写(_merge_derived:只并内连接一侧、只筛行选列的),引用多次的简单 CTE 每处写回一份后整条语句 的别名重新改成互不相同的(压测撞到的:同一个 CTE 并进外层和相关子查询两处,里层别名遮住外层);外层 WHERE 只挪进 分组子查询的 HAVING(同一个值)。几个来源的 SELECT * 先写成各来源的 t.*(sqlglot 展开 * 时把实表排在子查询前面)。 - 解析丢了写法的不在语法树上判等价:0x10 和 x'10' 这类字面量、换了名字的类型名(SQLite 的 STRING 是数值亲和); SQLite 不带表名、带双引号、表结构里没有的列名保留大小写和引号(找不到这一列时被当成字符串)。 差异说明: - 换了表的一边照它自己的表说(以前把 B 改名成 A 的表,中文名、detail 归错表)。 - 新增 _Range:同一个对象只和常量比的条件按取值逐段算,留下的行一样的不说(IN 和 OR、冗余 IS NOT NULL、拆成两段的 时间、COUNT(*) > 1 和 >= 2);关联条件比相等关系的分组;聚合只在参数是列时说。 - 新增 _Usage:说「另一边没有这个条件 / 没有按时间筛选」之前,看另一边整条语句(含相关子查询、CTE、CASE、判空时连 聚合参数)是否用这一列筛选;条件子查询里不带表名的列没给表结构时不拿来说具体的。 - 子查询要外面写法一模一样(含 NOT、IN 左边的列)才配对,位置照实说「id NOT IN 的子查询」;EXCEPT 按先后逐支比; order 只在最外层、没取行、没 DISTINCT ON / 窗口函数时说;LEFT 对 INNER 只在最外层、可空一侧没进 WHERE、结果不只是 可空一侧的聚合时说;DISTINCT 对同列 GROUP BY、MySQL 表名只差大小写不说成不同;互相矛盾的一对不说。 测试:tests/fixtures/sql_pairs.py 加 nums 表和一组专挑写法差异的数据(0.1 + 0.2、像数字的文本、'SH' / 'sh'), 第 2 轮反例收进语料(_SAME_HARD 20 对、_SAME_R2 11 对、_DIFF_R2 36 对进执行比对;REGRESSIONS_R2 21 对、 DIALECT_SAME_R2 6 对、KNOWN_UNRECOGNIZED 4 对各方言只查结论);执行比对共 7 组数据、372 对,标 same 而没认出的 不许有具体说法;test_sql_semantics.py 补第 2 轮的说法用例和 normalize_sql「只改参数」的用例。这些新用例在上一版 上有 109 处失败。 验证:test_sql_semantics + test_verify_agent + test_sql_equivalence 973 passed;全量 -n 8:7853 passed、11 skipped。 等价改写 124 对认出 120 对(97%),不给表结构 117 对(94%)。复验者的脚本:vsprobe 变异压测 6 个种子约 4.3 万对无 误判等价;在私起的 PostgreSQL 18、MySQL 8.0 上重跑 vsadv 全部语料无 blocker;advfp 的 180 对等价对上具体说法 0 条; 自己的压测 3 个种子各约 1.5 万条可执行,规范形式相同的组执行结果全一致。
1102 lines
74 KiB
Python
1102 lines
74 KiB
Python
"""SQL 语义(data/sql_semantics.py):两条 SQL 是不是等价,不是的话差在哪。
|
||
|
||
- normalize_sql:字面量换成占位符后的规范文本 + 字面量。只有字面量不同的两条 SQL 规范文本相同(定时运行每期的日期
|
||
不同,不能算模板变了);关键字大小写、空白、注释也不算不同;结构变了的一定不同;解析不了的退回逐词规范化
|
||
- facets:把一条查询拆成表、连接、WHERE 合取式、时间条件、分组、聚合、HAVING、取哪几行(给调用方取结构用)
|
||
- explain_sql_diff:两边规范化后的形式一字不差才算等价(规范化的每一步都只做保持语义的变换);不等价时只把有把握
|
||
的不同写成具体的中文句子(时间列、筛选条件、聚合与去重、关联、分组、HAVING、取行、UNION),说不准的给兜底说明,
|
||
并给出规范化后不同的行。等价的结论另有执行比对守门:见 test_sql_equivalence.py
|
||
- diff_facets:照旧收两份 facets,交给 explain_sql_diff 比原文,返回它的条目(含兜底条目)
|
||
|
||
夹具全是虚构的表:订单 orders、退款 refunds、门店 stores、用户 users、付款 payments,以及一张中文表「订单」。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
|
||
import pytest
|
||
|
||
from app.data.sql_semantics import (
|
||
FacetDiff,
|
||
Facets,
|
||
SqlDiff,
|
||
catalog_labels,
|
||
diff_facets,
|
||
explain_sql_diff,
|
||
facets,
|
||
normalize_sql,
|
||
)
|
||
from tests.fixtures import sql_pairs
|
||
|
||
DIALECTS = ("sqlite", "mysql", "postgres")
|
||
LABELS = ("原流程", "盲算")
|
||
FALLBACK = "两条 SQL 的写法不同,未能归纳成具体差异,请对照原文"
|
||
FALLBACK_REST = "另有未能归纳的差异,请对照原文"
|
||
|
||
|
||
def texts(a: str, b: str, dialect: str = "sqlite", **kwargs) -> list[str]:
|
||
return [d.text for d in diff_facets(facets(a, dialect), facets(b, dialect), **kwargs)]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# normalize_sql
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def test_only_literals_differ_means_the_same_template():
|
||
week1 = normalize_sql("SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-09-01' AND store_id = 3 LIMIT 10")
|
||
week2 = normalize_sql("SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-09-08' AND store_id = 7 LIMIT 10")
|
||
assert week1.same_shape(week2) and week1.text == week2.text
|
||
assert week1.text == "SELECT SUM(amount) FROM orders WHERE paid_at >= ? AND store_id = ? LIMIT ?"
|
||
# 字面量按规范文本里的先后排:语法树里 LIMIT 存在 WHERE 前面,不能因此把 10 排到第一个
|
||
assert week1.literals == ("'2026-09-01'", "3", "10")
|
||
assert week2.literals == ("'2026-09-08'", "7", "10")
|
||
assert week1.parse_error is None
|
||
|
||
|
||
def test_case_whitespace_and_comments_do_not_count():
|
||
a = normalize_sql("select Amount\nfrom ORDERS -- 本周\nwhere paid_at >= '2026-09-01'", "sqlite")
|
||
b = normalize_sql("SELECT amount FROM orders /* 上周 */ WHERE paid_at >= '2026-08-25'", "sqlite")
|
||
assert a.text == b.text and a.literals != b.literals
|
||
|
||
|
||
def test_a_structural_change_is_a_different_template():
|
||
base = normalize_sql("SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-09-01'")
|
||
for changed in ("SELECT SUM(amount) FROM orders WHERE created_at >= '2026-09-01'",
|
||
"SELECT SUM(amount) FROM orders WHERE paid_at > '2026-09-01'",
|
||
"SELECT AVG(amount) FROM orders WHERE paid_at >= '2026-09-01'",
|
||
"SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-09-01' AND is_deleted = 0"):
|
||
assert not normalize_sql(changed).same_shape(base), changed
|
||
|
||
|
||
@pytest.mark.parametrize("dialect,a,b", [
|
||
("sqlite", "SELECT strftime('%Y-%m', paid_at) AS m, SUM(amount) AS \"销售额\" FROM \"订单\" "
|
||
"WHERE \"支付日期\" BETWEEN '2026-09-01' AND '2026-09-30' GROUP BY 1",
|
||
"SELECT strftime('%Y-%m', paid_at) AS m, SUM(amount) AS \"销售额\" FROM \"订单\" "
|
||
"WHERE \"支付日期\" BETWEEN '2026-10-01' AND '2026-10-31' GROUP BY 1"),
|
||
("mysql", "SELECT SUM(o.`金额`) AS `销售额` FROM `订单` o WHERE o.paid_at >= \"2026-09-01\" LIMIT 5, 10",
|
||
"SELECT SUM(o.`金额`) AS `销售额` FROM `订单` o WHERE o.paid_at >= \"2026-09-08\" LIMIT 5, 20"),
|
||
("postgres", "SELECT SUM(amount) AS \"销售额\" FROM orders WHERE paid_at::date >= CURRENT_DATE - INTERVAL '7 days'",
|
||
"SELECT SUM(amount) AS \"销售额\" FROM orders WHERE paid_at::date >= CURRENT_DATE - INTERVAL '14 days'"),
|
||
])
|
||
def test_each_dialect_reads_its_own_syntax(dialect, a, b):
|
||
na, nb = normalize_sql(a, dialect), normalize_sql(b, dialect)
|
||
assert na.parse_error is None and nb.parse_error is None
|
||
assert na.text == nb.text and na.literals != nb.literals
|
||
# 带引号的中文别名原样留在规范文本里:别名不是字面量
|
||
assert "销售额" in na.text
|
||
|
||
|
||
def test_the_data_source_kind_is_accepted_as_the_dialect():
|
||
assert normalize_sql("SELECT 1 FROM t WHERE a = 'x'", "postgresql").text == \
|
||
normalize_sql("SELECT 1 FROM t WHERE a = 'y'", "postgres").text
|
||
|
||
|
||
def test_unparsable_sql_still_tells_a_literal_only_change():
|
||
a = normalize_sql("SELECT amount FROM orders WHERE paid_at >= '2026-09-01' AND AND", "sqlite")
|
||
b = normalize_sql("select amount from ORDERS where paid_at >= '2026-09-08' and and", "sqlite")
|
||
assert a.parse_error and "无法解析" in a.parse_error
|
||
assert a.text == b.text and a.literals == ("'2026-09-01'",) and b.literals == ("'2026-09-08'",)
|
||
|
||
|
||
@pytest.mark.parametrize("sql", ["", " ", None, "SELECT 1; SELECT 2"])
|
||
def test_nothing_to_normalize_never_raises(sql):
|
||
got = normalize_sql(sql, "sqlite")
|
||
assert got.parse_error
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# facets:各个方面
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def test_tables_are_the_real_tables_without_cte_names():
|
||
f = facets("WITH paid AS (SELECT * FROM orders WHERE status = 'paid') "
|
||
"SELECT s.name, SUM(p.amount) FROM paid p JOIN stores s ON s.id = p.store_id GROUP BY s.name")
|
||
assert [t.text for t in f.tables] == ["orders", "stores"] or [t.text for t in f.tables] == ["stores", "orders"]
|
||
assert "paid" not in {t.key for t in f.tables}
|
||
|
||
|
||
def test_joins_carry_kind_both_sides_and_a_normalized_condition():
|
||
f = facets("SELECT o.id FROM orders o LEFT JOIN refunds r ON r.order_id = o.id AND r.state = 'done'")
|
||
[join] = f.joins
|
||
assert join.kind == "left" and join.table.text == "refunds" and join.other == ("orders",)
|
||
# 别名换成真实表名,两边排好顺序,几个条件按 key 排序后用 AND 连起来
|
||
assert join.on.text == "orders.id = refunds.order_id AND refunds.state = 'done'"
|
||
same = facets("SELECT orders.id FROM orders LEFT OUTER JOIN refunds ON refunds.state = 'done' "
|
||
"AND orders.id = refunds.order_id")
|
||
assert same.joins[0].on.key == join.on.key and same.joins[0].kind == "left"
|
||
|
||
|
||
def test_a_comma_join_is_the_same_as_an_inner_join():
|
||
comma = facets("SELECT a.x FROM orders a, stores b WHERE a.store_id = b.id AND b.city = '虚构市'")
|
||
inner = facets("SELECT orders.x FROM orders JOIN stores ON stores.id = orders.store_id WHERE stores.city = '虚构市'")
|
||
assert comma.joins == inner.joins and comma.joins[0].kind == "inner"
|
||
assert comma.joins[0].table.text == "stores" and comma.joins[0].other == ("orders",)
|
||
cross = facets("SELECT 1 FROM orders o CROSS JOIN stores s")
|
||
assert (cross.joins[0].kind, cross.joins[0].on, cross.joins[0].other) == ("cross", None, ("orders",))
|
||
assert [p.key for p in comma.where] == [p.key for p in inner.where] == ["stores.city = '虚构市'"]
|
||
|
||
|
||
def test_where_conjuncts_are_normalized():
|
||
f = facets("SELECT 1 FROM orders o WHERE 0 = o.is_deleted AND o.status IN ('shipped', 'paid') "
|
||
"AND (o.channel = 'app' OR o.channel = 'web')")
|
||
assert [p.text for p in f.where] == ["is_deleted = 0", "status IN ('paid', 'shipped')",
|
||
"channel = 'app' OR channel = 'web'"]
|
||
assert f.where[0].columns == (("orders", "is_deleted"),)
|
||
|
||
|
||
@pytest.mark.parametrize("dialect", DIALECTS)
|
||
def test_exclusions_are_read_in_every_dialect(dialect):
|
||
f = facets("SELECT 1 FROM orders WHERE is_deleted <> 1 AND store_id NOT IN (8, 9) AND refund_id IS NOT NULL "
|
||
"AND note NOT LIKE '%测试%'", dialect)
|
||
assert [p.excludes for p in f.where] == ["is_deleted = 1", "store_id IN (8, 9)", "refund_id 为空",
|
||
"note LIKE '%测试%'"]
|
||
|
||
|
||
def test_time_predicates_have_column_operator_and_value():
|
||
f = facets("SELECT SUM(amount) FROM orders o WHERE o.paid_at >= '2026-09-01' AND '2026-10-01' > o.paid_at "
|
||
"AND o.store_id = 3")
|
||
assert [(t.column.text, t.op, t.value) for t in f.time] == [("paid_at", ">=", "'2026-09-01'"),
|
||
("paid_at", "<", "'2026-10-01'")]
|
||
between = facets("SELECT 1 FROM \"订单\" WHERE \"支付日期\" BETWEEN '2026-09-01' AND '2026-09-30'")
|
||
[t] = between.time
|
||
assert (t.column.text, t.op, t.value) == ('"支付日期"', "between", "'2026-09-01' AND '2026-09-30'")
|
||
# 列套着日期函数也认;按月份字符串筛也认
|
||
month = facets("SELECT 1 FROM orders WHERE strftime('%Y-%m', created_at) = '2026-09'")
|
||
assert [(t.column.text, t.op) for t in month.time] == [("created_at", "=")]
|
||
assert facets("SELECT 1 FROM orders WHERE store_id = 3 AND amount > 100").time == ()
|
||
|
||
|
||
def test_group_by_position_and_alias_mean_the_expression():
|
||
explicit = facets("SELECT o.region, SUM(o.amount) FROM orders o GROUP BY o.region")
|
||
by_position = facets("SELECT region AS r, SUM(amount) FROM orders GROUP BY 1")
|
||
by_alias = facets("SELECT region AS r, SUM(amount) FROM orders GROUP BY r")
|
||
assert [p.key for p in explicit.group_by] == [p.key for p in by_position.group_by] == \
|
||
[p.key for p in by_alias.group_by] == ["orders.region"]
|
||
|
||
|
||
def test_aggregates_have_function_argument_and_distinct():
|
||
f = facets("SELECT COUNT(*), COUNT(1), COUNT(DISTINCT o.order_id), SUM(o.amount), "
|
||
"SUM(o.amount) OVER (PARTITION BY o.region) FROM orders o")
|
||
assert [(g.func, g.arg.text, g.distinct) for g in f.aggregates] == [
|
||
("count", "*", False), ("count", "order_id", True), ("sum", "amount", False)], \
|
||
"COUNT(1) 和 COUNT(*) 是同一个;窗口函数不算聚合"
|
||
|
||
|
||
def test_having_and_limit():
|
||
f = facets("SELECT region, SUM(amount) FROM orders GROUP BY region HAVING 100 < SUM(amount) LIMIT 10")
|
||
assert [p.text for p in f.having] == ["SUM(amount) > 100"] and f.limit == "10"
|
||
assert facets("SELECT a FROM orders LIMIT 5, 20", "mysql").limit == "20"
|
||
|
||
|
||
def test_having_reads_select_aliases():
|
||
"""HAVING 里引用 SELECT 的别名(MySQL、SQLite 的写法)换成别名指的表达式,和 GROUP BY 别名一样。"""
|
||
by_alias = facets("SELECT region AS r, SUM(amount) AS s FROM orders GROUP BY r HAVING s > 100")
|
||
explicit = facets("SELECT o.region, SUM(o.amount) FROM orders o GROUP BY o.region HAVING SUM(o.amount) > 100")
|
||
assert [p.key for p in by_alias.having] == [p.key for p in explicit.having] == ["SUM(orders.amount) > 100"]
|
||
|
||
|
||
def test_limits_are_collected_per_segment_with_offset_and_order():
|
||
"""LIMIT 逐段收集(CTE、FROM 里的子查询各算一段),连同 OFFSET 和这一段的排序:只取前几行时,按什么排、
|
||
跳过几行决定了取到的是哪几行。OFFSET 0 等于没写。"""
|
||
f = facets("WITH top AS (SELECT amount FROM orders ORDER BY amount DESC LIMIT 10) "
|
||
"SELECT SUM(amount) FROM top LIMIT 1")
|
||
assert [(x.scope, x.count, x.offset) for x in f.limits] == [(None, "1", None), ("top", "10", None)]
|
||
[(key, desc)] = f.limits[1].order
|
||
assert key.text == "amount" and desc is True
|
||
mysql = facets("SELECT a FROM orders ORDER BY 1 LIMIT 5, 20", "mysql")
|
||
assert (mysql.limits[0].count, mysql.limits[0].offset) == ("20", "5")
|
||
assert [(k.text, d) for k, d in mysql.limits[0].order] == [("a", False)], "ORDER BY 1 换成 SELECT 里的第一项"
|
||
assert facets("SELECT a FROM orders LIMIT 10 OFFSET 0").limits[0].offset is None
|
||
|
||
|
||
@pytest.mark.parametrize("dialect,sql", [
|
||
("sqlite", 'SELECT SUM("金额") AS "销售额" FROM "订单" WHERE "是否作废" = 0'),
|
||
("mysql", "SELECT SUM(`金额`) AS `销售额` FROM `订单` WHERE `是否作废` = 0"),
|
||
("mysql", 'SELECT SUM(`金额`) AS "销售额" FROM `订单` WHERE `是否作废` = 0'),
|
||
("postgres", 'SELECT SUM("金额") AS "销售额" FROM "订单" WHERE "是否作废" = 0'),
|
||
])
|
||
def test_quoted_chinese_names_and_aliases(dialect, sql):
|
||
f = facets(sql, dialect)
|
||
assert f.parse_error is None
|
||
assert [t.key for t in f.tables] == ["订单"]
|
||
assert [(g.func, g.arg.key) for g in f.aggregates] == [("sum", '"订单"."金额"')] or \
|
||
[(g.func, g.arg.columns) for g in f.aggregates] == [("sum", (("订单", "金额"),))]
|
||
assert f.where[0].columns == (("订单", "是否作废"),)
|
||
|
||
|
||
def test_a_cte_that_passes_columns_through_maps_them_to_the_table():
|
||
wrapped = facets("WITH v AS (SELECT * FROM orders WHERE is_deleted = 0) SELECT SUM(v.amount) FROM v")
|
||
direct = facets("SELECT SUM(amount) FROM orders WHERE is_deleted = 0")
|
||
assert wrapped.aggregates == direct.aggregates and wrapped.where == direct.where
|
||
|
||
|
||
def test_a_correlated_subquery_reads_the_outer_alias():
|
||
"""子查询里引用外层的别名(o.id)到外层去找:写成真实表名,两张表的列互相比的条件也写出表名。"""
|
||
f = facets("SELECT o.id FROM orders o WHERE NOT EXISTS (SELECT 1 FROM refunds r WHERE r.order_id = o.id)")
|
||
assert "orders.id = refunds.order_id" in [p.text for p in f.where]
|
||
plain = facets("SELECT orders.id FROM orders WHERE NOT EXISTS "
|
||
"(SELECT 1 FROM refunds WHERE orders.id = refunds.order_id)")
|
||
assert plain.where[1].key == f.where[1].key
|
||
# 外层那个条件(NOT EXISTS 整句)也一样:子查询按它自己那一段的来源写,别名换成真实表名
|
||
assert plain.where[0].key == f.where[0].key
|
||
assert f.where[0].excludes == "EXISTS(SELECT 1 FROM refunds WHERE orders.id = refunds.order_id)"
|
||
|
||
|
||
def test_a_subquery_in_a_condition_is_written_with_its_own_tables():
|
||
f = facets("SELECT SUM(o.amount) FROM orders o WHERE o.user_id IN (SELECT u.id FROM users u WHERE u.vip = 1)")
|
||
assert f.where[0].text == "user_id IN (SELECT id FROM users WHERE vip = 1)"
|
||
assert f.where[0].key == "orders.user_id IN (SELECT users.id FROM users WHERE users.vip = 1)"
|
||
|
||
|
||
def test_the_schema_tells_which_table_an_unqualified_column_belongs_to():
|
||
sql = "SELECT SUM(amount) FROM orders o JOIN stores s ON s.id = o.store_id WHERE city = '虚构市'"
|
||
assert facets(sql).where[0].columns == ((None, "city"),), "不给表结构:几张表都可能有这一列,照写列名"
|
||
schema = {"tables": {"orders": {"columns": [{"name": "id"}, {"name": "amount"}, {"name": "store_id"}]},
|
||
"stores": {"columns": [{"name": "id"}, {"name": "city"}]}}}
|
||
assert facets(sql, schema=schema).where[0].columns == (("stores", "city"),)
|
||
|
||
|
||
@pytest.mark.parametrize("sql,words", [
|
||
("SELECT FROM WHERE", "无法解析"),
|
||
("SELECT 1; SELECT 2", "2 条"),
|
||
("UPDATE orders SET amount = 0", "不是查询"),
|
||
("", "为空"),
|
||
])
|
||
def test_unparsable_sql_carries_parse_error_and_never_raises(sql, words):
|
||
f = facets(sql, "sqlite")
|
||
assert f.parse_error and words in f.parse_error
|
||
assert (f.tables, f.joins, f.where, f.aggregates) == ((), (), (), ())
|
||
|
||
|
||
def test_facets_are_json_ready():
|
||
f = facets("SELECT COUNT(DISTINCT order_id) FROM orders WHERE paid_at >= '2026-09-01' GROUP BY region")
|
||
data = json.loads(json.dumps(f.as_dict(), ensure_ascii=False))
|
||
assert data["aggregates"][0]["distinct"] is True and data["time"][0]["op"] == ">="
|
||
assert not [k for k in data if k.startswith("_")], "拿来重新分析的原文不进 as_dict"
|
||
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# normalize_sql:「只改了参数」的判断不受差异说明改动影响
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.parametrize("dialect", DIALECTS)
|
||
@pytest.mark.parametrize("a,b", [
|
||
("SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-09-01' AND paid_at < '2026-10-01'",
|
||
"SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-10-01' AND paid_at < '2026-11-01'"),
|
||
("WITH p AS (SELECT * FROM orders WHERE status = 'paid' AND amount > 100) SELECT store_id, SUM(amount) FROM p "
|
||
"GROUP BY store_id HAVING SUM(amount) > 1000 ORDER BY 2 DESC LIMIT 5",
|
||
"WITH p AS (SELECT * FROM orders WHERE status = 'done' AND amount > 50) SELECT store_id, SUM(amount) FROM p "
|
||
"GROUP BY store_id HAVING SUM(amount) > 10 ORDER BY 2 DESC LIMIT 20"),
|
||
("SELECT COUNT(*) FROM users u WHERE u.id IN (SELECT user_id FROM orders WHERE status IN ('paid', 'done'))",
|
||
"SELECT COUNT(*) FROM users u WHERE u.id IN (SELECT user_id FROM orders WHERE status IN ('open', 'void'))"),
|
||
("SELECT o.id, CASE WHEN o.amount > 100 THEN 'big' ELSE 'small' END FROM orders o LEFT JOIN refunds r "
|
||
"ON r.order_id = o.id AND r.amount > 0 WHERE o.created_at BETWEEN '2026-09-01' AND '2026-09-30'",
|
||
"SELECT o.id, CASE WHEN o.amount > 200 THEN 'huge' ELSE 'tiny' END FROM orders o LEFT JOIN refunds r "
|
||
"ON r.order_id = o.id AND r.amount > 5 WHERE o.created_at BETWEEN '2026-10-01' AND '2026-10-31'"),
|
||
], ids=["dates", "cte-having-limit", "in-list", "case-join-between"])
|
||
def test_only_parameters_changed_is_still_the_same_template(dialect, a, b):
|
||
"""定时运行每期只换日期、阈值:规范文本相同(同一个模板),字面量各是各的;同时 explain_sql_diff 照样说两条
|
||
不等价(取值不同就是不同的口径)。"""
|
||
na, nb = normalize_sql(a, dialect), normalize_sql(b, dialect)
|
||
assert na.parse_error is None and na.same_shape(nb) and na.literals != nb.literals
|
||
assert not explain_sql_diff(a, b, dialect).equivalent
|
||
|
||
|
||
@pytest.mark.parametrize("dialect", DIALECTS)
|
||
@pytest.mark.parametrize("b", [
|
||
"SELECT SUM(amount) FROM orders WHERE created_at >= '2026-09-01' GROUP BY store_id",
|
||
"SELECT SUM(amount) FROM orders WHERE paid_at > '2026-09-01' GROUP BY store_id",
|
||
"SELECT AVG(amount) FROM orders WHERE paid_at >= '2026-09-01' GROUP BY store_id",
|
||
"SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-09-01' AND is_deleted = 0 GROUP BY store_id",
|
||
"SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-09-01' GROUP BY user_id",
|
||
"SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-09-01'",
|
||
"SELECT SUM(amount) FROM orders_archive WHERE paid_at >= '2026-09-01' GROUP BY store_id",
|
||
"SELECT SUM(DISTINCT amount) FROM orders WHERE paid_at >= '2026-09-01' GROUP BY store_id",
|
||
"SELECT SUM(amount) FROM orders o JOIN stores s ON s.id = o.store_id WHERE paid_at >= '2026-09-01' "
|
||
"GROUP BY store_id",
|
||
"SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-09-01' GROUP BY store_id ORDER BY 1",
|
||
"SELECT SUM(amount) FROM orders WHERE paid_at IN ('2026-09-01') GROUP BY store_id",
|
||
"WITH t AS (SELECT * FROM orders) SELECT SUM(amount) FROM t WHERE paid_at >= '2026-09-01' GROUP BY store_id",
|
||
])
|
||
def test_a_structural_change_is_never_the_same_template(dialect, b):
|
||
a = "SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-09-01' GROUP BY store_id"
|
||
assert not normalize_sql(a, dialect).same_shape(normalize_sql(b, dialect)), b
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# explain_sql_diff:等价怎么判
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def check_shape(diff: SqlDiff) -> None:
|
||
"""SqlDiff 自己的承诺:等价才没有条目;不等价一定有条目,兜底时最后一条是 aspect=other,并给出不同的行。"""
|
||
assert isinstance(diff, SqlDiff)
|
||
if diff.equivalent:
|
||
assert diff.items == [] and not diff.fallback and diff.text_diff == ()
|
||
return
|
||
assert diff.items and diff.text_diff and len(diff.text_diff) <= 40
|
||
assert all(line[:2] in ("- ", "+ ") for line in diff.text_diff)
|
||
if diff.fallback:
|
||
assert diff.items[-1].aspect == "other" and diff.items[-1].text in (FALLBACK, FALLBACK_REST)
|
||
else:
|
||
assert all(d.aspect != "other" for d in diff.items)
|
||
# 不自相矛盾:同一句话不说两遍,配成一对的两边写法不会一样
|
||
assert len({d.text for d in diff.items}) == len(diff.items)
|
||
assert all(d.a != d.b for d in diff.items if isinstance(d.a, str) and isinstance(d.b, str))
|
||
|
||
|
||
@pytest.mark.parametrize("dialect,a,b", [
|
||
("sqlite", "SELECT SUM(o.amount) FROM orders o WHERE o.paid_at >= '2026-09-01' AND o.is_deleted = 0",
|
||
"select sum(amount) from orders where 0 = is_deleted and '2026-09-01' <= paid_at"),
|
||
("sqlite", "WITH v AS (SELECT * FROM orders WHERE is_deleted = 0) SELECT COUNT(DISTINCT v.order_id) FROM v",
|
||
"SELECT COUNT(DISTINCT order_id) FROM orders WHERE is_deleted = 0"),
|
||
("sqlite", "SELECT SUM(o.amount) FROM orders o, stores s WHERE o.store_id = s.id AND s.city = '虚构市'",
|
||
"SELECT SUM(orders.amount) FROM orders INNER JOIN stores ON stores.id = orders.store_id "
|
||
"WHERE stores.city = '虚构市'"),
|
||
("sqlite", "SELECT amount FROM orders ORDER BY 1 DESC LIMIT 10 OFFSET 0",
|
||
"SELECT o.amount FROM orders o ORDER BY o.amount DESC LIMIT 10"),
|
||
("postgres", "WITH user_totals AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
|
||
"SELECT COUNT(*) FROM user_totals WHERE total > 100",
|
||
"SELECT COUNT(*) FROM (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) AS agg "
|
||
"WHERE agg.total > 100"),
|
||
("mysql", "SELECT SUM(o.amount) FROM orders o JOIN (SELECT order_id, COUNT(*) AS n FROM refunds GROUP BY order_id) r "
|
||
"ON r.order_id = o.id WHERE r.n > 1",
|
||
"SELECT SUM(o.amount) FROM orders o JOIN (SELECT order_id, COUNT(*) AS n FROM refunds GROUP BY order_id) "
|
||
"`退款` ON o.id = `退款`.order_id WHERE `退款`.n > 1"),
|
||
("postgres", "SELECT SUM(amount) FROM orders WHERE user_id IN "
|
||
"(WITH t AS (SELECT id FROM users WHERE grade >= 3) SELECT id FROM t)",
|
||
"SELECT SUM(amount) FROM orders WHERE user_id IN "
|
||
"(WITH g AS (SELECT id FROM users WHERE grade >= 3) SELECT g.id FROM g)"),
|
||
("postgres", "SELECT COUNT(*) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id",
|
||
"SELECT COUNT(*) FROM refunds r RIGHT JOIN orders o ON o.id = r.order_id"),
|
||
("mysql", "SELECT SUM(`amount`) FROM `orders` WHERE `status` = 'paid'",
|
||
"SELECT SUM(amount) FROM orders WHERE status = 'paid'"),
|
||
("postgres", 'SELECT SUM("amount") FROM "orders" WHERE "status" = \'paid\'',
|
||
"SELECT SUM(amount) FROM orders WHERE status = 'paid'"),
|
||
("postgres", "SELECT user_id FROM orders UNION SELECT user_id FROM refunds",
|
||
"SELECT * FROM (SELECT user_id FROM orders UNION SELECT user_id FROM refunds) t"),
|
||
# UNION ALL 调换各支:行一样多、一样的值,各方言都认得出(SQLite 只限整条语句最外层、不排序的,见下面的反例)
|
||
("sqlite", "SELECT user_id FROM orders WHERE amount > 1 UNION ALL SELECT user_id FROM refunds",
|
||
"SELECT user_id FROM refunds UNION ALL SELECT user_id FROM orders WHERE amount > 1"),
|
||
("postgres", "SELECT user_id FROM orders UNION ALL SELECT user_id FROM refunds ORDER BY 1",
|
||
"SELECT user_id FROM refunds UNION ALL SELECT user_id FROM orders ORDER BY 1"),
|
||
("mysql", "SELECT COUNT(*) FROM (SELECT user_id FROM orders UNION ALL SELECT user_id FROM refunds) t",
|
||
"SELECT COUNT(*) FROM (SELECT user_id FROM refunds UNION ALL SELECT user_id FROM orders) t"),
|
||
], ids=["aliases-and-flips", "filter-in-cte", "comma-join", "offset-0", "cte-vs-subquery",
|
||
"mysql-chinese-alias", "cte-inside-in", "left-vs-right", "mysql-backticks", "pg-quotes", "pg-union-wrapped",
|
||
"sqlite-union-all-branches", "pg-union-all-order-by-position", "mysql-union-all-in-subquery"])
|
||
def test_rewrites_that_keep_the_meaning_are_equivalent(dialect, a, b):
|
||
diff = explain_sql_diff(a, b, dialect)
|
||
check_shape(diff)
|
||
assert diff.equivalent and diff.level in ("optimizer", "light", "parsed")
|
||
assert texts(a, b, dialect) == []
|
||
|
||
|
||
@pytest.mark.parametrize("name,dialect,a,b", sql_pairs.DIALECT_SAME, ids=[f"{n}-{d}-{i}" for i, (n, d, _, _)
|
||
in enumerate(sql_pairs.DIALECT_SAME)])
|
||
def test_rewrites_from_earlier_rounds_are_recognized(name, dialect, a, b):
|
||
"""旧测试里「写法不同、意思一样」的对(各方言,SQLite 以外的不能执行,只在这里查):都认得出是等价的。"""
|
||
diff = explain_sql_diff(a, b, dialect)
|
||
check_shape(diff)
|
||
assert diff.equivalent, name
|
||
|
||
|
||
@pytest.mark.parametrize("name,dialect,a,b", sql_pairs.REGRESSIONS, ids=[f"{n}-{d}-{i}" for i, (n, d, _, _)
|
||
in enumerate(sql_pairs.REGRESSIONS)])
|
||
def test_differences_reported_in_earlier_rounds_are_never_called_equivalent(name, dialect, a, b):
|
||
"""过去五轮复验、旧测试报过的每一处不同:一律不判等价,条目不为空、不自相矛盾;换个先后问也一样。第 5 轮回退的
|
||
「只在 IN / EXISTS / 标量子查询里用到的 CTE 里的不同」也在里面。"""
|
||
for x, y in ((a, b), (b, a)):
|
||
diff = explain_sql_diff(x, y, dialect, labels=LABELS)
|
||
check_shape(diff)
|
||
assert not diff.equivalent, name
|
||
|
||
|
||
@pytest.mark.parametrize("dialect,a,b,why", [
|
||
("sqlite", "SELECT o.id, r.flag FROM orders o LEFT JOIN (SELECT order_id, 1 AS flag FROM refunds) r "
|
||
"ON r.order_id = o.id",
|
||
"SELECT o.id, 1 AS flag FROM orders o LEFT JOIN refunds r ON r.order_id = o.id",
|
||
"merge_subqueries 会把外连接可空一侧的常量列并进外层:没关联上的行本该是空值"),
|
||
("sqlite", "SELECT id FROM orders WHERE status = 'paid' OR amount > (SELECT amount FROM refunds WHERE id = 999)",
|
||
"SELECT orders.id FROM orders CROSS JOIN (SELECT amount FROM refunds WHERE id = 999) AS q "
|
||
"WHERE orders.status = 'paid' OR orders.amount > q.amount",
|
||
"unnest_subqueries 把 OR 里的标量子查询改成 CROSS JOIN:子查询没有行时整条都没了"),
|
||
("sqlite", "SELECT COUNT(*) FROM orders WHERE paid_at >= '2026-09-01'",
|
||
"SELECT COUNT(*) FROM orders WHERE paid_at >= CAST('2026-09-01' AS DATE)",
|
||
"canonicalize 给日期比较加 CAST;SQLite 里 CAST(… AS DATE) 取的是数字"),
|
||
("sqlite", "SELECT COUNT(*) FROM orders WHERE CAST(paid_at AS DATE) >= '2026-09-01'",
|
||
"SELECT COUNT(*) FROM orders WHERE DATE(paid_at) >= '2026-09-01'",
|
||
"sqlglot 生成 SQLite 时把 CAST(x AS DATE) 写成 DATE(x):比规范形式不能比生成的文本"),
|
||
("sqlite", "SELECT COUNT(*) FROM orders WHERE +amount = '10'", "SELECT COUNT(*) FROM orders WHERE amount = '10'",
|
||
"一元加号在 SQLite 里去掉了类型亲和,解析时却被丢掉了"),
|
||
("postgres", "SELECT COUNT(*) FROM orders WHERE status > 'b' AND status > 'O'",
|
||
"SELECT COUNT(*) FROM orders WHERE status > 'b'",
|
||
"字符串比较的强弱取决于排序规则:simplify 的这条化简不做"),
|
||
("sqlite", "SELECT COUNT(*) FROM orders WHERE region > 10 AND region > 9",
|
||
"SELECT COUNT(*) FROM orders WHERE region > 10", "TEXT 列和数字比是按文本比:不合并同一列上的比较"),
|
||
("sqlite", "SELECT COUNT(*) FROM orders WHERE amount + 1 = 101", "SELECT COUNT(*) FROM orders WHERE amount = 100",
|
||
"等式两边移项对 TEXT 列不成立"),
|
||
("sqlite", "SELECT user_id, 1 AS total FROM orders GROUP BY user_id ORDER BY total DESC, user_id LIMIT 3",
|
||
"SELECT user_id, 1 AS total FROM orders GROUP BY user_id ORDER BY 1 DESC, user_id LIMIT 3",
|
||
"ORDER BY 别名换成常量 1 就成了按第一列排"),
|
||
("sqlite", "SELECT user_id FROM orders UNION SELECT user_id FROM refunds",
|
||
"SELECT * FROM (SELECT user_id FROM orders UNION SELECT user_id FROM refunds) t",
|
||
"SQLite 从子查询读 UNION 时按第一支的类型亲和转换别的支的值"),
|
||
("sqlite", "SELECT COUNT(*) FROM orders o WHERE o.id NOT IN (SELECT order_id FROM refunds)",
|
||
"SELECT COUNT(*) FROM orders o WHERE NOT EXISTS (SELECT 1 FROM refunds r WHERE r.order_id = o.id)",
|
||
"NOT IN 遇到空值整个不成立,NOT EXISTS 不会"),
|
||
("sqlite", "SELECT COUNT(*) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id WHERE r.id IS NULL OR r.amount > 5",
|
||
"SELECT COUNT(*) FROM orders o JOIN refunds r ON r.order_id = o.id WHERE r.id IS NULL OR r.amount > 5",
|
||
"IS NULL 留得下没关联上的行:这次外连接不是内连接"),
|
||
# 合并的各支调换先后:下面这些都会改变结果(复验压测时撞到的是第一种,SQLite 里留下的是 2 还是 2.0)
|
||
("sqlite", "SELECT tax FROM orders UNION SELECT referrer_id FROM orders",
|
||
"SELECT referrer_id FROM orders UNION SELECT tax FROM orders",
|
||
"UNION 去重时留先出现的那个值:SQLite 里 REAL 列的 2.0 和 INTEGER 列的 2 算重复,留下哪个取决于哪支在前"),
|
||
("mysql", "SELECT city FROM stores UNION SELECT city FROM users",
|
||
"SELECT city FROM users UNION SELECT city FROM stores",
|
||
"MySQL 默认的排序规则不分大小写,'SH' 和 'sh' 去重时留先出现的那个"),
|
||
("postgres", "SELECT amount FROM orders INTERSECT SELECT amount FROM refunds",
|
||
"SELECT amount FROM refunds INTERSECT SELECT amount FROM orders",
|
||
"INTERSECT 留左边那支的值:numeric 的 2 和 2.00 相等,写出来却不一样"),
|
||
("postgres", "SELECT id FROM orders UNION ALL SELECT id FROM refunds LIMIT 3",
|
||
"SELECT id FROM refunds UNION ALL SELECT id FROM orders LIMIT 3",
|
||
"没有 ORDER BY 的 LIMIT 取先出来的几行:各支调换先后,取到的就是另一张表的行"),
|
||
("postgres", "SELECT id, user_id FROM orders UNION ALL SELECT user_id, id FROM refunds ORDER BY id",
|
||
"SELECT user_id, id FROM refunds UNION ALL SELECT id, user_id FROM orders ORDER BY id",
|
||
"合并结果的列名取第一支的:调换之后 ORDER BY id 排的是另一列"),
|
||
("sqlite", "SELECT name FROM users UNION ALL SELECT city FROM stores ORDER BY 1",
|
||
"SELECT city FROM stores UNION ALL SELECT name FROM users ORDER BY 1",
|
||
"SQLite 给合并的结果排序时用最左一支那列声明的排序规则(COLLATE NOCASE 的列在前就不分大小写)"),
|
||
("sqlite", "SELECT COUNT(*) FROM (SELECT name AS v FROM users UNION ALL SELECT city AS v FROM stores) t "
|
||
"WHERE v = 'sh'",
|
||
"SELECT COUNT(*) FROM (SELECT city AS v FROM stores UNION ALL SELECT name AS v FROM users) t WHERE v = 'sh'",
|
||
"SQLite 从子查询读合并的结果再比较时,列的排序规则也取最左一支的"),
|
||
("sqlite", "SELECT COUNT(*) FROM users u JOIN stores s ON u.name COLLATE NOCASE = s.city COLLATE BINARY",
|
||
"SELECT COUNT(*) FROM users u JOIN stores s ON s.city COLLATE BINARY = u.name COLLATE NOCASE",
|
||
"比较的两边都写了 COLLATE 时 SQLite 用左边那个:两边不能对调"),
|
||
# 别名和名字:qualify 没有表结构时把 SELECT 列表里和前面别名同名的名字当成别名展开,库里却不认
|
||
("sqlite", "SELECT amount * 2 AS x, x + 1 AS y FROM orders",
|
||
"SELECT amount * 2 AS x, amount * 2 + 1 AS y FROM orders",
|
||
"SELECT 列表里不能引用前面列的别名:x 不是表的列,前一条在库里报错"),
|
||
("sqlite", "SELECT user_id AS amount, amount AS user_id FROM orders ORDER BY amount",
|
||
"SELECT user_id AS amount, amount AS user_id FROM orders ORDER BY user_id",
|
||
"ORDER BY 里单独一个名字先认别名:两条排的是不同的列(第二列的 amount 是表的列)"),
|
||
# 取到哪几行、哪一行的值 SQL 本来就没规定的:库里看扫描的先后,调换关联先后就变了(压测撞到的是第一种)
|
||
("sqlite", "SELECT o.created_at, COUNT(*) FROM orders o JOIN refunds r ON r.order_id = o.id GROUP BY o.store_id",
|
||
"SELECT o.created_at, COUNT(*) FROM refunds r JOIN orders o ON r.order_id = o.id GROUP BY o.store_id",
|
||
"聚合查询里既不分组也不聚合的列,SQLite 取组里任意一行的值"),
|
||
("sqlite", "SELECT o.id, r.id FROM orders o JOIN refunds r ON r.order_id = o.id LIMIT 5",
|
||
"SELECT o.id, r.id FROM refunds r JOIN orders o ON r.order_id = o.id LIMIT 5",
|
||
"没有 ORDER BY 的 LIMIT 取先扫到的几行"),
|
||
("sqlite", "SELECT COUNT(*) FROM orders WHERE user_id IN (SELECT DISTINCT user_id FROM refunds LIMIT 2)",
|
||
"SELECT COUNT(*) FROM orders WHERE user_id IN (SELECT user_id FROM refunds GROUP BY user_id LIMIT 2)",
|
||
"没有 ORDER BY 的 LIMIT:DISTINCT 按扫到的先后出行,GROUP BY 按分组键排好了出,取到的两个不同"),
|
||
("sqlite", "SELECT r.id, o.id FROM refunds r RIGHT JOIN orders o ON r.order_id = o.id LIMIT 5",
|
||
"SELECT r.id, o.id FROM orders o LEFT JOIN refunds r ON r.order_id = o.id LIMIT 5",
|
||
"没有 ORDER BY 的 LIMIT:RIGHT JOIN 改写成 LEFT JOIN,扫描的先后就变了"),
|
||
], ids=["nullable-merge", "unnest-or", "cast-date", "cast-vs-date", "unary-plus", "collation", "text-number",
|
||
"equality-move", "order-by-constant", "sqlite-union-affinity", "not-in-null", "outer-join-is-null",
|
||
"union-dedup-storage-class", "union-dedup-collation", "intersect-numeric-scale", "union-all-limit",
|
||
"union-all-order-by-name", "sqlite-union-all-order-collation", "sqlite-nested-union-all", "collate-sides",
|
||
"select-list-alias-ref", "order-by-swapped-aliases", "loose-column-join-order", "limit-join-order",
|
||
"limit-distinct-vs-group", "limit-right-vs-left"])
|
||
def test_rewrites_that_change_the_meaning_are_not_equivalent(dialect, a, b, why):
|
||
"""这些写法看着能改写成同一个样子,其实结果可能不同(why):sqlglot 优化器里有的改写不保持语义,规范化不用它们,
|
||
或者守住了用法。"""
|
||
for schema in (None, sql_pairs.schema_cache()):
|
||
diff = explain_sql_diff(a, b, dialect, schema=schema)
|
||
check_shape(diff)
|
||
assert not diff.equivalent, why
|
||
|
||
|
||
def test_the_schema_expands_select_star_and_qualifies_columns():
|
||
"""给了表结构(数据源的 schema_cache)才认得出 SELECT * 和列全写出来是一回事、几张表时不写表名的列是哪张表的。"""
|
||
schema = sql_pairs.schema_cache()
|
||
star = "SELECT * FROM stores"
|
||
listed = "SELECT id, name, city, region, owner_id FROM stores"
|
||
assert explain_sql_diff(star, listed, "sqlite", schema=schema).equivalent
|
||
assert not explain_sql_diff(star, listed, "sqlite").equivalent
|
||
a = "SELECT SUM(amount) FROM orders o JOIN stores s ON s.id = o.store_id WHERE city = 'SH'"
|
||
b = "SELECT SUM(o.amount) FROM stores s JOIN orders o ON o.store_id = s.id WHERE s.city = 'SH'"
|
||
assert explain_sql_diff(a, b, "sqlite", schema=schema).equivalent
|
||
|
||
|
||
def test_an_alias_used_in_group_by_needs_the_schema_to_be_told_from_a_column():
|
||
"""WHERE、GROUP BY、HAVING 里的名字库里先认表的列、没有这一列才认别名:GROUP BY r 里的 r 是不是 orders 的列,
|
||
给了表结构才知道。给了就认得出和按 region 分组是一回事;没给不猜(DATE(created_at) AS created_at 再 GROUP BY
|
||
created_at 按的是原来的时间列,当成别名就成了按日期分组)。"""
|
||
a = "SELECT o.region, SUM(o.amount) FROM orders o GROUP BY o.region HAVING SUM(o.amount) > 100"
|
||
b = "SELECT region AS r, SUM(amount) AS s FROM orders GROUP BY r HAVING s > 100"
|
||
assert explain_sql_diff(a, b, "sqlite", schema=sql_pairs.schema_cache()).equivalent
|
||
assert not explain_sql_diff(a, b, "sqlite").equivalent
|
||
shadow = "SELECT DATE(created_at) AS created_at, COUNT(*) FROM orders GROUP BY created_at"
|
||
by_day = "SELECT DATE(created_at) AS created_at, COUNT(*) FROM orders GROUP BY DATE(created_at)"
|
||
for schema in (None, sql_pairs.schema_cache()):
|
||
assert not explain_sql_diff(shadow, by_day, "sqlite", schema=schema).equivalent
|
||
|
||
|
||
def test_equivalence_comes_with_the_level_it_was_decided_at():
|
||
diff = explain_sql_diff("SELECT COUNT(1) FROM orders", "select count(*) from ORDERS", "sqlite")
|
||
assert diff.equivalent and diff.level == "optimizer"
|
||
data = json.loads(json.dumps(diff.as_dict(), ensure_ascii=False))
|
||
assert data == {"equivalent": True, "items": [], "fallback": False, "text_diff": [], "level": "optimizer"}
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# explain_sql_diff:有把握的具体说法
|
||
# --------------------------------------------------------------------------
|
||
|
||
BASE = "SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-09-01' AND paid_at < '2026-10-01'"
|
||
|
||
|
||
def test_time_column_differs_with_catalog_names():
|
||
a = BASE
|
||
b = "SELECT SUM(amount) FROM orders WHERE created_at >= '2026-09-01' AND created_at < '2026-10-01'"
|
||
diff = explain_sql_diff(a, b, names={"orders.paid_at": "支付时间", "created_at": "下单时间"})
|
||
check_shape(diff)
|
||
[item] = diff.items
|
||
assert item.aspect == "time" and item.text == "A 按支付时间 paid_at 筛时间,B 按下单时间 created_at"
|
||
assert (item.a, item.b) == ("paid_at", "created_at")
|
||
assert item.detail == {"a": [{"table": "orders", "column": "paid_at"}],
|
||
"b": [{"table": "orders", "column": "created_at"}]}
|
||
assert texts(a, b) == ["A 按 paid_at 筛时间,B 按 created_at"], "没有中文名就只写列名"
|
||
|
||
|
||
@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 的行,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 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 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")
|
||
check_shape(diff)
|
||
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、标量子查询一样。"""
|
||
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", "在条件的子查询里")):
|
||
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 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。"""
|
||
a = "SELECT SUM(o.amount) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id WHERE o.status = 'paid'"
|
||
b = "SELECT SUM(o.amount) 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"]
|
||
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 没有这个条件"]
|
||
# 本来就是内连接、和被 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"
|
||
).equivalent
|
||
|
||
|
||
@pytest.mark.parametrize("dialect", DIALECTS)
|
||
def test_each_dialect_gives_the_same_three_findings(dialect):
|
||
q = "`" if dialect == "mysql" else '"'
|
||
a = (f"SELECT COUNT(DISTINCT o.order_id) AS {q}订单数{q} FROM orders o "
|
||
"WHERE o.paid_at >= '2026-09-01' AND o.is_deleted <> 1")
|
||
b = f"SELECT COUNT(o.order_id) AS {q}订单数{q} FROM orders o WHERE o.created_at >= '2026-09-01'"
|
||
assert texts(a, b, dialect) == ["A 按 paid_at 筛时间,B 按 created_at", "A 排除了 is_deleted = 1 的行,B 没有",
|
||
"A 对 order_id 去重计数,B 没去重"]
|
||
|
||
|
||
def test_side_labels_in_chinese_have_no_stray_spaces():
|
||
a = "SELECT COUNT(DISTINCT order_id) FROM orders WHERE paid_at >= '2026-09-01'"
|
||
b = "SELECT COUNT(order_id) FROM orders WHERE created_at >= '2026-09-01'"
|
||
assert texts(a, b, labels=LABELS) == ["原流程按 paid_at 筛时间,盲算按 created_at", "原流程对 order_id 去重计数,盲算没去重"]
|
||
|
||
|
||
def test_filters_on_real_columns_carry_the_columns_for_the_catalog():
|
||
"""筛选条件只涉及的实表列记在 detail 里:调用方据此对照数据目录里的有效行过滤。"""
|
||
[item] = explain_sql_diff("SELECT SUM(o.amount) FROM orders o WHERE o.is_deleted = 0",
|
||
"SELECT SUM(amount) FROM orders").items
|
||
assert item.aspect == "filter" and item.detail == {"columns": [{"table": "orders", "column": "is_deleted"}]}
|
||
|
||
|
||
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
|
||
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
|
||
assert item.text == ("B 关联了 stores 表(关联的条件是 orders.store_id = stores.id AND "
|
||
"(stores.city = 'BJ' OR stores.city = 'SH')),A 没有")
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# explain_sql_diff:兜底说明
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def test_nothing_specific_gives_one_fallback_item_and_the_lines_that_differ():
|
||
a = "SELECT COUNT(*) FROM orders o WHERE o.id NOT IN (SELECT order_id FROM refunds)"
|
||
b = "SELECT COUNT(*) FROM orders o WHERE NOT EXISTS (SELECT 1 FROM refunds r WHERE r.order_id = o.id)"
|
||
diff = explain_sql_diff(a, b, "sqlite")
|
||
check_shape(diff)
|
||
assert diff.fallback and diff.items == [FacetDiff("other", None, None, FALLBACK)]
|
||
assert any(line.startswith("- ") for line in diff.text_diff)
|
||
assert any(line.startswith("+ ") for line in diff.text_diff)
|
||
assert diff_facets(facets(a), facets(b)) == diff.items
|
||
|
||
|
||
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"
|
||
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]
|
||
|
||
|
||
def test_columns_inside_a_cte_are_not_compared_because_canonical_forms_prune_them():
|
||
"""规范化会去掉 CTE 里没用到的列、重排剩下的:CTE 里选出来的列不同不说具体的(原文里两边的 CTE 都选了三列,
|
||
说「A 在 t 里选出 2 列」就是错的),交给兜底说明。"""
|
||
a = ("SELECT COUNT(*) FROM (SELECT id, status, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY paid_at, id) AS rn "
|
||
"FROM orders) t WHERE t.rn = 1 AND t.status = 'paid'")
|
||
b = ("SELECT COUNT(*) FROM (SELECT id, status, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY paid_at, id) AS rn "
|
||
"FROM orders WHERE status = 'paid') t WHERE t.rn = 1")
|
||
diff = explain_sql_diff(a, b)
|
||
check_shape(diff)
|
||
assert diff.fallback and not [d for d in diff.items if "选出" in d.text]
|
||
|
||
|
||
def test_differences_that_cannot_be_checked_get_only_the_fallback():
|
||
"""两边算法不同、又判断不了是不是真的不同的(ROUND 包了一层、CASE 和 COALESCE、两种写法的时间常量),不说具体的:
|
||
「A 算的是 X,B 算的是 Y」读起来就是两边不同,说错了比不说更糟。"""
|
||
for a, b in (("SELECT SUM(amount) FROM orders", "SELECT ROUND(SUM(amount), 2) FROM orders"),
|
||
("SELECT SUM(COALESCE(discount, 0)) FROM orders",
|
||
"SELECT SUM(CASE WHEN discount IS NULL THEN 0 ELSE discount END) FROM orders"),
|
||
("SELECT COUNT(*) FROM orders WHERE paid_at >= '2026-09-01'",
|
||
"SELECT COUNT(*) FROM orders WHERE paid_at >= DATE('2026-09-01')")):
|
||
diff = explain_sql_diff(a, b, "sqlite")
|
||
check_shape(diff)
|
||
assert diff.fallback and diff.items == [FacetDiff("other", None, None, FALLBACK)], (a, b, diff.items)
|
||
|
||
|
||
def test_text_diff_lists_at_most_forty_lines():
|
||
cols_a = ", ".join(f"SUM(amount * {i}) AS c{i}" for i in range(60))
|
||
cols_b = ", ".join(f"SUM(amount * {i + 100}) AS c{i}" for i in range(60))
|
||
diff = explain_sql_diff(f"SELECT {cols_a} FROM orders", f"SELECT {cols_b} FROM orders")
|
||
assert not diff.equivalent and len(diff.text_diff) == 40
|
||
|
||
|
||
@pytest.mark.parametrize("a,b", [
|
||
("SELECT FROM WHERE", BASE),
|
||
(BASE, "SELECT 1; SELECT 2"),
|
||
("", BASE),
|
||
(None, BASE),
|
||
(BASE, "UPDATE orders SET amount = 0"),
|
||
])
|
||
def test_an_unparsable_side_gives_one_parse_item_and_never_raises(a, b):
|
||
diff = explain_sql_diff(a, b, "sqlite")
|
||
assert not diff.equivalent and len(diff.items) == 1 and diff.items[0].aspect == "parse"
|
||
assert "没有逐项比较" in diff.items[0].text
|
||
json.dumps(diff.as_dict(), ensure_ascii=False)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# diff_facets:照旧收 facets,交给 explain_sql_diff
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def test_diff_facets_returns_the_items_of_explain_sql_diff():
|
||
pairs = [(BASE, "SELECT SUM(amount) FROM orders"),
|
||
("SELECT COUNT(DISTINCT order_id) FROM orders", "SELECT COUNT(order_id) FROM orders"),
|
||
("SELECT COUNT(*) FROM orders o WHERE o.id NOT IN (SELECT order_id FROM refunds)",
|
||
"SELECT COUNT(*) FROM orders o WHERE NOT EXISTS (SELECT 1 FROM refunds r WHERE r.order_id = o.id)")]
|
||
for a, b in pairs:
|
||
for dialect in DIALECTS:
|
||
expected = explain_sql_diff(a, b, dialect, labels=LABELS, names={"paid_at": "支付时间"}).items
|
||
assert diff_facets(facets(a, dialect), facets(b, dialect), labels=LABELS,
|
||
names={"paid_at": "支付时间"}) == expected
|
||
|
||
|
||
def test_diff_facets_passes_the_schema_given_to_facets():
|
||
schema = sql_pairs.schema_cache()
|
||
a = "SELECT SUM(amount) FROM orders o JOIN stores s ON s.id = o.store_id WHERE city = 'SH'"
|
||
b = "SELECT SUM(o.amount) FROM stores s JOIN orders o ON o.store_id = s.id WHERE s.city = 'SH'"
|
||
assert diff_facets(facets(a, "sqlite", schema=schema), facets(b, "sqlite", schema=schema)) == []
|
||
assert diff_facets(facets(a, "sqlite"), facets(b, "sqlite")) != []
|
||
|
||
|
||
def test_an_unparsable_side_is_reported_once_without_item_by_item_comparison():
|
||
[diff] = diff_facets(facets("SELECT FROM WHERE"), facets(BASE))
|
||
assert diff.aspect == "parse" and diff.text.startswith("A:SQL 无法解析") and "没有逐项比较" in diff.text
|
||
assert diff.b is None
|
||
|
||
|
||
def test_facets_without_the_original_sql_cannot_be_called_equivalent():
|
||
"""不是 facets 算出来的 Facets(没记原文)比不了原文:facets 记的结构不全,两份一样也不说明 SQL 一样,只有同一个
|
||
对象才算没有不同,否则给兜底说明,不猜。"""
|
||
f = Facets(dialect="sqlite")
|
||
assert diff_facets(f, f) == []
|
||
for other in (Facets(dialect="sqlite"), Facets(dialect="sqlite", limit="10")):
|
||
assert diff_facets(f, other) == [FacetDiff("other", None, None, FALLBACK)]
|
||
|
||
|
||
def test_catalog_labels_come_from_visible_catalog_items():
|
||
notes = {
|
||
"orders": {"columns": {"paid_at": {"label": {"value": "支付时间", "status": "confirmed"}},
|
||
"status": {"label": {"value": "状态", "status": "proposed"}},
|
||
"amount": {"label": {"value": "错的名字", "status": "rejected"}}}},
|
||
"refunds": {"columns": {"status": {"label": {"value": "退款状态", "status": "confirmed"}}}},
|
||
}
|
||
labels = catalog_labels(notes)
|
||
assert labels["orders.paid_at"] == labels["paid_at"] == "支付时间"
|
||
assert labels["orders.status"] == "状态" and labels["refunds.status"] == "退款状态"
|
||
assert "status" not in labels, "两张表叫法不同的列不按光列名记"
|
||
assert "orders.amount" not in labels and "amount" not in labels, "被驳回的不算"
|
||
|
||
|
||
def test_a_facets_object_compares_with_itself_cleanly():
|
||
f = facets("WITH v AS (SELECT * FROM orders WHERE is_deleted = 0) SELECT COUNT(DISTINCT order_id) FROM v")
|
||
assert isinstance(f, Facets) and diff_facets(f, f) == []
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 第 2 轮复验的反例:等价的结论、具体说法的可靠性
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.parametrize("name,dialect,a,b", sql_pairs.REGRESSIONS_R2,
|
||
ids=[f"{n}-{d}" for n, d, _, _ in sql_pairs.REGRESSIONS_R2])
|
||
def test_round_two_counterexamples_are_never_equivalent(name, dialect, a, b):
|
||
"""第 2 轮复验在 PostgreSQL / MySQL 上跑出结果不同的对:常量折叠(Decimal 和浮点数)、取两次负、HAVING 挪进 WHERE
|
||
(不分大小写的排序规则下同组的值各不一样)、ORDER BY 里有聚合的 EXISTS / IN、列名表、合并的各支调换、BETWEEN
|
||
SYMMETRIC、有并列的取行、DISTINCT ON。不管给不给表结构、谁先谁后,一律不判等价。"""
|
||
for schema in (None, sql_pairs.schema_cache()):
|
||
for x, y in ((a, b), (b, a)):
|
||
diff = explain_sql_diff(x, y, dialect, schema=schema)
|
||
check_shape(diff)
|
||
assert not diff.equivalent, name
|
||
|
||
|
||
@pytest.mark.parametrize("name,dialect,a,b", sql_pairs.DIALECT_SAME_R2,
|
||
ids=[f"{n}-{d}" for n, d, _, _ in sql_pairs.DIALECT_SAME_R2])
|
||
def test_round_two_rewrites_are_recognized(name, dialect, a, b):
|
||
"""FETCH FIRST 和 LIMIT、MySQL 列名的大小写、各方言 IS NOT NULL / NOT LIKE 的不同解析形状、MySQL 的 IF 和 CASE:
|
||
认得出是一回事(以前把 FETCH 读成「不限行数」)。"""
|
||
diff = explain_sql_diff(a, b, dialect)
|
||
check_shape(diff)
|
||
assert diff.equivalent, (name, diff.items)
|
||
|
||
|
||
@pytest.mark.parametrize("name,dialect,a,b", sql_pairs.KNOWN_UNRECOGNIZED,
|
||
ids=[f"{n}-{d}" for n, d, _, _ in sql_pairs.KNOWN_UNRECOGNIZED])
|
||
def test_equivalences_left_unrecognized_on_purpose_get_only_the_fallback(name, dialect, a, b):
|
||
"""意思一样、规范化故意不认的(要假设「相等就是一样」才认得出):只给兜底说明,不说具体的不同。"""
|
||
for schema in (None, sql_pairs.schema_cache()):
|
||
diff = explain_sql_diff(a, b, dialect, schema=schema)
|
||
check_shape(diff)
|
||
assert diff.equivalent or all(d.aspect == "other" for d in diff.items), (name, diff.items)
|
||
|
||
|
||
@pytest.mark.parametrize("dialect", DIALECTS)
|
||
@pytest.mark.parametrize("a,b", [
|
||
("SELECT COUNT(*) FROM orders o WHERE o.paid_at >= '2026-09-01' AND EXISTS "
|
||
"(SELECT 1 FROM refunds r WHERE r.order_id = o.id)",
|
||
"SELECT COUNT(*) FROM orders o WHERE EXISTS (SELECT 1 FROM refunds r WHERE r.order_id = o.id "
|
||
"AND o.paid_at >= '2026-09-01')"),
|
||
("SELECT COUNT(*) FROM orders o WHERE o.status = 'paid' AND EXISTS (SELECT 1 FROM refunds r WHERE r.order_id = o.id)",
|
||
"SELECT COUNT(*) FROM orders o WHERE EXISTS (SELECT 1 FROM refunds r WHERE r.order_id = o.id AND o.status = 'paid')"),
|
||
("SELECT COUNT(*) FROM orders WHERE status = 'paid' AND id IN (SELECT order_id FROM refunds)",
|
||
"SELECT COUNT(*) FROM orders WHERE id IN (SELECT order_id FROM refunds WHERE status = 'paid')"),
|
||
("SELECT COUNT(*) FROM users u WHERE u.city = 'BJ' AND EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)",
|
||
"SELECT COUNT(*) FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id AND u.city = 'BJ')"),
|
||
], ids=["exists-time", "exists-status", "in-outer-column", "exists-city"])
|
||
def test_a_condition_moved_into_a_correlated_subquery_is_not_called_missing(dialect, a, b):
|
||
"""条件挪进了相关子查询(引用的还是外层那一行):两条意思一样,B 是有这个条件的。以前说「B 没有按时间筛选」
|
||
「B 没有这个条件」,现在说不清就只给兜底说明。"""
|
||
for schema in (None, sql_pairs.schema_cache()):
|
||
for x, y in ((a, b), (b, a)):
|
||
diff = explain_sql_diff(x, y, dialect, schema=schema)
|
||
check_shape(diff)
|
||
assert all(d.aspect == "other" for d in diff.items), diff.items
|
||
|
||
|
||
@pytest.mark.parametrize("dialect", DIALECTS)
|
||
def test_time_filters_written_another_way_are_never_called_no_time_filter(dialect):
|
||
"""时间条件换了写法(拆成两段、IN 和 OR、多一个下界、LIKE 和区间):另一边是按时间筛了的,不能说「没有按时间筛选」,
|
||
也不能说成两个范围不同。"""
|
||
base = "SELECT COUNT(*) FROM orders WHERE paid_at >= '2026-09-01' AND paid_at < '2026-10-01'"
|
||
for other in ("SELECT COUNT(*) FROM orders WHERE (paid_at >= '2026-09-01' AND paid_at < '2026-09-16') OR "
|
||
"(paid_at >= '2026-09-16' AND paid_at < '2026-10-01')",
|
||
"SELECT COUNT(*) FROM orders WHERE paid_at >= '2026-09-01' AND paid_at < '2026-10-01' "
|
||
"AND paid_at > '2026-08-01'",
|
||
"SELECT COUNT(*) FROM orders WHERE paid_at LIKE '2026-09%'"):
|
||
diff = explain_sql_diff(base, other, dialect)
|
||
check_shape(diff)
|
||
assert not any("没有按时间筛选" in d.text or d.aspect == "time_range" for d in diff.items), diff.items
|
||
diff = explain_sql_diff("SELECT COUNT(*) FROM orders WHERE paid_at IN ('2026-09-01', '2026-09-30')",
|
||
"SELECT COUNT(*) FROM orders WHERE paid_at = '2026-09-01' OR paid_at = '2026-09-30'", dialect)
|
||
assert all(d.aspect == "other" for d in diff.items)
|
||
|
||
|
||
def test_except_with_its_sides_swapped_is_not_one_time_difference():
|
||
"""EXCEPT 两边对调,外加一边换了时间列:以前只说时间列不同、还说全了(会写进数据目录)。EXCEPT 按先后逐支比,对不上
|
||
就只给兜底说明;子查询、CTE、IN 子查询里套着的也一样。"""
|
||
a = "SELECT user_id FROM orders WHERE paid_at >= '2026-09-01' EXCEPT SELECT user_id FROM refunds"
|
||
b = "SELECT user_id FROM refunds EXCEPT SELECT user_id FROM orders WHERE created_at >= '2026-09-01'"
|
||
for x, y in ((a, b), (f"SELECT COUNT(*) FROM ({a}) t", f"SELECT COUNT(*) FROM ({b}) t"),
|
||
(f"WITH e AS ({a}) SELECT COUNT(*) FROM e", f"WITH e AS ({b}) SELECT COUNT(*) FROM e"),
|
||
(f"SELECT COUNT(*) FROM users WHERE id IN ({a})", f"SELECT COUNT(*) FROM users WHERE id IN ({b})")):
|
||
for dialect in ("sqlite", "postgres"):
|
||
diff = explain_sql_diff(x, y, dialect)
|
||
check_shape(diff)
|
||
assert diff.fallback and all(d.aspect == "other" for d in diff.items), (x, diff.items)
|
||
|
||
|
||
@pytest.mark.parametrize("a,b", [
|
||
(sql_pairs.PAIRS_BY_ID["diff:r2-in-not-in-swapped"].a, sql_pairs.PAIRS_BY_ID["diff:r2-in-not-in-swapped"].b),
|
||
(sql_pairs.PAIRS_BY_ID["diff:r2-exists-not-exists-swapped"].a,
|
||
sql_pairs.PAIRS_BY_ID["diff:r2-exists-not-exists-swapped"].b),
|
||
(sql_pairs.PAIRS_BY_ID["diff:r2-in-left-column-swapped"].a, sql_pairs.PAIRS_BY_ID["diff:r2-in-left-column-swapped"].b),
|
||
], ids=["in-not-in", "exists-not-exists", "left-column"])
|
||
def test_subqueries_swapped_between_in_and_not_in_are_not_explained_both_ways(a, b):
|
||
"""IN 和 NOT IN(EXISTS 和 NOT EXISTS、id IN 和 referrer_id IN)两个子查询对调:以前配错了对,说出一对互相矛盾的话、
|
||
还把 NOT IN 说成「IN 子查询」。子查询要外面的写法(含 NOT、IN 左边的列)一模一样才配对,配不上就只给兜底。"""
|
||
for schema in (None, sql_pairs.schema_cache()):
|
||
diff = explain_sql_diff(a, b, "sqlite", schema=schema, labels=LABELS)
|
||
check_shape(diff)
|
||
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),不说具体的;给了表结构、
|
||
或者写了表名,就说得出。"""
|
||
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
|
||
|
||
|
||
def test_a_swapped_table_keeps_each_sides_own_columns():
|
||
"""换了一张表:盲算的 created_at 是 users 的(注册时间),以前把 B 的表改名成 A 的表再比,中文名和 detail 都归到了
|
||
orders。现在两边各用各的表说。"""
|
||
names = {"orders.created_at": "下单时间", "users.created_at": "注册时间", "orders.paid_at": "支付时间"}
|
||
diff = explain_sql_diff("SELECT COUNT(*) FROM orders WHERE paid_at >= '2026-09-01'",
|
||
"SELECT COUNT(*) FROM users WHERE created_at >= '2026-09-01'", "sqlite", names=names,
|
||
labels=LABELS)
|
||
check_shape(diff)
|
||
assert [(d.aspect, d.text) for d in diff.items] == [
|
||
("tables", "原流程查的是 orders 表,盲算查的是 users 表"),
|
||
("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"]
|
||
|
||
|
||
def test_ordering_that_decides_which_row_distinct_on_keeps_is_not_just_ordering():
|
||
"""DISTINCT ON 留哪一行由 ORDER BY 决定:排序不同就是留下的行不同,不能说成「只是排序不同」(aspect=order)。"""
|
||
a = "SELECT DISTINCT ON (user_id) user_id, status FROM orders ORDER BY user_id, created_at DESC NULLS LAST, id"
|
||
b = "SELECT DISTINCT ON (user_id) user_id, status FROM orders ORDER BY user_id, paid_at DESC NULLS LAST, id"
|
||
diff = explain_sql_diff(a, b, "postgres")
|
||
check_shape(diff)
|
||
assert not diff.equivalent and all(d.aspect != "order" for d in diff.items)
|
||
|
||
|
||
def test_a_table_name_differing_only_in_case_is_not_called_another_table_in_mysql():
|
||
"""MySQL 的表名在 Linux 上分大小写、在 macOS / Windows 上不分:BILLS 和 bills 不判等价,也不说「查的是另一张表」。"""
|
||
diff = explain_sql_diff("SELECT store_id, SUM(amount) AS s FROM orders WHERE is_deleted = 0 GROUP BY store_id",
|
||
"select STORE_ID, sum( AMOUNT ) as s from ORDERS where IS_DELETED = 0 group by store_id",
|
||
"mysql")
|
||
check_shape(diff)
|
||
assert all(d.aspect == "other" for d in diff.items)
|
||
|
||
|
||
def test_redundant_or_constant_conditions_are_not_claimed():
|
||
"""多写一个本来就成立的条件(1 = 1、已经被 > 筛掉的空值、关联键上的 IS NOT NULL、分组后 COUNT(*) > 0):两条意思一样,
|
||
不说「A 只保留 … 的行,B 没有这个条件」。1 = 1 直接认成等价。"""
|
||
assert explain_sql_diff("SELECT COUNT(*) FROM orders WHERE 1 = 1 AND status = 'paid'",
|
||
"SELECT COUNT(*) FROM orders WHERE status = 'paid'", "sqlite").equivalent
|
||
for dialect in DIALECTS:
|
||
for a, b in (("SELECT SUM(tax) FROM orders WHERE tax > 1 AND tax IS NOT NULL", "SELECT SUM(tax) FROM orders WHERE tax > 1"),
|
||
("SELECT SUM(o.amount) FROM orders o JOIN users u ON u.id = o.user_id WHERE o.user_id IS NOT NULL",
|
||
"SELECT SUM(o.amount) FROM orders o JOIN users u ON u.id = o.user_id"),
|
||
("SELECT store_id, SUM(amount) FROM orders GROUP BY store_id HAVING COUNT(*) > 0",
|
||
"SELECT store_id, SUM(amount) FROM orders GROUP BY store_id"),
|
||
("SELECT user_id FROM orders GROUP BY user_id HAVING COUNT(*) > 1",
|
||
"SELECT user_id FROM orders GROUP BY user_id HAVING COUNT(*) >= 2"),
|
||
("SELECT COUNT(tax) FROM orders", "SELECT COUNT(*) FROM orders WHERE tax IS NOT NULL"),
|
||
("SELECT COUNT(r.id) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id",
|
||
"SELECT COUNT(r.id) FROM orders o JOIN refunds r ON r.order_id = o.id")):
|
||
diff = explain_sql_diff(a, b, dialect)
|
||
check_shape(diff)
|
||
assert all(d.aspect == "other" for d in diff.items), (dialect, a, diff.items)
|
||
|
||
|
||
def test_single_item_in_list_becomes_equality_only_for_a_constant():
|
||
"""IN 列表只有一项:是常量才写成 =;是一列的话 SQLite 里 a IN (b) 是 a = +b(右边不带类型亲和),和 a = b 不同。"""
|
||
assert explain_sql_diff("SELECT COUNT(*) FROM orders WHERE status IN ('paid')",
|
||
"SELECT COUNT(*) FROM orders WHERE status = 'paid'", "sqlite").equivalent
|
||
assert not explain_sql_diff("SELECT id FROM t WHERE a IN (b)", "SELECT id FROM t WHERE a = b", "sqlite").equivalent
|
||
|
||
|
||
@pytest.mark.parametrize("dialect", DIALECTS)
|
||
@pytest.mark.parametrize("a,b", [
|
||
("SELECT id FROM orders ORDER BY amount DESC LIMIT 5 OFFSET 10", "SELECT id FROM orders ORDER BY amount DESC LIMIT 20 OFFSET 0"),
|
||
("WITH p(uid, total) AS (SELECT user_id, SUM(amount) FROM orders WHERE amount > 10 GROUP BY user_id) "
|
||
"SELECT COUNT(*) FROM p WHERE total > 100",
|
||
"WITH p(uid, total) AS (SELECT user_id, SUM(amount) FROM orders WHERE amount > 50 GROUP BY user_id) "
|
||
"SELECT COUNT(*) FROM p WHERE total > 999"),
|
||
("SELECT COUNT(*) FROM orders WHERE amount = 0.1 + 0.2 AND status IN ('paid', 'done')",
|
||
"SELECT COUNT(*) FROM orders WHERE amount = 0.5 + 0.25 AND status IN ('open', 'void')"),
|
||
("SELECT COUNT(*) FROM users WHERE id NOT IN (SELECT user_id FROM orders WHERE created_at >= '2026-09-01')",
|
||
"SELECT COUNT(*) FROM users WHERE id NOT IN (SELECT user_id FROM orders WHERE created_at >= '2026-10-01')"),
|
||
], ids=["limit-offset", "cte-column-list", "arith-and-in-list", "not-in"])
|
||
def test_only_literals_changed_stays_the_same_template_after_the_rewrite(dialect, a, b):
|
||
"""normalize_sql「只改了参数」的判断不受这次改动影响:只改字面量的照旧同一个模板(等价判定照样说不等价)。"""
|
||
na, nb = normalize_sql(a, dialect), normalize_sql(b, dialect)
|
||
assert na.parse_error is None and na.same_shape(nb) and na.literals != nb.literals
|
||
assert not explain_sql_diff(a, b, dialect).equivalent
|
||
|
||
|
||
@pytest.mark.parametrize("dialect", DIALECTS)
|
||
@pytest.mark.parametrize("a,b", [
|
||
("WITH p(uid, total) AS (SELECT user_id, SUM(amount) FROM orders GROUP BY user_id) SELECT COUNT(*) FROM p WHERE total > 1",
|
||
"WITH p(total, uid) AS (SELECT user_id, SUM(amount) FROM orders GROUP BY user_id) SELECT COUNT(*) FROM p WHERE total > 1"),
|
||
("SELECT COUNT(*) FROM users WHERE id IN (SELECT user_id FROM orders)",
|
||
"SELECT COUNT(*) FROM users WHERE id NOT IN (SELECT user_id FROM orders)"),
|
||
("SELECT COUNT(*) FROM orders WHERE amount = 0.1 + 0.2", "SELECT COUNT(*) FROM orders WHERE amount = 0.3"),
|
||
("SELECT u.city, COUNT(*) FROM users u JOIN orders o ON o.user_id = u.id GROUP BY u.city",
|
||
"SELECT u.city, COUNT(*) FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.city"),
|
||
], ids=["cte-column-list-order", "in-vs-not-in", "folded-constant", "join-kind"])
|
||
def test_a_structural_change_is_still_a_different_template(dialect, a, b):
|
||
"""结构变了的(列名表的先后、IN 和 NOT IN、常量写成一个数、连接类型)照旧不是同一个模板。"""
|
||
assert not normalize_sql(a, dialect).same_shape(normalize_sql(b, dialect))
|