Files
AgentLab/backend/tests/test_sql_text_diff.py
T
ThearchyHelios 6d4f452f36 M4 复核修正:规范化后不同的行带上所在的子句,ON 挂在 JOIN 那一行的末尾
复核发现上一版把 ON 单独排成一行后,「规范化后不同的行」丢了关联条件的上下文:只差一个关联条件时那一栏只剩
「AND refunds.state = 'done'」,有两个 LEFT JOIN 时看不出是哪个 JOIN 的,也看不出是关联条件还是筛选条件;两边 JOIN
的表不同时,两边都有的那一行「ON」被 difflib 当成相同的行吃掉,表名下面直接跟条件;关联子查询换成两张表时也一样。
这几种都只给兜底说明,审核人全靠这一栏逐行对照。

- sql_semantics:ON 挂在 JOIN 那一行的末尾(表名、子查询的右括号后面),条件各占一行缩在下面,仍比 WHERE 的条件
  深一级(ON 里和 WHERE 里的同一个条件不当成同一行)。text_diff 里不同的行前面带上它的各级上级行(排在前面、缩进比
  它浅的最近一行,一层层数到顶格),以「  」开头、两边相同,只为标出不同的行在哪个 WHERE、哪个 JOIN 的 ON、哪层
  子查询里;截到 40 行时末尾落单的上级行不留。只动排版:拿测试里的 SQL 语料(990 对,4 种方言、有无表结构、两个
  方向)逐对比过,equivalent、items、fallback、level 和改之前完全一致,有没有不同的行也一致,不再有单独一行的 ON。
- SqlDiff 的约定多一条:「  」开头的是上下文行,只跟着不同的行出现。test_sql_semantics 的 check_shape 原来只认
  「- 」「+ 」,改成也认「  」、并要求最后一行是不同的行(374 个用例只因这一行断言变红,判定和说明都没变)。
- 审批卡(DualCheckApproval):两栏各自只放用得着的上级行(这一边不同的行往上数,缩进更浅的最近一行,一层层往上),
  只有另一边用得着的不放;上级行不高亮、用淡一级的字色(同原文栏里没标的行),标题后面说明「淡色的行两边相同,标出
  所在的子句」,悬停也是这句;这一边没有不同的行时照旧说没有,老工件没有上级行,照旧。lineDiff 注释里「条件并成
  一行」的旧说法改正。
- 测试:test_sql_text_diff 里 ON 那条的期望改成带 JOIN 的上级行,补两个 LEFT JOIN 只差一个关联条件(两个方向)、
  两边 JOIN 的表不同、关联子查询换成两张表、同一个条件在 ON 和 WHERE 里、顶格的行不带上下文、40 行截断几条;去掉
  上级行、去掉「ON 挂在行末」、去掉截断后的清理,各自让对应的用例变红。
- 夹具 dual_check.json 按 test_dual_check_fixture 的办法重新生成(只变 text_diff),另加 join_condition 变体(只差
  一个关联条件、只给兜底);check-verify 查两栏的上级行(WHERE)列在不同的行前面、不高亮、标题有说明,join_condition
  下原流程一栏是带 ON 的 JOIN 加缩进的条件、盲算一栏不画只有原流程用得着的上级行,深浅色对比度、390px 不溢出。
2026-10-07 01:34:59 +08:00

