diff --git a/backend/app/data/sql_semantics.py b/backend/app/data/sql_semantics.py index 6494db96..0d1f81a7 100644 --- a/backend/app/data/sql_semantics.py +++ b/backend/app/data/sql_semantics.py @@ -146,7 +146,9 @@ WHERE / GROUP BY 里用列别名的),而且只说五类: 时 MAX(u.city) 和 MIN(u.city) 一样,LEFT JOIN 把行复制了也一样);去重不起作用的不说(MIN / MAX;不分组的聚合只出 一行;已经按选出来的列分组;只取一行;表结构里的键说明每行本来就不一样);每组只有一行时 SUM、AVG、MIN、MAX 不说。 同一位置上一边套了 COALESCE(聚合, 常量)、另一边就是那个聚合(SUM / AVG / MIN / MAX,COUNT 不会是空、不说),说 - 「X 套了 COALESCE(…, 0):没有符合条件的行(或参数都为空)时 A 是 0、B 是空」(_coalesced)。 + 「X 套了 COALESCE(…, 0):没有符合条件的行(或参数都为空)时 A 是 0、B 是空」(_coalesced)。同一位置上一边套了 + ROUND(表达式, 位数)、另一边就是那个表达式,说「X 套了 ROUND(…, 2):保留 2 位小数,B 没有取整」(_rounded):取整改了值, + 判等价照旧不等价;口径、期望 SQL 里的取整最好交给口径卡、展示的小数位。 - 同一列上一边按运行当天现算(CURRENT_DATE、date('now', …)、now())、另一边写定的常量(time_range,_relative):说「A 对 X 的条件是 …,按运行当天现算;B 写定的是 …」,要求同 _rewrites。 - 关联的表、关联类型(tables / join):换了一张表;一边多关联了一张表(表结构里有指向它的外键、关联条件又用到它的键的 @@ -7785,7 +7787,8 @@ class _Explainer: return False covered = True for i, (x, y, fx, fy) in enumerate(zip(A.exprs, B.exprs, ta, tb)): - if fx != fy and not self._aggregates(x, y, A, B, mapping) and not self._coalesced(i, x, y, A, B, mapping): + if fx != fy and not self._aggregates(x, y, A, B, mapping) and not self._coalesced(i, x, y, A, B, mapping) \ + and not self._rounded(i, x, y, A, B, mapping): covered = False return covered @@ -7838,6 +7841,32 @@ class _Explainer: return True return False + def _rounded(self, index: int, x: Any, y: Any, A: _Block, B: _Block, mapping: Mapping[str, str]) -> bool: + """同一位置上一边是 ROUND(表达式, 位数常量)、另一边就是那个表达式本身:说「X 套了 ROUND(…, 2):保留 2 位小数,B 没有 + 取整」。位数不写(ROUND(x))按 0 位。只改说明:走到这里时两边已经判了不等价(取整确实改了值)。""" + from sqlglot import exp + + for side, (wrapped, plain) in enumerate(((x.unalias(), y.unalias()), (y.unalias(), x.unalias()))): + if not isinstance(wrapped, exp.Round) or wrapped.this is None: + continue + digits = wrapped.args.get("decimals") + if digits is not None and not (isinstance(digits, exp.Literal) and digits.is_int): + continue + same = self.fp(wrapped.this) == self.mfp(plain, mapping) if side == 0 else \ + self.mfp(wrapped.this, mapping) == self.fp(plain) + if not same: + continue + places = int(digits.this) if digits is not None else 0 + me, them = (self.la, self.lb) if side == 0 else (self.lb, self.la) + name = self._output_name(side, index) + what = _say(me, "的", name) if name else _say(me, "的这一列") + keep = f"保留 {places} 位小数" if places > 0 else "取整到个位" + self.add("aggregate", self.show(x, A), self.show(y, B), + _say(what, f"套了 ROUND(…, {places}):" if digits is not None else "套了 ROUND(…):", keep + ",", + them, "没有取整")) + return True + return False + def _dedup(self, A: _Block, B: _Block, ga: list[str], gb: list[str]) -> bool: """一边去重(SELECT DISTINCT,规范化写成了按选出来的列 GROUP BY)、另一边不去重,选出来的列一样。去重不起作用的 不说:不分组的聚合只出一行;已经按选出来的列分组;只取一行;表结构里的键说明每行本来就不一样。""" diff --git a/backend/tests/fixtures/eval/retail_suite.json b/backend/tests/fixtures/eval/retail_suite.json index cf38b1f4..acca975a 100644 --- a/backend/tests/fixtures/eval/retail_suite.json +++ b/backend/tests/fixtures/eval/retail_suite.json @@ -24,7 +24,7 @@ "gold": { "kind": "sql", "datasource": "retail_demo", - "sql": "SELECT ROUND(SUM(amount), 2) AS 销售额 FROM orders WHERE status = 'paid' AND paid_date BETWEEN '{{ schedule.last_week_start }}' AND '{{ schedule.last_week_end }}'" + "sql": "SELECT SUM(amount) AS 销售额 FROM orders WHERE status = 'paid' AND paid_date BETWEEN '{{ schedule.last_week_start }}' AND '{{ schedule.last_week_end }}'" }, "expectations": [ { @@ -50,7 +50,7 @@ "gold": { "kind": "sql", "datasource": "retail_demo", - "sql": "SELECT COUNT(*) AS 退款单数, ROUND(SUM(amount), 2) AS 退款金额 FROM orders WHERE status = 'refunded' AND paid_date BETWEEN '{{ schedule.last_week_start }}' AND '{{ schedule.last_week_end }}'" + "sql": "SELECT COUNT(*) AS 退款单数, SUM(amount) AS 退款金额 FROM orders WHERE status = 'refunded' AND paid_date BETWEEN '{{ schedule.last_week_start }}' AND '{{ schedule.last_week_end }}'" }, "expectations": [ { @@ -108,7 +108,7 @@ "gold": { "kind": "sql", "datasource": "retail_demo", - "sql": "SELECT s.region AS 地区, ROUND(SUM(o.amount), 2) AS 销售额 FROM orders o JOIN stores s ON s.id = o.store_id WHERE o.status = 'paid' AND o.paid_date BETWEEN '{{ schedule.last_week_start }}' AND '{{ schedule.last_week_end }}' AND s.region IN ('华南', '华东') GROUP BY s.region ORDER BY 销售额 DESC" + "sql": "SELECT s.region AS 地区, SUM(o.amount) AS 销售额 FROM orders o JOIN stores s ON s.id = o.store_id WHERE o.status = 'paid' AND o.paid_date BETWEEN '{{ schedule.last_week_start }}' AND '{{ schedule.last_week_end }}' AND s.region IN ('华南', '华东') GROUP BY s.region ORDER BY 销售额 DESC" }, "expectations": [ { @@ -137,7 +137,7 @@ "gold": { "kind": "sql", "datasource": "retail_demo", - "sql": "SELECT s.region AS 地区, ROUND(SUM(o.amount), 2) AS 销售额 FROM orders o JOIN stores s ON s.id = o.store_id WHERE s.region = '华中' AND o.status = 'paid' AND o.paid_date BETWEEN '{{ schedule.last_week_start }}' AND '{{ schedule.last_week_end }}' GROUP BY s.region" + "sql": "SELECT s.region AS 地区, SUM(o.amount) AS 销售额 FROM orders o JOIN stores s ON s.id = o.store_id WHERE s.region = '华中' AND o.status = 'paid' AND o.paid_date BETWEEN '{{ schedule.last_week_start }}' AND '{{ schedule.last_week_end }}' GROUP BY s.region" }, "expectations": [ { diff --git a/backend/tests/test_ledger_cells.py b/backend/tests/test_ledger_cells.py index 3ce99dad..a2d18a31 100644 --- a/backend/tests/test_ledger_cells.py +++ b/backend/tests/test_ledger_cells.py @@ -190,6 +190,26 @@ def test_coalesce_explanation_holds_on_the_virtual_shop(): assert not [i for i in counted.items if "COALESCE" in i.text] +def test_round_around_the_value_is_still_not_equivalent_and_says_why(): + """一边套了 ROUND(…, 2)、另一边没取整:取整改了值,照旧不等价;说明说清是哪一边取整、保留几位,不退回兜底。 + 评测的期望 SQL、口径的取数里常见这种写法(样例题集原来就是),说不清时整段兜底,人看不出只差在取整上。""" + from app.data.sql_semantics import explain_sql_diff + + a = "SELECT SUM(amount) AS s FROM orders WHERE status = 'paid'" + b = "SELECT ROUND(SUM(amount), 2) AS s FROM orders WHERE status = 'paid'" + db = sqlite3.connect(":memory:") + db.executescript("CREATE TABLE orders (id INTEGER, status TEXT, amount REAL);" + "INSERT INTO orders VALUES (1, 'paid', 10.005), (2, 'paid', 0.001)") + assert db.execute(a).fetchall() != db.execute(b).fetchall(), "取整确实改了值" + diff = explain_sql_diff(a, b, "sqlite") + assert not diff.equivalent and not diff.fallback + assert [(i.aspect, i.text) for i in diff.items] == [("aggregate", "B 的 s 套了 ROUND(…, 2):保留 2 位小数,A 没有取整")] + whole = explain_sql_diff("SELECT ROUND(SUM(amount)) AS s FROM orders", "SELECT SUM(amount) AS s FROM orders", "sqlite") + assert not whole.equivalent and [i.text for i in whole.items] == ["A 的 s 套了 ROUND(…):取整到个位,B 没有取整"] + other = explain_sql_diff("SELECT ROUND(AVG(amount), 2) AS s FROM orders", "SELECT SUM(amount) AS s FROM orders", "sqlite") + assert not [i for i in other.items if "ROUND" in i.text], "取整的不是同一个表达式时不说成只差在取整" + + def test_dates_computed_from_today_are_not_equivalent_and_say_so(): from app.data.sql_semantics import explain_sql_diff diff --git a/backend/tests/test_sql_semantics.py b/backend/tests/test_sql_semantics.py index 0b9f35cf..d5b0152f 100644 --- a/backend/tests/test_sql_semantics.py +++ b/backend/tests/test_sql_semantics.py @@ -867,10 +867,10 @@ def test_columns_inside_a_cte_are_not_compared_because_canonical_forms_prune_the def test_differences_that_cannot_be_checked_get_only_the_fallback(): - """两边算法不同、又判断不了是不是真的不同的(ROUND 包了一层、CASE 和 COALESCE、两种写法的时间常量),不说具体的: - 「A 算的是 X,B 算的是 Y」读起来就是两边不同,说错了比不说更糟。""" - for a, b in (("SELECT SUM(amount) FROM orders", "SELECT ROUND(SUM(amount), 2) FROM orders"), - ("SELECT SUM(COALESCE(discount, 0)) FROM orders", + """两边算法不同、又判断不了是不是真的不同的(CASE 和 COALESCE、两种写法的时间常量),不说具体的: + 「A 算的是 X,B 算的是 Y」读起来就是两边不同,说错了比不说更糟。ROUND 包了一层的另有一句只陈述写法、不断言值不同的 + 说明(_Explainer._rounded,见 test_ledger_cells 的 ROUND 那条)。""" + for a, b in (("SELECT SUM(COALESCE(discount, 0)) FROM orders", "SELECT SUM(CASE WHEN discount IS NULL THEN 0 ELSE discount END) FROM orders"), ("SELECT COUNT(*) FROM orders WHERE paid_at >= '2026-09-01'", "SELECT COUNT(*) FROM orders WHERE paid_at >= DATE('2026-09-01')")):