mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:01:58 +00:00
c55b1983f6
为什么重做:过去五轮按方面归纳任意 SQL,每轮修好一批又冒出一批(第 5 轮还回退过「只在 IN / EXISTS /
标量子查询里用到的 CTE 里的不同」),收不拢。改成先判两条 SQL 是不是一回事,不是的话只说有把握的不同。
做了什么(backend/app/data/sql_semantics.py):
- 新增 explain_sql_diff(a, b, dialect=None, *, schema=None, names=None, labels=("A", "B")) -> SqlDiff
(equivalent、items、fallback、text_diff、level)。两边各自规范化,每一步只做保持语义的变换;同一级的两个
规范形式语法树逐节点一样才算等价(不比生成的 SQL:sqlglot 生成 SQLite 时把 CAST(x AS DATE) 写成 DATE(x)),
否则一律不等价、条目一定不为空。三级:optimizer(sqlglot 的 qualify、eliminate_subqueries、merge_subqueries、
eliminate_ctes、pushdown_projections、simplify,给了数据源的 schema_cache 就按它认列、展开 SELECT *)、
light(不认列)、parsed(只统一大小写、空白、引号)。
- 不用的 sqlglot 规则:unnest_subqueries(OR 里的标量子查询改成 CROSS JOIN,子查询没有行时整条都没了)、
canonicalize(日期比较加 CAST,SQLite 里 CAST(… AS DATE) 取的是数字)、normalize(合取范式人读不懂);
simplify 去掉同一列上比较的合并、字符串常量比较、等式移项、DATE_TRUNC 改区间(取决于列类型、排序规则、方言);
merge_subqueries 不并外连接可空一侧算出来的列。
- 本模块自己的几步:去括号、ASC、OFFSET 0,COUNT(1)→COUNT(*),IN 列表去重排序,EXISTS 写成 SELECT 1,
DISTINCT↔GROUP BY,原样透列的子查询换掉,RIGHT JOIN→LEFT JOIN,WHERE 筛掉了没关联上的行的外连接写成内连接,
内连接写成逗号连接,起规范的名字(CTE 叫 cte1…、CTE 的列叫 c1…、别名按表名和层数,同名来源排列着试),
交换律成立的地方排序。
- 具体说法的门槛:两段读的来源对得上(一样,或只差一处说得清的)才逐项比时间列、筛选条件、聚合与去重计数、
选出来的列、分组键、HAVING、取行和排序、UNION 还是 UNION ALL;条件里的子查询两边各一个、外面一样的往里比
(第 5 轮回退的 IN-CTE 反例在这里说出来);原文是 LEFT / RIGHT JOIN、规范化成内连接或调了方向的照原文说;
牵涉 CTE 算出来的列、自己关联自己、说不准的子查询的不说具体的;两边给人看一样的不说。
- 兜底:一条具体说法都没有时给 aspect="other"「两条 SQL 的写法不同,未能归纳成具体差异,请对照原文」,只覆盖
一部分时追加「另有未能归纳的差异,请对照原文」,fallback 为真;text_diff 是 difflib 比两边 sqlglot pretty
的输出,最多 40 行。
- diff_facets 签名不变,交给 explain_sql_diff 比两份 facets 记下的原文,返回它的 items。normalize_sql 判
「只有参数不同」不变,补了测试。
守门(tests/test_sql_equivalence.py、tests/fixtures/sql_pairs.py):虚构库(users、stores、orders、
orders_archive、order_items、refunds、payments、shipments)上 5 组固定种子的数据加 1 组极端数据(空值、重复、
边界值、悬空外键、空表)。305 对 SQL(113 对已知等价、192 对不许判等价,含复验者以前造的、在这几组数据上结果
不同的 91 对)在不给和给表结构两种情况下各判一次:结果不同的绝不判等价,标 diff 的不判等价,换个先后问结论
一样。旧测试五轮复核里报过不同的 234 对、意思一样的 56 对(SQLite / PostgreSQL / MySQL)原样收进来只查结论。
压测(变异 + 等价改写,每个种子约 1.87 万条)时找到、已经堵上的不保持语义的改写:
- merge_subqueries 把外连接可空一侧的常量列并进外层;merge 时同一个 CTE 并进外层和相关子查询两处互相遮住;
- 没给表结构时 qualify 把 WHERE / GROUP BY / HAVING 里和列别名同名的名字当成别名(SQLite 先认列),把 SELECT
列表里和前面别名同名的名字展开(库里不认 SELECT 列表里的别名):这时不出 optimizer 这一级;
- UNION(去重)、INTERSECT 调换各支:去重留先出现的值(SQLite 里 2 和 2.0、MySQL 不分大小写时 'SH' 和 'sh');
UNION ALL 带 LIMIT、按名字 ORDER BY 的也要看哪支在前,SQLite 合并结果的排序规则取最左一支:现在只在调换
不改变结果时排;SQLite 从子查询读 UNION 的类型亲和;
- 一元加号在 SQLite 里去掉类型亲和、解析时却丢了;ORDER BY 别名换成表达式后撞上另一个别名;
- 没有 ORDER BY 的 LIMIT、聚合里既不分组也不聚合的列:调换来源、DISTINCT 写成 GROUP BY、RIGHT 写成 LEFT
都会改变实际取到的行,这样的一段不做;两边都写了 COLLATE 的比较不对调。
验证:tests/test_sql_semantics.py + tests/test_verify_agent.py 519 passed,tests/test_sql_equivalence.py
307 passed,全量 -n 8:7706 passed、11 skipped。已知等价的 113 对认出 111 对(98%),不给表结构时 109 对
(96%)。压测 4 个种子各约 1.5 万条可执行:规范形式相同的组执行结果全一致,能执行的规范形式结果和原文一致
(带 CAST 的不比:生成 SQLite 时写法变了),生成的等价改写认出 2113/2115;复验者留下的 1201 对里可执行的 440 对没有误判等价,结果相同的 302 对认出 276 对。
仍做不到的(只给兜底或认不出等价)见模块说明:IN 子查询和 EXISTS 互换、窗口函数前后按分区键筛、三项以上的
加乘换结合、和方言有关的同义写法、SQLite 里套一层子查询的 UNION、去重 UNION 调换各支、LATERAL、递归 CTE、
没给表结构时别名和列同名的。假设列没有声明非默认的排序规则(SQLite 比较两列取左边那列的,表结构里看不到)。
860 lines
56 KiB
Python
860 lines
56 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 SUM(amount) FROM orders", "SELECT ROUND(SUM(amount), 2) FROM orders",
|
||
[("expression", "A 算的是 SUM(amount),B 算的是 ROUND(SUM(amount), 2)")]),
|
||
("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
|
||
子查询,往里比,说得出是哪个条件。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)", "在 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('2026-09-01') 冒充原文。几个条件连起来时 OR 带上括号。"""
|
||
[item] = explain_sql_diff("SELECT COUNT(*) FROM orders WHERE paid_at >= '2026-09-01'",
|
||
"SELECT COUNT(*) FROM orders WHERE paid_at >= CAST('2026-09-01' AS DATE)").items
|
||
assert "CAST('2026-09-01' AS DATE)" in item.text and "DATE('2026-09-01')" 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_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) == []
|