mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:21:58 +00:00
d5e9ef39ae
上一轮只去掉了条件里子查询的表别名,子查询整段写进外层条件的 key 时还带着各自起的名字:标量子查询、IN 子查询给选出来的列起的名字(AVG(amount) AS avg_amount),子查询里 FROM 的子查询别名(s 换成 agg),以及外层引用 CTE、FROM 里的子查询算出来的列时的来源名(派生来源的 key 就是别名)。CTE 改个名,外层的 total > 100 就被报成两边各有一个、互相矛盾的条件;双盲复算里盲算的名字本来就是自己起的,会被说成换了口径。 现在派生来源(引用 CTE 的表、FROM / JOIN 里的子查询和 LATERAL 子查询)按位置编号作比较用的 key:从外往里一层一层地编,CTE 在第一次用到的地方编号,写成 CTE 还是 FROM 里的子查询、CTE 定义的先后都不影响;给人看的写法照写名字。不拿里面那段查询的写法作 key:里面差一个条件,外层按它的列写的条件就全跟着变,同一处不同会报好几遍。条件里的子查询写进外层 key 时,引用 CTE 的表、子查询(含 LATERAL)、子查询自带的 CTE 定义都换成编号,只看取值的子查询(IN、EXISTS、标量子查询)去掉选出来的列名;FROM 里的子查询、CTE 定义选出来的列名留着,外层按名字引用它们。LATERAL 的别名原来没登记成来源,按「别名写错了」照写,一并改成派生来源。 编号只看位置:一边多出一个排在前面的 CTE(比如盲算先关联一个筛 VIP 的 CTE),后面的编号就错开了,同名的 CTE 反倒比原来按名字比时多报出两条自相矛盾的话。Part 加 anon(派生来源一律写「@」、不写编号),diff_facets 每个方面先按编号比,比不上的再按 anon 一对一地配,配上的不算不同;比较两边的先后先按 anon 排、分不出再按编号;连接先配完全一样的再按关联的表配,免得错开的两个 CTE 被配成一对、说成关联条件不同。编号对得上时照样分得清是哪个 CTE 的同名列(p.total > r.total 写反成 r.total > p.total 照报)。筛选条件配对改按列的比较写法(Part.column_keys,CTE、子查询的列写「@.列」),CTE 名不同、阈值不同时说成「按 total > 100 筛选,盲算按 total > 200 筛选」,不再拆成两条各说各的。 仍按名字比的只有 CTE、子查询选出来、外层按名字引用的列(一边叫 total、一边叫 sum_amt),写进了模块说明。
715 lines
44 KiB
Python
715 lines
44 KiB
Python
"""SQL 语义(data/sql_semantics.py):两条 SQL 算的是不是一回事,不是的话差在哪。
|
||
|
||
- normalize_sql:字面量换成占位符后的规范文本 + 字面量。只有字面量不同的两条 SQL 规范文本相同(定时运行每期的日期
|
||
不同,不能算模板变了);关键字大小写、空白、注释也不算不同;解析不了的退回逐词规范化,照样认得出只改了字面量
|
||
- facets:表、连接(类型 / 关联进来的表 / ON 条件)、WHERE 合取式、时间条件(列 / 运算符 / 取值)、分组、聚合
|
||
(函数 / 参数 / 去重)、HAVING、取哪几行(各段的 LIMIT / OFFSET / 排序)。别名换成真实表名(条件里套着的子查询、
|
||
HAVING 里的别名也是),CTE、子查询按位置编号、不按起的名字比,条件里的子查询选出来的列名不算;比较的两边排好
|
||
顺序;解析不了带 parse_error,不抛
|
||
- diff_facets:每处不同一句中文:「A 按支付时间 paid_at 筛时间,B 按下单时间 created_at」「A 排除了
|
||
is_deleted = 1 的行,B 没有」「A 对 order_id 去重计数,B 没去重」……写法不同、口径相同的不报,一边多出一个排在
|
||
前面的 CTE、编号错开了也不报
|
||
|
||
夹具全是虚构的表:订单 orders、退款 refunds、门店 stores、用户 users、付款 payments,以及一张中文表「订单」。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
|
||
import pytest
|
||
|
||
from app.data.sql_semantics import (
|
||
Facets,
|
||
catalog_labels,
|
||
diff_facets,
|
||
facets,
|
||
normalize_sql,
|
||
)
|
||
|
||
DIALECTS = ("sqlite", "mysql", "postgres")
|
||
|
||
|
||
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)]
|
||
|
||
|
||
def aspects(a: str, b: str, dialect: str = "sqlite") -> list[str]:
|
||
return [d.aspect for d in diff_facets(facets(a, dialect), facets(b, dialect))]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 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"] == ">="
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# diff_facets:每个方面一对
|
||
# --------------------------------------------------------------------------
|
||
|
||
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 = "SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-09-01' AND paid_at < '2026-10-01'"
|
||
b = "SELECT SUM(amount) FROM orders WHERE created_at >= '2026-09-01' AND created_at < '2026-10-01'"
|
||
[diff] = diff_facets(facets(a), facets(b), names={"orders.paid_at": "支付时间", "created_at": "下单时间"})
|
||
assert diff.aspect == "time"
|
||
assert diff.text == "A 按支付时间 paid_at 筛时间,B 按下单时间 created_at"
|
||
assert (diff.a, diff.b) == ("paid_at", "created_at")
|
||
assert diff.detail == {"a": [{"table": "orders", "column": "paid_at"}],
|
||
"b": [{"table": "orders", "column": "created_at"}]}
|
||
assert texts(a, b) == ["A 按 paid_at 筛时间,B 按 created_at"], "没有中文名就只写列名"
|
||
|
||
|
||
def test_time_filter_on_one_side_only():
|
||
assert texts(BASE, "SELECT SUM(amount) FROM orders") == ["A 按 paid_at 筛时间,B 没有按时间筛选"]
|
||
assert texts("SELECT SUM(amount) FROM orders", BASE) == ["A 没有按时间筛选,B 按 paid_at 筛时间"]
|
||
|
||
|
||
def test_same_time_column_different_range():
|
||
other = "SELECT SUM(amount) FROM orders WHERE paid_at BETWEEN '2026-09-01' AND '2026-09-30'"
|
||
[diff] = diff_facets(facets(BASE), facets(other))
|
||
assert diff.aspect == "time_range"
|
||
assert diff.text == ("A 的时间范围是 paid_at >= '2026-09-01' AND paid_at < '2026-10-01',"
|
||
"B 是 paid_at BETWEEN '2026-09-01' AND '2026-09-30'")
|
||
|
||
|
||
def test_only_literals_differ_is_still_a_different_range():
|
||
"""规范文本相同(同一个模板),但比口径时日期不同就是不同的范围:两个函数回答的是两个问题。"""
|
||
later = BASE.replace("2026-09-01", "2026-09-08").replace("2026-10-01", "2026-10-08")
|
||
assert normalize_sql(BASE).same_shape(normalize_sql(later))
|
||
assert aspects(BASE, later) == ["time_range"]
|
||
|
||
|
||
def test_an_exclusion_on_one_side():
|
||
assert texts("SELECT SUM(amount) FROM orders WHERE is_deleted <> 1", "SELECT SUM(amount) FROM orders") == \
|
||
["A 排除了 is_deleted = 1 的行,B 没有"]
|
||
assert texts("SELECT SUM(amount) FROM orders", "SELECT SUM(amount) FROM orders WHERE refund_id IS NOT NULL") == \
|
||
["B 排除了 refund_id 为空的行,A 没有"]
|
||
|
||
|
||
def test_a_kept_condition_on_one_side():
|
||
assert texts("SELECT SUM(amount) FROM orders", "SELECT SUM(amount) FROM orders WHERE status = 'paid'") == \
|
||
["B 只保留 status = 'paid' 的行,A 没有这个条件"]
|
||
|
||
|
||
def test_two_conditions_on_the_same_column_are_paired():
|
||
[diff] = diff_facets(facets("SELECT SUM(amount) FROM orders WHERE status = 'paid'"),
|
||
facets("SELECT SUM(amount) FROM orders WHERE status IN ('paid', 'shipped')"))
|
||
assert (diff.aspect, diff.text) == ("filter", "A 按 status = 'paid' 筛选,B 按 status IN ('paid', 'shipped') 筛选")
|
||
assert (diff.a, diff.b) == ("status = 'paid'", "status IN ('paid', 'shipped')")
|
||
|
||
|
||
def test_distinct_count_versus_plain_count():
|
||
assert texts("SELECT COUNT(DISTINCT order_id) FROM orders", "SELECT COUNT(order_id) FROM orders") == \
|
||
["A 对 order_id 去重计数,B 没去重"]
|
||
assert texts("SELECT COUNT(order_id) FROM orders", "SELECT COUNT(DISTINCT order_id) FROM orders") == \
|
||
["A 对 order_id 计数时没去重,B 去重计数"]
|
||
|
||
|
||
@pytest.mark.parametrize("a,b,text", [
|
||
("SELECT SUM(amount) FROM orders", "SELECT AVG(amount) FROM orders", "A 对 amount 求和,B 求平均"),
|
||
("SELECT SUM(paid_amount) FROM orders", "SELECT SUM(amount) FROM orders", "A 对 paid_amount 求和,B 对 amount 求和"),
|
||
("SELECT COUNT(*) FROM orders", "SELECT COUNT(DISTINCT order_id) FROM orders",
|
||
"A 数的是行数,B 对 order_id 去重计数"),
|
||
])
|
||
def test_other_aggregate_differences(a, b, text):
|
||
assert texts(a, b) == [text]
|
||
|
||
|
||
def test_join_differences():
|
||
left = "SELECT SUM(o.amount) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id"
|
||
inner = "SELECT SUM(o.amount) FROM orders o JOIN refunds r ON r.order_id = o.id"
|
||
other_key = "SELECT SUM(o.amount) FROM orders o LEFT JOIN refunds r ON r.user_id = o.user_id"
|
||
plain = "SELECT SUM(amount) FROM orders"
|
||
assert texts(left, inner) == ["A 用 LEFT JOIN 关联 refunds 表,B 用 INNER JOIN"]
|
||
assert texts(left, other_key) == ["A 关联 refunds 表的条件是 orders.id = refunds.order_id,"
|
||
"B 是 orders.user_id = refunds.user_id"]
|
||
assert texts(left, plain) == ["A 关联了 refunds 表,B 没有"]
|
||
|
||
|
||
def test_a_different_source_table_is_said_once():
|
||
"""表换了一张:说一次「查的是哪张表」就够了,同一个聚合、同一个条件不再因为表名不同各报一遍。"""
|
||
a = "SELECT SUM(amount) FROM orders WHERE is_deleted = 0 AND paid_at >= '2026-09-01'"
|
||
b = "SELECT SUM(amount) FROM orders_archive WHERE is_deleted = 0 AND paid_at >= '2026-09-01'"
|
||
[diff] = diff_facets(facets(a), facets(b))
|
||
assert (diff.aspect, diff.text) == ("tables", "A 查的是 orders 表,B 查的是 orders_archive 表")
|
||
assert aspects(a, b.replace("is_deleted = 0", "is_deleted = 1")) == ["filter", "tables"], "换表之外的不同照报"
|
||
|
||
|
||
def test_two_columns_that_read_the_same_are_told_apart():
|
||
"""各自只写列名时两边读起来一样,其实是两张表的列:改写成带表名的写法,不说「A 对 amount 求和,B 对 amount 求和」。"""
|
||
a = "SELECT SUM(o.amount) FROM orders o JOIN refunds r ON r.order_id = o.id"
|
||
b = "SELECT SUM(r.amount) FROM orders o JOIN refunds r ON r.order_id = o.id"
|
||
assert texts(a, b) == ["A 对 orders.amount 求和,B 对 refunds.amount 求和"]
|
||
|
||
|
||
def test_group_by_difference():
|
||
assert texts("SELECT region, SUM(amount) FROM orders GROUP BY region",
|
||
"SELECT region, city, SUM(amount) FROM orders GROUP BY region, city") == \
|
||
["A 按 region 分组,B 按 region、city 分组"]
|
||
assert texts("SELECT SUM(amount) FROM orders", "SELECT region, SUM(amount) FROM orders GROUP BY region") == \
|
||
["A 没有分组,B 按 region 分组"]
|
||
|
||
|
||
def test_having_difference():
|
||
assert texts("SELECT region, SUM(amount) FROM orders GROUP BY region HAVING SUM(amount) > 100",
|
||
"SELECT region, SUM(amount) FROM orders GROUP BY region") == \
|
||
["A 分组后只保留 SUM(amount) > 100 的组,B 没有这个条件"]
|
||
|
||
|
||
def test_limit_difference():
|
||
assert texts("SELECT amount FROM orders LIMIT 10", "SELECT amount FROM orders") == ["A 只取前 10 行,B 不限行数"]
|
||
assert texts("SELECT amount FROM orders LIMIT 10", "SELECT amount FROM orders LIMIT 100") == \
|
||
["A 只取前 10 行,B 只取前 100 行"]
|
||
|
||
|
||
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
|
||
|
||
|
||
@pytest.mark.parametrize("a,b", [
|
||
# 别名、比较两边的顺序、大小写
|
||
("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"),
|
||
# 有效记录条件写在 CTE 里、还是写在外层
|
||
("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"),
|
||
# 逗号连接和 INNER JOIN
|
||
("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 = '虚构市'"),
|
||
# 条件里套着的子查询用了别名:NOT EXISTS、IN (SELECT …)
|
||
("SELECT o.id FROM orders o WHERE NOT EXISTS (SELECT 1 FROM refunds r WHERE r.order_id = o.id)",
|
||
"SELECT orders.id FROM orders WHERE NOT EXISTS (SELECT 1 FROM refunds WHERE refunds.order_id = orders.id)"),
|
||
("SELECT SUM(o.amount) FROM orders o WHERE o.user_id IN (SELECT u.id FROM users u WHERE u.vip = 1)",
|
||
"SELECT SUM(amount) FROM orders WHERE user_id IN (SELECT id FROM users WHERE vip = 1)"),
|
||
# HAVING 里用 SELECT 的别名
|
||
("SELECT o.region, SUM(o.amount) FROM orders o GROUP BY o.region HAVING SUM(o.amount) > 100",
|
||
"SELECT region AS r, SUM(amount) AS s FROM orders GROUP BY r HAVING s > 100"),
|
||
# OFFSET 0 等于没写;取前几行时 ORDER BY 1 和写出列名是同一个排序
|
||
("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"),
|
||
])
|
||
def test_the_same_meaning_written_differently_is_no_difference(a, b):
|
||
assert texts(a, b) == []
|
||
|
||
|
||
def test_a_real_difference_inside_a_subquery_is_still_reported():
|
||
a = "SELECT SUM(o.amount) FROM orders o WHERE o.user_id IN (SELECT u.id FROM users u WHERE u.vip = 1)"
|
||
b = "SELECT SUM(o.amount) FROM orders o WHERE o.user_id IN (SELECT u.referrer_id FROM users u WHERE u.vip = 1)"
|
||
assert texts(a, b) == ["A 按 user_id IN (SELECT id FROM users WHERE vip = 1) 筛选,"
|
||
"B 按 user_id IN (SELECT referrer_id FROM users WHERE vip = 1) 筛选"]
|
||
|
||
|
||
@pytest.mark.parametrize("dialect,a,b", [
|
||
# 条件里的子查询给选出来的列起了名字:IN、标量子查询只看取值,列名不影响口径
|
||
("sqlite",
|
||
"SELECT SUM(o.amount) FROM orders o WHERE o.amount > "
|
||
"(SELECT AVG(o2.amount) AS avg_amount FROM orders o2 WHERE o2.is_deleted = 0)",
|
||
"SELECT SUM(amount) FROM orders WHERE amount > (SELECT AVG(amount) FROM orders WHERE is_deleted = 0)"),
|
||
("sqlite",
|
||
"SELECT SUM(o.amount) FROM orders o WHERE o.user_id IN (SELECT u.id AS uid FROM users u WHERE u.vip = 1)",
|
||
"SELECT SUM(amount) FROM orders WHERE user_id IN (SELECT id FROM users WHERE vip = 1)"),
|
||
# 条件里的子查询从 FROM 里的子查询取数,两边给它起的别名不同
|
||
("sqlite",
|
||
"SELECT SUM(o.amount) FROM orders o WHERE o.user_id IN (SELECT s.user_id FROM "
|
||
"(SELECT user_id, SUM(amount) AS total FROM payments GROUP BY user_id) s WHERE s.total > 100)",
|
||
"SELECT SUM(o.amount) FROM orders o WHERE o.user_id IN (SELECT agg.user_id FROM "
|
||
"(SELECT user_id, SUM(amount) AS total FROM payments GROUP BY user_id) agg WHERE agg.total > 100)"),
|
||
# CTE 起的名字不同,外层按 CTE 算出来的列筛选
|
||
("sqlite",
|
||
"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",
|
||
"WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders WHERE is_deleted = 0 GROUP BY user_id) "
|
||
"SELECT COUNT(*) FROM t WHERE t.total > 100"),
|
||
# 写成 CTE 还是写成 FROM 里的子查询
|
||
("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"),
|
||
# LATERAL 子查询的别名不同:别名挂在 LATERAL 上,不在子查询上
|
||
("postgres",
|
||
"SELECT SUM(o.amount) FROM orders o, LATERAL (SELECT COUNT(*) AS n FROM refunds r WHERE r.order_id = o.id) l "
|
||
"WHERE l.n > 0",
|
||
"SELECT SUM(o.amount) FROM orders o CROSS JOIN LATERAL (SELECT COUNT(*) AS n FROM refunds r "
|
||
"WHERE r.order_id = o.id) AS x WHERE x.n > 0"),
|
||
# 关联进来的是子查询,两边的别名不同(一边是带反引号的中文别名)
|
||
("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"),
|
||
# 条件里的子查询用到 CTE,两边给 CTE 起的名字不同
|
||
("sqlite",
|
||
"WITH vip AS (SELECT id FROM users WHERE grade >= 3) "
|
||
"SELECT SUM(amount) FROM orders WHERE user_id IN (SELECT id FROM vip)",
|
||
"WITH good_users AS (SELECT id FROM users WHERE grade >= 3) "
|
||
"SELECT SUM(amount) FROM orders WHERE user_id IN (SELECT g.id FROM good_users g)"),
|
||
# CTE 定义在条件里的子查询里:定义连同子查询整个写进外层的条件,定义上的名字也不算
|
||
("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)"),
|
||
# 两个 CTE 定义的先后、起的名字都不同,按用到的先后它们是同一对
|
||
("sqlite",
|
||
"WITH paid AS (SELECT user_id, SUM(amount) AS total FROM orders WHERE status = 'paid' GROUP BY user_id), "
|
||
"refunded AS (SELECT user_id, SUM(amount) AS total FROM refunds GROUP BY user_id) "
|
||
"SELECT COUNT(*) FROM paid p JOIN refunded r ON r.user_id = p.user_id WHERE p.total > r.total",
|
||
"WITH r AS (SELECT user_id, SUM(amount) AS total FROM refunds GROUP BY user_id), "
|
||
"p AS (SELECT user_id, SUM(amount) AS total FROM orders WHERE status = 'paid' GROUP BY user_id) "
|
||
"SELECT COUNT(*) FROM p JOIN r ON p.user_id = r.user_id WHERE p.total > r.total"),
|
||
# CTE 定义的先后和用到的先后不一致,另一边又把它们写成了 FROM 里的子查询:按用到的先后编号才对得上
|
||
("sqlite",
|
||
"WITH c AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id), "
|
||
"d AS (SELECT user_id, COUNT(*) AS n FROM refunds GROUP BY user_id), "
|
||
"a AS (SELECT * FROM c WHERE total > 100), b AS (SELECT * FROM d WHERE n > 1) "
|
||
"SELECT COUNT(*) FROM b JOIN a ON a.user_id = b.user_id",
|
||
"SELECT COUNT(*) FROM (SELECT * FROM (SELECT user_id, COUNT(*) AS n FROM refunds GROUP BY user_id) x "
|
||
"WHERE n > 1) b JOIN (SELECT * FROM (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) y "
|
||
"WHERE total > 100) a ON a.user_id = b.user_id"),
|
||
# 同上,两个 CTE 还算出了同名的列、外层拿它们互相比:不写编号的写法分不出是哪个的 total,编号得对得上
|
||
("sqlite",
|
||
"WITH c AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id), "
|
||
"d AS (SELECT user_id, SUM(amount) AS total FROM refunds GROUP BY user_id), "
|
||
"a AS (SELECT * FROM c), b AS (SELECT * FROM d) "
|
||
"SELECT COUNT(*) FROM b JOIN a ON a.user_id = b.user_id WHERE a.total > b.total",
|
||
"SELECT COUNT(*) FROM (SELECT * FROM (SELECT user_id, SUM(amount) AS total FROM refunds GROUP BY user_id) x) b "
|
||
"JOIN (SELECT * FROM (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) y) a "
|
||
"ON a.user_id = b.user_id WHERE a.total > b.total"),
|
||
# 先用一个 CTE 筛一遍再汇总,还是直接在汇总的 CTE 里筛:里层多一段,不挪动外层那一段的编号
|
||
("sqlite",
|
||
"WITH base AS (SELECT * FROM orders WHERE is_deleted = 0), "
|
||
"per_user AS (SELECT user_id, SUM(amount) AS total FROM base GROUP BY user_id) "
|
||
"SELECT COUNT(*) FROM per_user WHERE total > 100",
|
||
"WITH totals AS (SELECT user_id, SUM(amount) AS total FROM orders WHERE is_deleted = 0 GROUP BY user_id) "
|
||
"SELECT COUNT(*) FROM totals WHERE total > 100"),
|
||
])
|
||
def test_names_given_to_derived_tables_are_no_difference(dialect, a, b):
|
||
"""CTE 名、子查询的别名、条件里的子查询选出来的列名都是各自起的(双盲复算的盲算更是):只因起的名字不同,
|
||
不算换了口径。"""
|
||
assert texts(a, b, dialect) == []
|
||
|
||
|
||
def test_derived_tables_are_compared_by_position_and_shown_by_name():
|
||
"""派生来源(CTE、FROM 里的子查询)比较用的写法按它在语句里的位置编号,不写名字;给人看的写法照原样写名字。"""
|
||
a = facets("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")
|
||
b = facets("SELECT COUNT(*) FROM (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) agg "
|
||
"WHERE agg.total > 100")
|
||
assert a.where[0].key == b.where[0].key
|
||
assert "user_totals" not in a.where[0].key and "agg" not in b.where[0].key
|
||
assert (a.where[0].text, a.where[0].full) == ("total > 100", "user_totals.total > 100")
|
||
assert (b.where[0].text, b.where[0].full) == ("total > 100", "agg.total > 100")
|
||
joined = facets("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")
|
||
[join] = joined.joins
|
||
assert join.table.text == "r" and join.on.text == "orders.id = refunds.order_id"
|
||
|
||
|
||
SHIFTED_A = ("WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
|
||
"SELECT COUNT(*) FROM t WHERE total > 100 AND user_id IN (SELECT id FROM users WHERE vip = 1)")
|
||
SHIFTED_B = ("WITH vip AS (SELECT id FROM users WHERE vip = 1), "
|
||
"t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
|
||
"SELECT COUNT(*) FROM vip JOIN t ON t.user_id = vip.id WHERE t.total > 100")
|
||
|
||
|
||
def test_a_shifted_derived_number_does_not_split_one_condition_in_two():
|
||
"""编号只看位置:一边多出一个排在前面的 CTE(筛 VIP 用户),后面那个 CTE 的编号就错开了。按编号比不上的再按
|
||
不写编号的写法一对一地配,同一个条件不会被说成两边各有一个;真正的不同(VIP 用 IN 筛还是关联进来)照报。"""
|
||
found = diff_facets(facets(SHIFTED_A), facets(SHIFTED_B), labels=("原流程", "盲算"))
|
||
filters = [d.text for d in found if d.aspect == "filter"]
|
||
assert filters == ["原流程只保留 user_id IN (SELECT id FROM users WHERE vip = 1) 的行,盲算没有这个条件"]
|
||
# 错开之后的条件真不同,也照样配成一对说
|
||
other = SHIFTED_B.replace("t.total > 100", "t.total > 200")
|
||
filters = [d.text for d in diff_facets(facets(SHIFTED_A), facets(other), labels=("原流程", "盲算"))
|
||
if d.aspect == "filter"]
|
||
assert filters[0] == "原流程按 total > 100 筛选,盲算按 total > 200 筛选" and len(filters) == 2
|
||
|
||
|
||
@pytest.mark.parametrize("aspect,a,b", [
|
||
# 时间条件、分组键、聚合、HAVING、取前几行时的排序都是按 CTE 算出来的列
|
||
("time",
|
||
"WITH t AS (SELECT user_id, DATE(paid_at) AS day, amount FROM orders) "
|
||
"SELECT SUM(amount) FROM t WHERE day >= '2026-09-01' AND user_id IN (SELECT id FROM users WHERE vip = 1)",
|
||
"WITH vip AS (SELECT id FROM users WHERE vip = 1), "
|
||
"t AS (SELECT user_id, DATE(paid_at) AS day, amount FROM orders) "
|
||
"SELECT SUM(t.amount) FROM vip JOIN t ON t.user_id = vip.id WHERE t.day >= '2026-09-01'"),
|
||
("group_by",
|
||
"WITH t AS (SELECT user_id, SUM(amount) AS total, COUNT(*) AS n FROM orders GROUP BY user_id) "
|
||
"SELECT n, COUNT(*) FROM t WHERE user_id IN (SELECT id FROM users WHERE vip = 1) GROUP BY n",
|
||
"WITH vip AS (SELECT id FROM users WHERE vip = 1), "
|
||
"t AS (SELECT user_id, SUM(amount) AS total, COUNT(*) AS n FROM orders GROUP BY user_id) "
|
||
"SELECT t.n, COUNT(*) FROM vip JOIN t ON t.user_id = vip.id GROUP BY t.n"),
|
||
("aggregate",
|
||
"WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
|
||
"SELECT AVG(total) FROM t WHERE user_id IN (SELECT id FROM users WHERE vip = 1)",
|
||
"WITH vip AS (SELECT id FROM users WHERE vip = 1), "
|
||
"t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
|
||
"SELECT AVG(t.total) FROM vip JOIN t ON t.user_id = vip.id"),
|
||
("having",
|
||
"WITH t AS (SELECT store_id, user_id, SUM(amount) AS total FROM orders GROUP BY store_id, user_id) "
|
||
"SELECT store_id FROM t WHERE user_id IN (SELECT id FROM users WHERE vip = 1) GROUP BY store_id "
|
||
"HAVING MAX(total) > 100",
|
||
"WITH vip AS (SELECT id FROM users WHERE vip = 1), "
|
||
"t AS (SELECT store_id, user_id, SUM(amount) AS total FROM orders GROUP BY store_id, user_id) "
|
||
"SELECT t.store_id FROM vip JOIN t ON t.user_id = vip.id GROUP BY t.store_id HAVING MAX(t.total) > 100"),
|
||
("limit",
|
||
"WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
|
||
"SELECT user_id FROM t WHERE user_id IN (SELECT id FROM users WHERE vip = 1) ORDER BY total DESC LIMIT 10",
|
||
"WITH vip AS (SELECT id FROM users WHERE vip = 1), "
|
||
"t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
|
||
"SELECT t.user_id FROM vip JOIN t ON t.user_id = vip.id ORDER BY t.total DESC LIMIT 10"),
|
||
])
|
||
def test_a_shifted_derived_number_is_no_difference_in_any_aspect(aspect, a, b):
|
||
"""编号错开之后,按 CTE 算出来的列写的时间条件、分组键、聚合、HAVING、排序也不算不同:只剩 VIP 那处真正的
|
||
不同。"""
|
||
assert not [x for x in aspects(a, b) if x.startswith(aspect)]
|
||
|
||
|
||
def test_a_shifted_number_inside_a_condition_subquery_is_no_difference():
|
||
"""条件里的子查询用到的 CTE 编号错开了:子查询整个写进外层条件的那一份,按不写编号的写法也写「@」,照样配得上。"""
|
||
a = ("WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
|
||
"SELECT COUNT(*) FROM users u WHERE u.vip = 1 AND u.id IN (SELECT user_id FROM t WHERE total > 100)")
|
||
b = ("WITH vip AS (SELECT id FROM users WHERE vip = 1), "
|
||
"t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
|
||
"SELECT COUNT(*) FROM vip WHERE vip.id IN (SELECT user_id FROM t WHERE total > 100)")
|
||
assert texts(a, b) == []
|
||
|
||
|
||
def test_a_shifted_derived_join_is_paired_with_its_counterpart():
|
||
"""关联进来的 CTE 编号错开了(盲算多关联了一个排在前面的 CTE):先把完全一样的关联配掉,再按关联的是哪张表配,
|
||
不会把原流程的 t 和盲算的 vip 当成同一处关联、说成「关联条件不同」。"""
|
||
a = ("WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
|
||
"SELECT COUNT(*) FROM users u JOIN t ON t.user_id = u.id WHERE t.total > 100")
|
||
b = ("WITH vip AS (SELECT id FROM users WHERE vip = 1), "
|
||
"t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
|
||
"SELECT COUNT(*) FROM users u JOIN vip ON vip.id = u.id JOIN t ON t.user_id = u.id WHERE t.total > 100")
|
||
assert texts(a, b, labels=("原流程", "盲算")) == ["盲算只保留 vip = 1 的行,原流程没有这个条件",
|
||
"盲算关联了 vip 表,原流程没有"]
|
||
|
||
|
||
def test_swapped_join_order_of_two_derived_tables_is_no_difference():
|
||
"""两个 CTE 关联的先后对调,编号也跟着对调:拿它们的列互相比的条件,两边先按不写编号的写法排好两边的先后,
|
||
才配得上(只按编号排,同一个比较会排成两个方向)。"""
|
||
ctes = ("WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id), "
|
||
"u AS (SELECT user_id, COUNT(*) AS cnt FROM refunds GROUP BY user_id) ")
|
||
assert texts(ctes + "SELECT COUNT(*) FROM t JOIN u ON u.user_id = t.user_id WHERE t.total > u.cnt",
|
||
ctes + "SELECT COUNT(*) FROM u JOIN t ON t.user_id = u.user_id WHERE t.total > u.cnt") == []
|
||
|
||
|
||
def test_a_reversed_comparison_between_two_derived_tables_is_still_reported():
|
||
"""两个 CTE 算出了同名的列:编号对得上时照样分得清是哪个 CTE 的,比较的方向反了要报出来(不写编号的写法只在
|
||
按编号比不上时才拿来配,不会把它吞掉)。"""
|
||
a = ("WITH paid AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id), "
|
||
"refunded AS (SELECT user_id, SUM(amount) AS total FROM refunds GROUP BY user_id) "
|
||
"SELECT COUNT(*) FROM paid p JOIN refunded r ON r.user_id = p.user_id WHERE p.total > r.total")
|
||
b = a.replace("WHERE p.total > r.total", "WHERE r.total > p.total")
|
||
assert texts(a, b) == ["A 只保留 paid.total > refunded.total 的行,B 没有这个条件",
|
||
"B 只保留 paid.total < refunded.total 的行,A 没有这个条件"]
|
||
|
||
|
||
def test_a_real_difference_next_to_renamed_derived_tables_is_reported_once():
|
||
"""派生来源起的名字不同、里面又真有不同时,只报真正不同的那一处:CTE 里少一个条件,外层按 CTE 算出来的列
|
||
筛选的条件不跟着报(不然两边各报一句「只保留 total > 100 的行」,自相矛盾);外层的条件本身不同,两边配成
|
||
一对说。"""
|
||
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")
|
||
inner = ("WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
|
||
"SELECT COUNT(*) FROM t WHERE total > 100")
|
||
assert texts(a, inner, labels=("原流程", "盲算")) == ["原流程只保留 is_deleted = 0 的行,盲算没有这个条件"]
|
||
outer = ("WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders WHERE is_deleted = 0 GROUP BY user_id) "
|
||
"SELECT COUNT(*) FROM t WHERE total > 200")
|
||
assert texts(a, outer, labels=("原流程", "盲算")) == ["原流程按 total > 100 筛选,盲算按 total > 200 筛选"]
|
||
|
||
|
||
@pytest.mark.parametrize("a,b,text", [
|
||
("WITH top AS (SELECT amount FROM orders ORDER BY amount DESC LIMIT 10) SELECT SUM(amount) FROM top",
|
||
"WITH top AS (SELECT amount FROM orders ORDER BY amount DESC LIMIT 100) SELECT SUM(amount) FROM top",
|
||
"A 在 top 里只取前 10 行,B 只取前 100 行"),
|
||
("SELECT amount FROM orders LIMIT 10 OFFSET 0", "SELECT amount FROM orders LIMIT 10 OFFSET 10",
|
||
"A 不跳过行,B 跳过前 10 行"),
|
||
("SELECT amount FROM orders ORDER BY 1 DESC LIMIT 10", "SELECT amount FROM orders ORDER BY 1 ASC LIMIT 10",
|
||
"A 取行前按 amount 降序排,B 按 amount 升序排"),
|
||
("SELECT amount FROM orders ORDER BY amount DESC LIMIT 10", "SELECT amount FROM orders LIMIT 10",
|
||
"A 取行前按 amount 降序排,B 没有排序"),
|
||
])
|
||
def test_which_rows_are_taken_is_compared(a, b, text):
|
||
"""取哪几行:每一段的行数、跳过的行数,以及取前几行时的排序。排序只在有 LIMIT / OFFSET 的段里比:
|
||
没有取前几行,排序不影响算出来的数。"""
|
||
assert texts(a, b) == [text]
|
||
assert texts("SELECT SUM(amount) FROM orders ORDER BY 1 DESC", "SELECT SUM(amount) FROM orders") == []
|
||
|
||
|
||
@pytest.mark.parametrize("dialect", DIALECTS)
|
||
def test_each_dialect_gives_the_same_three_findings(dialect):
|
||
q = {"sqlite": '"', "postgres": '"', "mysql": "`"}[dialect]
|
||
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=("原流程", "盲算"), names={"paid_at": "支付时间"}) == [
|
||
"原流程按支付时间 paid_at 筛时间,盲算按 created_at", "原流程对 order_id 去重计数,盲算没去重"]
|
||
|
||
|
||
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(BASE)
|
||
assert isinstance(f, Facets) and diff_facets(f, f) == []
|