186 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""explain_sql_diff 的 text_diff(「规范化后不同的行」):两边相同的条件不因为换行的位置不同被标成差异,不同的行看得出
在哪个子句里。
2026-10 验收:双盲复算审批卡上,两边完全相同的日期区间被标成了差异——
+ orders.paid_date >= '2026-09-28' AND orders.paid_date <= '2026-10-04'
- orders.paid_date >= '2026-09-28'
- AND orders.paid_date <= '2026-10-04'
- AND orders.status <> 'refunded'
sqlglot 的 pretty 按宽度决定换不换行:两个条件放得下就排在一行,三个就拆成三行,同一个条件在两边落在不同的行上。原文
SQL 都是单行时原文栏一行都不标,这一栏是唯一逐行指出差在哪的地方,审核人会以为统计期也不一样。
现在逐行比之前两边按同一套排版写:WHERE、ON、HAVING 的合取式每个条件一行(嵌在子查询里的也一样),条件本身是 OR
连起来的加上括号;比的时候不看条件前面的 AND(一边多出来的条件排在最前面时,另一边同一个条件只差在有没有 AND 上)。
只动排版:判不判等价、差异说明(items)不变,sql_semantics、sql_equivalence 原有的测试照样全过。
复核(第 2 轮):ON 单独占一行时,只差一个关联条件的那一栏只剩一行「AND 条件」,看不出是哪个 JOIN 的关联条件、还是
筛选条件;两边 JOIN 的表不同时,两边都有的那一行「ON」被当成相同的行吃掉,条件前面没了 ON。现在 ON 挂在 JOIN 那一行
的末尾(条件缩在下面,比 WHERE 的条件深一级);不同的行前面带上它的各级上级行——「 」开头、两边相同、只为标出不同的
行在哪个子句里(哪个 WHERE、哪个 JOIN 的 ON、哪层子查询),审批卡上淡色显示。
表名、列名、日期、取值都是虚构的。
"""
from __future__ import annotations
from app.data.sql_semantics import explain_sql_diff
LABELS = ("原流程", "盲算")
#: 包裹表、仓库表、快递员表的结构(虚构):给了表结构才认列,同验收时双盲复算节点的调用
SCHEMA = {"tables": {
"parcels": {"columns": [{"name": "id", "type": "INTEGER"}, {"name": "fee", "type": "REAL"},
{"name": "sent_on", "type": "TEXT"}, {"name": "state", "type": "TEXT"},
{"name": "hub", "type": "TEXT"}, {"name": "depot_id", "type": "INTEGER"},
{"name": "courier_id", "type": "INTEGER"}]},
"depots": {"columns": [{"name": "id", "type": "INTEGER"}, {"name": "zone", "type": "TEXT"},
{"name": "kind", "type": "TEXT"}]},
"couriers": {"columns": [{"name": "id", "type": "INTEGER"}, {"name": "team", "type": "TEXT"},
{"name": "active", "type": "INTEGER"}]},
}}
WEEK = "sent_on BETWEEN '2031-03-03' AND '2031-03-09'"
#: 两个 LEFT JOIN,{d}、{c} 处各可再加一个关联条件
TWO_JOINS = ("SELECT COUNT(*) FROM parcels p LEFT JOIN depots d ON d.id = p.depot_id{d} "
"LEFT JOIN couriers c ON c.id = p.courier_id{c}")
def diff(a: str, b: str):
return explain_sql_diff(a, b, "sqlite", schema=SCHEMA, labels=LABELS)
def depth(line: str) -> int:
return len(line) - len(line.lstrip(" "))
def test_the_same_date_range_is_not_marked_when_one_side_has_one_more_condition():
"""验收时的形状:原流程比盲算多一个排除作废的条件,两边的日期区间一样——只标多出来的那一个条件,前面带上它所在的
WHERE(两边相同的上下文行)。"""
a = f"SELECT SUM(fee) AS week_fee FROM parcels WHERE {WEEK} AND state <> 'void'"
b = f"SELECT SUM(fee) AS total_fee FROM parcels WHERE {WEEK}"
got = diff(a, b)
assert got.text_diff == (" WHERE", "- AND parcels.state <> 'void'")
# 只动排版:判定和差异说明照旧
assert not got.equivalent and not got.fallback and got.level == "optimizer"
assert [(i.aspect, i.text) for i in got.items] == [("filter", "原流程排除了 state = 'void' 的行和 state 为空的行,盲算没有")]
# 反过来比也一样,只是换到「+ 」那一边
assert diff(b, a).text_diff == (" WHERE", "+ AND parcels.state <> 'void'")
def test_an_extra_condition_sorted_first_does_not_drag_the_shared_ones_along():
"""多出来的条件排在最前面:另一边同一个日期条件只差在前面有没有 AND,不算不同。"""
a = f"SELECT SUM(fee) FROM parcels WHERE {WEEK} AND hub = 'north'"
b = f"SELECT SUM(fee) FROM parcels WHERE {WEEK}"
assert diff(a, b).text_diff == (" WHERE", "- parcels.hub = 'north'")
assert diff(b, a).text_diff == (" WHERE", "+ parcels.hub = 'north'")
def test_two_conditions_on_each_side_mark_only_the_one_that_differs():
"""同一列上的区间只差结束日期(这种只给兜底说明,审核人全靠逐行对照):开始日期那一行两边一样,不标。"""
a = f"SELECT SUM(fee) FROM parcels WHERE {WEEK}"
b = "SELECT SUM(fee) FROM parcels WHERE sent_on BETWEEN '2031-03-03' AND '2031-03-10'"
got = diff(a, b)
assert got.fallback
assert got.text_diff == (" WHERE", "- AND parcels.sent_on <= '2031-03-09'", "+ AND parcels.sent_on <= '2031-03-10'")
def test_on_conditions_get_one_line_each_under_their_join():
"""LEFT JOIN 的 ON 挂在 JOIN 那一行的末尾,条件各占一行缩在下面;多出来的那个条件单独标出来,前面带上它所在的
JOIN(以前 ON 单独一行,标出来的只剩「AND depots.kind = 'cold'」,看不出是关联条件还是筛选条件)。"""
a = "SELECT COUNT(*) FROM parcels p LEFT JOIN depots d ON d.id = p.depot_id AND d.kind = 'cold'"
b = "SELECT COUNT(*) FROM parcels p LEFT JOIN depots d ON d.id = p.depot_id"
assert diff(a, b).text_diff == (" LEFT JOIN depots AS depots ON", "- AND depots.kind = 'cold'")
# 关联条件本身不同:只标那一行,JOIN 那一行两边一样,作为上下文列出
c = "SELECT COUNT(*) FROM parcels p LEFT JOIN depots d ON d.zone = p.hub"
assert diff(b, c).text_diff == (" LEFT JOIN depots AS depots ON", "- depots.id = parcels.depot_id",
"+ depots.zone = parcels.hub")
def test_two_left_joins_name_the_join_the_differing_condition_belongs_to():
"""两个 LEFT JOIN 只差其中一个的关联条件:标出来的条件前面带上的是它自己的那个 JOIN,不是另一个,也不是 WHERE。"""
plain = TWO_JOINS.format(d="", c="")
on_courier = TWO_JOINS.format(d="", c=" AND c.active = 1")
assert diff(on_courier, plain).text_diff == (" LEFT JOIN couriers AS couriers ON", "- AND couriers.active = 1")
on_depot = TWO_JOINS.format(d=" AND d.kind = 'cold'", c="")
assert diff(plain, on_depot).text_diff == (" LEFT JOIN depots AS depots ON", "+ AND depots.kind = 'cold'")
def test_a_different_join_table_keeps_on_with_each_side():
"""两边 LEFT JOIN 的表不同:每一栏都是带着 ON 的 JOIN 那一行、下面紧跟各自的关联条件(以前两边都有的那一行 ON 被
当成相同的行吃掉,表名下面直接跟条件)。"""
a = "SELECT COUNT(*) FROM parcels p LEFT JOIN depots d ON d.id = p.depot_id"
b = "SELECT COUNT(*) FROM parcels p LEFT JOIN couriers c ON c.id = p.courier_id"
assert diff(a, b).text_diff == ("- LEFT JOIN depots AS depots ON", "- depots.id = parcels.depot_id",
"+ LEFT JOIN couriers AS couriers ON", "+ couriers.id = parcels.courier_id")
def test_a_join_condition_never_floats_without_its_join():
"""关联的对象换了写法(原流程关联一个子查询,盲算关联两张表):每个关联条件紧跟在它所在的、带着 ON 的那一行下面——
子查询的右括号后面也带着 ON,不会出现条件上面是子查询的最后一行、或者上面是别的 JOIN 的情况。"""
a = ("SELECT COUNT(*) FROM parcels p LEFT JOIN (SELECT depot_id AS k, SUM(fee) AS s FROM parcels GROUP BY depot_id) "
"AS sub ON p.depot_id = sub.k")
b = ("SELECT COUNT(*) FROM parcels p LEFT JOIN depots d ON d.id = p.depot_id "
"LEFT JOIN couriers c ON c.id = p.courier_id")
got = diff(a, b).text_diff
assert [line for line in got if line.startswith("+ ")] == [
"+ LEFT JOIN couriers AS couriers ON", "+ couriers.id = parcels.courier_id",
"+ LEFT JOIN depots AS depots ON", "+ depots.id = parcels.depot_id"]
original = [line for line in got if line.startswith("- ")]
assert original[0] == "- LEFT JOIN (" and original[-2:] == ["- ) AS sub ON", "- parcels.depot_id = sub.c1"], got
# 每一行都只在一边,没有两边相同的上下文行,也没有单独一行的 ON
assert len(original) + 4 == len(got) and not any(line.strip() in ("ON", "- ON", "+ ON") for line in got)
def test_the_same_condition_in_on_and_in_where_is_not_one_line():
"""同一个条件一边写在 LEFT JOIN 的 ON 里、一边写在 WHERE 里(意思不同:写在 ON 里不筛掉左表的行):ON 里的条件比
WHERE 的缩进深一级,不会被当成两边相同的行。"""
a = "SELECT COUNT(*) FROM parcels p LEFT JOIN depots d ON d.id = p.depot_id AND p.hub = 'north'"
b = "SELECT COUNT(*) FROM parcels p LEFT JOIN depots d ON d.id = p.depot_id WHERE p.hub = 'north'"
assert diff(a, b).text_diff == (" LEFT JOIN depots AS depots ON", "+ WHERE", "- AND parcels.hub = 'north'",
"+ parcels.hub = 'north'")
def test_a_top_level_line_carries_no_context():
"""不同的行本身顶格(FROM 换了表):没有上级行,不带上下文。"""
assert diff("SELECT COUNT(*) FROM parcels", "SELECT COUNT(*) FROM depots").text_diff == (
"- FROM parcels AS parcels", "+ FROM depots AS depots")
def test_having_conditions_get_one_line_each_too():
a = "SELECT hub, SUM(fee) FROM parcels GROUP BY hub HAVING SUM(fee) > 10 AND COUNT(*) > 2"
b = "SELECT hub, SUM(fee) FROM parcels GROUP BY hub HAVING SUM(fee) > 10"
assert diff(a, b).text_diff == (" HAVING", "- AND COUNT(*) > 2")
def test_conditions_inside_a_subquery_get_one_line_each_too():
"""子查询里的条件也是每个条件一行;上下文一层层带到顶格:外层的 WHERE、EXISTS 那一行、子查询的 WHERE。"""
a = ("SELECT COUNT(*) FROM parcels p WHERE p.state <> 'void' "
"AND EXISTS (SELECT 1 FROM depots d WHERE d.id = p.depot_id AND d.zone = 'east')")
b = ("SELECT COUNT(*) FROM parcels p WHERE p.state <> 'void' "
"AND EXISTS (SELECT 1 FROM depots d WHERE d.id = p.depot_id)")
*context, line = diff(a, b).text_diff
assert line.startswith("- AND ") and line.endswith(".zone = 'east'")
assert [c.strip() for c in context] == ["WHERE", "EXISTS(", "WHERE"] and all(c.startswith(" ") for c in context)
assert [depth(c[2:]) for c in context] == [0, 2, 4]
def test_an_or_condition_reads_the_same_alone_and_among_others():
"""OR 连起来的条件和别的条件并列时要加括号,单独一个时以前不加:同一个条件两边写法不同,就被标成了差异。现在条件
本身是 OR 的一律加括号,两边写法一样;加括号不改意思。"""
either = "(hub = 'north' OR hub = 'south')"
a = f"SELECT COUNT(*) FROM parcels WHERE {either} AND state <> 'void'"
b = f"SELECT COUNT(*) FROM parcels WHERE {either}"
assert diff(a, b).text_diff == (" WHERE", "- parcels.state <> 'void'")
c = "SELECT COUNT(*) FROM parcels WHERE (hub = 'north' OR hub = 'west') AND state <> 'void'"
assert diff(a, c).text_diff == (" WHERE", "- AND (parcels.hub = 'north' OR parcels.hub = 'south')",
"+ AND (parcels.hub = 'north' OR parcels.hub = 'west')")
def test_context_cut_off_by_the_forty_line_cap_is_dropped():
"""截到 40 行时,最后剩下的上级行后面的不同行被截掉了:这种上级行不留,最后一行总是不同的行。"""
cols = 19
a = ", ".join(f"SUM(fee * {i}) AS c{i}" for i in range(cols))
b = ", ".join(f"SUM(fee * {i + 100}) AS c{i}" for i in range(cols))
got = diff(f"SELECT {a} FROM parcels WHERE fee > 0 AND state <> 'void'",
f"SELECT {b} FROM parcels WHERE fee > 0").text_diff
# 上级行 SELECT、两边各 19 行的选择列共 39 行;第 40 行本该是 WHERE,它下面那一行被截掉了
assert got[0] == " SELECT" and len(got) == 1 + 2 * cols
assert got[-1][:2] in ("- ", "+ ") and " WHERE" not in got