From 02b08f9ad4e77126bd5844b59513b33082109840 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 12:11:37 +0800 Subject: [PATCH] =?UTF-8?q?SQL=20=E5=B7=AE=E5=BC=82=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E9=87=8D=E5=81=9A=EF=BC=88=E7=AC=AC=202=20=E8=BD=AE=E8=A1=A5?= =?UTF-8?q?=E5=85=85=EF=BC=89=EF=BC=9A=E5=9C=A8=E7=9C=9F=E5=AE=9E=E7=9A=84?= =?UTF-8?q?=20PostgreSQL=20/=20MySQL=20=E4=B8=8A=E5=A4=8D=E6=9F=A5?= =?UTF-8?q?=EF=BC=8C=E6=B5=AE=E7=82=B9=E6=95=B0=E6=B1=82=E5=92=8C=E7=9A=84?= =?UTF-8?q?=E4=B8=80=E6=AE=B5=E4=B8=8D=E8=B0=83=E6=8D=A2=E5=85=88=E5=90=8E?= =?UTF-8?q?=E3=80=81=E5=A4=96=E5=B1=82=E6=9D=A1=E4=BB=B6=E5=8F=AA=E6=8A=8A?= =?UTF-8?q?=E8=81=9A=E5=90=88=E7=BB=93=E6=9E=9C=E5=92=8C=E3=80=8C=E7=9B=B8?= =?UTF-8?q?=E7=AD=89=E5=B0=B1=E6=98=AF=E4=B8=80=E6=A0=B7=E3=80=8D=E7=9A=84?= =?UTF-8?q?=E5=88=86=E7=BB=84=E9=94=AE=E6=8C=AA=E8=BF=9B=20HAVING=EF=BC=9B?= =?UTF-8?q?=E6=8E=92=E5=88=97=E6=95=B0=E5=85=88=E7=AE=97=E5=86=8D=E5=B1=95?= =?UTF-8?q?=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:第 2 轮的主体提交只在 SQLite 上执行比对。这次私起了 PostgreSQL 18 和 MySQL 8.0(复验者语料的那份数据), 拿复验者的全部语料和自己针对新改写造的 22 对重跑,抓到两处在真实的库上结果不同、却判了等价的: - 套在子查询里的 UNION ALL 调换各支,外面 SUM:浮点数加的先后变了,1902.2 对 1902.2000000000003(PG、MySQL)。 - 外层 WHERE BINARY t.city = 'SH' 挪进分组子查询的 HAVING:MySQL 自己会把外层只涉及分组键的条件推进子查询的 WHERE 先筛行,不分大小写时和 HAVING 结果不同。 做了什么(backend/app/data/sql_semantics.py): - _Sensitivity:PostgreSQL、MySQL 里对不知道是不是精确数(整数、定点小数)的值 SUM / AVG / STDDEV / VARIANCE,这一段 算「结果看行出来的先后」,连同给它供行的子查询、CTE 不调换来源、不排分组键、不并子查询。SQLite 不算(它用 Kahan-Babuska-Neumaier 求和,关联先后也不跟着 FROM 变;语料 120 对在 7 组数据上按精确值比没有差别)。 - 外连接按 WHERE 变成内连接、内连接的条件挪进 WHERE:三种库的优化器自己也做,执行先后不变,看先后的一段也照做; 只有 SQLite 写了 CROSS JOIN 的不挪。 - 外层 WHERE 挪进分组子查询的 HAVING 只挪聚合结果和「相等就是一样」的分组键上的条件;引用了好几次的简单 CTE 有 一处在看先后的一段里就不拆开(PostgreSQL 会先把它算出来存着)。只筛行选列的子查询照样并进求浮点数和的外层:三种库 自己也会并(PostgreSQL 的 subquery pull-up、MySQL 的 derived_merge、SQLite 的 flattening),执行先后一样(在两个 库上核对过结果一字不差)。 - 同名来源的排列先算个数再展开:十几个同名表时以前先把十几的阶乘个排列都列出来(一条 12 张表自连接的语句跑了两分钟 以上),现在超了上限直接按排好的先后。 - 模块说明照实写清上面几条和 SQLite 求和的假设。 测试:REGRESSIONS_R2 补这两处(PG、MySQL 的 UNION ALL 求和、MySQL 的分组键条件);DIALECT_SAME 里两对需要在求和的 一段调换关联先后的 PG / MySQL 改写移到 KNOWN_UNRECOGNIZED(只给兜底说明)。 验证(最终代码):tests/test_sql_semantics.py + tests/test_verify_agent.py 601 passed,tests/test_sql_equivalence.py 375 passed,全量 -n 8:7856 passed、11 skipped。等价改写 124 对认出 120 对(97%),不给表结构 117 对(94%)。复验者 vsadv 全部语料(928 次判定,SQLite / PG / MySQL 执行)无 blocker、判等价的没有一对执行结果不同,自己针对新改写的 32 对 同样;advfp 180 对等价对上具体说法 0 条;vsprobe 变异压测(种子 9、10)约 1.6 万对无误判等价、无异常;自己的压测 约 1.65 万条可执行,规范形式相同的组执行结果全一致。 --- backend/app/data/sql_semantics.py | 136 +++++++++++++++++++++------- backend/tests/fixtures/sql_pairs.py | 27 ++++-- 2 files changed, 124 insertions(+), 39 deletions(-) diff --git a/backend/app/data/sql_semantics.py b/backend/app/data/sql_semantics.py index d1a45006..3c4d937a 100644 --- a/backend/app/data/sql_semantics.py +++ b/backend/app/data/sql_semantics.py @@ -47,18 +47,22 @@ canonicalize(给日期比较加 CAST)、pushdown_projections;HAVING 里的 'SH'、'sh' 在同一组,HAVING BINARY city = 'SH' 看的是这一组留下的那个值);只有一项、又不是常量的 IN 列表写成 = (SQLite 里 a IN (b) 是 a = +b)。 -**结果看行出来的先后的一段(见 _Sensitivity)不调换来源的先后、不把 CROSS JOIN 写成逗号连接、不排 GROUP BY 的键、 -不改 DISTINCT、不往里并子查询**:取行(LIMIT / OFFSET / FETCH,ORDER BY 有并列时取哪几行也看先后)、DISTINCT ON、 -窗口函数、拼串拼数组的聚合(GROUP_CONCAT)、聚合里既不分组也不聚合的列、当成标量用的子查询(SQLite 取第一行),以及 -去重、分组、MIN / MAX、去重的 UNION 留下的值「相等却不一样」的时候(MySQL 默认不分大小写的字符串、PostgreSQL numeric -的 2.0 和 2.00、SQLite 没有类型亲和的列、算出来的表达式:留下的是先扫到的那个);给这样一段供行的子查询、CTE 也算。 -SQLite 里逗号连接和 CROSS JOIN 解析成同一个节点,按原文分开(_mark_comma_joins:SQLite 不调换 CROSS JOIN 两边)。 +**结果看行出来的先后的一段(见 _Sensitivity)不调换来源的先后、不排 GROUP BY 的键、不改 DISTINCT、不并子查询、不把 +引用了好几次的 CTE 拆开**:取行(LIMIT / OFFSET / FETCH,ORDER BY 有并列时取哪几行也看先后)、DISTINCT ON、窗口函数、 +拼串拼数组的聚合(GROUP_CONCAT)、聚合里既不分组也不聚合的列、当成标量用的子查询(SQLite 取第一行),去重、分组、 +MIN / MAX、去重的 UNION 留下的值「相等却不一样」的时候(MySQL 默认不分大小写的字符串、PostgreSQL numeric 的 2.0 和 +2.00、SQLite 没有类型亲和的列、算出来的表达式:留下的是先扫到的那个),以及 PostgreSQL、MySQL 里对不知道是不是精确数 +的值求和、求平均(浮点数加的先后不同,结果的最后几位就不同:1902.2 和 1902.2000000000003,在真实的库上跑出来过); +给这样一段供行的子查询、CTE 也算。外连接按 WHERE 变成内连接、内连接的条件挪进 WHERE 这两步三种库的优化器自己也做, +执行起来先后不变,这样的一段也照做——只有 SQLite 里写了 CROSS JOIN 的不挪(SQLite 不调换 CROSS JOIN 两边、会调换逗号 +连接;两种写法解析成同一个节点,按原文分开,见 _mark_comma_joins)。 **假设(做不到的)**:SQLite 没给表结构时按列有声明的类型、默认的 BINARY 排序规则算——声明了 COLLATE NOCASE 的列、 没有类型亲和的列(能同时存 1 和 1.0)表结构里看不出来,这样的列上去重、分组再调换关联先后可能留下不同的代表值; -比较两边都是列时对调两边(SQLite 比较两列用左边那列声明的排序规则,两边都写了 COLLATE 的不对调)。PostgreSQL、 -MySQL 没给表结构时一律不当成「相等就是一样」,去重、分组、MIN / MAX 的一段不调换来源。MySQL 的表名按 Linux 上分 -大小写算(BILLS 和 bills 不判等价)。 +比较两边都是列时对调两边(SQLite 比较两列用左边那列声明的排序规则,两边都写了 COLLATE 的不对调)。SQLite 的求和 +当成和先后无关(它用 Kahan-Babuska-Neumaier 求和,关联先后也不跟着 FROM 的先后变;语料在 7 组数据上按精确值比也没有 +差别)。PostgreSQL、MySQL 没给表结构时一律不当成「相等就是一样」、也不当成精确数,去重、分组、MIN / MAX、求和的一段 +不调换来源。MySQL 的表名按 Linux 上分大小写算(BILLS 和 bills 不判等价)。 等价的结论另有执行比对守门(tests/test_sql_equivalence.py):虚构库上五组随机数据、一组极端数据、一组专挑写法差异的 数据,判等价的两条每组结果都得一样,结果不一样的绝不能判等价。原文里有 sqlglot 解析时会丢掉、语义却不同的写法 @@ -97,7 +101,7 @@ MySQL 没给表结构时一律不当成「相等就是一样」,去重、分 SQLite 里套在子查询里的 UNION、三项以上的加乘换结合、和方言有关的同义写法(DATE(x) 和 CAST(x AS DATE))、LATERAL、 递归 CTE、实表别名带列名、USING 用在第一个连接以外的地方;没给表结构时 WHERE、GROUP BY、HAVING 里用列别名的、 SELECT 列表里和前面的列别名同名的;结果看行出来的先后的一段调换关联先后、DISTINCT 改 GROUP BY;PostgreSQL、MySQL - 没给表结构时去重、分组的一段调换关联先后。 + 没给表结构时去重、分组、求和的一段调换关联先后、并子查询。 - 说不出具体差异、只给兜底的:自己关联自己的方向或角色不同、几个派生来源互相交错、相关子查询内外层的条件、 另一边别处(别的子查询、CTE)也用到同一列筛选的、读的来源对不上、CTE 和子查询里选出来的列不同、条件或算法写法 不同又判断不了是不是真不一样的、EXCEPT 两边对调、几处不同交错在一起的。 @@ -2287,6 +2291,9 @@ _ORDERED_AGGS = frozenset({"groupconcat", "stringagg", "arrayagg", "arrayconcata "jsonobjectagg", "jsonagg", "jsonbagg", "jsongrouparray", "jsongroupobject", "listagg", "xmlagg", "anyvalue", "first", "last", "firstvalue", "lastvalue", "nthvalue", "jsonbobjectagg", "jsonobjectaggstrict", "arrayaggdistinct", "bitstringagg"}) +#: 浮点数求和类的聚合:浮点数加法不满足结合律,加的先后不同结果的最后几位就不同(1902.2 和 1902.2000000000003) +_FLOAT_AGGS = frozenset({"sum", "avg", "stddev", "stddevpop", "stddevsamp", "variance", "variancepop", "varpop", + "varsamp", "covarpop", "covarsamp", "corr", "total"}) #: 拿空值做比较、算术都得空值(外连接没关联上的那一侧整行是空值) _STRICT_UNARY = ("Neg", "Cast", "Lower", "Upper", "Length", "Abs") _STRICT_BINARY = ("Add", "Sub", "Mul", "Div", "Mod") @@ -2541,13 +2548,13 @@ def _optimized(root: Any, read: str | None, info: _Schema) -> _Form: if qualified is None: raise _Unsupported("认不全列") tree = qualified - _split_simple_ctes(tree) + _split_simple_ctes(tree, read, info) _inline_ctes(tree) # 写回的 CTE 定义各是一份副本,里面的别名一样:同一个 CTE 写回外层和相关子查询两处,并进去之后子查询里本来指外层 # 的列就被里层同名的别名遮住了。写回之后再把整条语句的别名改成互不相同的 _unique_aliases(tree) _merge_derived(tree, read, info) - _push_into_groups(tree) + _push_into_groups(tree, read, info) _drop_unused_ctes(tree) return _finish(tree, read, info, level=_OPTIMIZER, qualified=True) @@ -2589,8 +2596,8 @@ def _finish(tree: Any, read: str | None, info: _Schema, *, level: str, qualified sens = _Sensitivity(tree, read, info) _right_to_left(tree, sens) sens = _Sensitivity(tree, read, info) - _reject_nulls(tree, sens, read) - _flatten_prefix(tree, sens) + _reject_nulls(tree, read) + _flatten_prefix(tree, sens, read) _in_subquery_distinct(tree, sens) _distinct_to_group(tree, sens) _count_star(tree) @@ -3033,7 +3040,9 @@ def _merge_derived(tree: Any, read: str | None, info: _Schema) -> None: for _ in range(64): resolver = _Resolver(tree) - sens = _Sensitivity(tree, read, info, resolver) + # 浮点数求和这一条不挡并子查询:只筛行选列的子查询三种库自己也会并进外层(PostgreSQL 的 subquery pull-up、 + # MySQL 的 derived_merge、SQLite 的 flattening),并不并执行起来先后一样 + sens = _Sensitivity(tree, read, info, resolver, floats=False) done = False for select in reversed(list(tree.find_all(exp.Select))): if sens.of(select): @@ -3107,21 +3116,24 @@ def _merge_derived(tree: Any, read: str | None, info: _Schema) -> None: return -def _split_simple_ctes(tree: Any) -> None: +def _split_simple_ctes(tree: Any, read: str | None, info: "_Schema") -> None: """引用了好几次、只从几张表里筛行选列的 CTE(见 _mergeable),每处引用各写回一份 FROM 里的子查询(别名照旧): 这样的 CTE 是确定的(有不确定函数的语句不走这一级),算几遍结果一样,写回之后每处都能并进外层。语句里还有别的 - WITH 的不动(名字可能被里层的 CTE 遮住)。""" + WITH 的不动(名字可能被里层的 CTE 遮住);有一处引用在结果看行出来先后的一段里的不动(PostgreSQL 把引用了好几次 + 的 CTE 先算出来存着,写回之后扫描的先后可能就变了)。""" from sqlglot import exp with_ = tree.args.get("with_") if with_ is None or with_.args.get("recursive") or _nested_with(tree): return resolver = _Resolver(tree) + sens = _Sensitivity(tree, read, info, resolver) for cte in list(with_.expressions): if not _mergeable(cte.this): continue refs = [t for t in tree.find_all(exp.Table) if resolver.cte_of.get(id(t)) is cte] - if len(refs) < 2 or any(r.find_ancestor(exp.CTE) is not None for r in refs): + if len(refs) < 2 or any(r.find_ancestor(exp.CTE) is not None for r in refs) or \ + any(sens.of(resolver.owner[id(r)]) for r in refs): continue for ref in refs: ref.replace(exp.Subquery(this=cte.this.copy(), alias=exp.TableAlias(this=exp.to_identifier(ref.alias_or_name)))) @@ -3144,13 +3156,16 @@ def _grouped(expression: Any, keys: set[str]) -> bool: return True -def _push_into_groups(tree: Any) -> None: +def _push_into_groups(tree: Any, read: str | None, info: "_Schema") -> None: """(认列之后)外层 WHERE 里只涉及一个分组子查询(FROM 或内连接进来的)选出来的列的条件,换成子查询里对应的表达式、 挪进它的 HAVING:子查询的每一行就是一组,外层按这一行的值筛和 HAVING 按这一组的值筛,用的是同一个值(分组键取的 - 是同一个代表值,聚合是同一个结果)。子查询有窗口函数、取行、去重、ROLLUP 这些的不挪;HAVING 里的条件不往 WHERE - 挪(相等不等于一样时,组里的行各是各的值)。""" + 是同一个代表值,聚合是同一个结果)。条件涉及分组键时,分组键得是「相等就是一样」的(见 _Sensitivity._identity_safe): + MySQL 会把外层只涉及分组键的条件推进子查询的 WHERE 里先筛行,不分大小写时 BINARY city = 'SH' 推进去和放在 HAVING + 里结果不同(在真实的 MySQL 8.0 上跑出来过)。子查询有窗口函数、取行、去重、ROLLUP 这些的不挪;HAVING 里的条件 + 不往 WHERE 挪(相等不等于一样时,组里的行各是各的值)。""" from sqlglot import exp + sens = _Sensitivity(tree, read, info) for select in list(tree.find_all(exp.Select)): where = select.args.get("where") joins = select.args.get("joins") or [] @@ -3172,7 +3187,9 @@ def _push_into_groups(tree: Any) -> None: cols = list(cond.find_all(exp.Column)) if not cols or cond.find(exp.Query, exp.Subquery) is not None or \ any(c.table != alias or c.name not in outputs or not _grouped(outputs[c.name], keys) - for c in cols): + for c in cols) or \ + any(k is not None and not sens._identity_safe(k, body) + for c in cols for k in [outputs[c.name] if _fingerprint(outputs[c.name]) in keys else None]): kept.append(cond) continue new = cond.copy() @@ -3260,14 +3277,17 @@ class _Sensitivity: 取任意一个这类聚合(GROUP_CONCAT);聚合里既不分组也不聚合的列(SQLite、MySQL 取组里任意一行的值);当成标量 用的子查询(SQLite 取第一行),除非是不分组的聚合(只出一行);去重、分组、MIN / MAX、去重的 UNION 留下的是先 出现的那个值,而「相等」不等于「写法一样」的时候(MySQL 默认不分大小写的排序规则下的 'SH' 和 'sh'、PostgreSQL - numeric 的 2.0 和 2.00、SQLite 没有类型亲和的列里的 1 和 1.0、算出来的表达式)看先后,见 _identity_safe。 + numeric 的 2.0 和 2.00、SQLite 没有类型亲和的列里的 1 和 1.0、算出来的表达式)看先后,见 _identity_safe; + PostgreSQL、MySQL 里对不知道是不是精确数的值求和、求平均(浮点数加的先后不同,结果的最后几位就不同,见 _exact)。 给看先后的一段供行的(它 FROM 里的子查询、引用的 CTE,层层往里)也看先后。""" - def __init__(self, tree: Any, read: str | None, info: _Schema, resolver: _Resolver | None = None) -> None: + def __init__(self, tree: Any, read: str | None, info: _Schema, resolver: _Resolver | None = None, + floats: bool = True) -> None: from sqlglot import exp self.read = read self.info = info + self.floats = floats try: self.resolver = resolver or _Resolver(tree) except _Unsupported: @@ -3321,6 +3341,40 @@ class _Sensitivity: not self._identity_safe(n.this.expressions[0] if isinstance(n.this, exp.Distinct) and n.this.expressions else n.this, select): return True + if self.floats and self.read != "sqlite" and _float_aggregate(n) and not self._exact(n.this, select): + return True + return False + + def _exact(self, node: Any, select: Any, depth: int = 0) -> bool: + """node 的取值是不是精确的数(整数、定点小数):浮点数求和的先后不同结果就不同(PostgreSQL、MySQL 按扫到的先后 + 一个个加;SQLite 用 Kahan-Babuska-Neumaier 求和、关联先后也不跟着 FROM 的先后变,当成精确的)。不知道类型的不算。""" + from sqlglot import exp + + node = _unparen(node) + if isinstance(node, exp.Distinct) and len(node.expressions) == 1: + node = node.expressions[0] + if isinstance(node, exp.Count) or (isinstance(node, exp.Literal) and node.is_int): + return True + if isinstance(node, (exp.Add, exp.Sub, exp.Mul)): + return self._exact(node.left, select, depth) and self._exact(node.right, select, depth) + if not isinstance(node, exp.Column) or node.is_star or depth > 16 or self.resolver is None: + return False + item = self.resolver.source_of.get(id(node)) + if item is None: + items = _items_of(select) + item = items[0] if len(items) == 1 and not node.table else None + if item is None: + return False + body = self.resolver.body(item) + if body is None: + columns = self.info.types.get(item.name.translate(_ASCII_LOWER)) if isinstance(item, exp.Table) else None + declared = (columns or {}).get(node.name.translate(_ASCII_LOWER), "").lower() + return any(declared.startswith(w) for w in ("int", "integer", "bigint", "smallint", "tinyint", "mediumint", + "serial", "bigserial", "decimal", "numeric", "dec")) + if isinstance(body, exp.Select): + for p in body.expressions: + if not _is_star(p) and p.alias_or_name == node.name: + return self._exact(p.unalias(), body, depth + 1) return False def _identity_safe(self, node: Any, select: Any, depth: int = 0) -> bool: @@ -3399,6 +3453,18 @@ class _Sensitivity: "timestamp", "time", "bool", "uuid")) +def _float_aggregate(node: Any) -> bool: + """浮点数求和类的聚合(不在窗口函数里的)。""" + from sqlglot import exp + + if isinstance(node.parent, exp.Window): + return False + name = type(node).__name__.lower() + if isinstance(node, exp.Anonymous): + name = str(node.name).lower().replace("_", "") + return name in _FLOAT_AGGS + + def _order_dependent(node: Any) -> bool: from sqlglot import exp @@ -3598,15 +3664,16 @@ def _rejects(cond: Any, alias: str, read: str | None) -> bool: return False -def _reject_nulls(tree: Any, sens: _Sensitivity, read: str | None) -> None: +def _reject_nulls(tree: Any, read: str | None) -> None: """LEFT JOIN 关联进来的那一侧,同一段 WHERE 里有把它整行为空的行筛掉的条件(见 _rejects):没关联上的行反正留 - 不下来,这次外连接就是内连接。有 RIGHT / FULL JOIN 的段、结果看行出来的先后的段不动。""" + 不下来,这次外连接就是内连接。三种库的优化器自己也做这一步(outer join reduction),执行起来扫描的先后一样,结果 + 看行出来先后的一段也照做。有 RIGHT / FULL JOIN 的段不动。""" from sqlglot import exp for select in list(tree.find_all(exp.Select)): joins = select.args.get("joins") or [] where = select.args.get("where") - if where is None or sens.of(select) or any(str(j.side or "").upper() in ("RIGHT", "FULL") for j in joins): + if where is None or any(str(j.side or "").upper() in ("RIGHT", "FULL") for j in joins): continue conds = _conjuncts(where.this) for join in joins: @@ -3619,19 +3686,23 @@ def _reject_nulls(tree: Any, sens: _Sensitivity, read: str | None) -> None: join.this.meta[_REJECTED] = "RIGHT JOIN" if join.meta.get(_FLIPPED) else "LEFT JOIN" -def _flatten_prefix(tree: Any, sens: _Sensitivity) -> None: +def _flatten_prefix(tree: Any, sens: _Sensitivity, read: str | None) -> None: """开头那组内连接(FROM 和紧跟着的内连接、CROSS JOIN、逗号连接,到第一个外连接为止)的 ON 条件挪进 WHERE、写成 逗号连接:内连接的条件写在 ON 还是 WHERE 一样;后面的 LEFT JOIN 对左边每一行各自关联,左边先筛后筛一样。后面有 RIGHT / FULL JOIN 的不动:没关联上的那一侧整行空值,挪到 WHERE 的条件会把它们筛掉(PostgreSQL、MySQL 里逗号还比 - JOIN 结合得松,a, b RIGHT JOIN c 是 a 乘上 b RIGHT JOIN c)。结果看行出来的先后的一段也不动(SQLite 不调换 CROSS - JOIN 两边、会调换逗号连接)。""" + JOIN 结合得松,a, b RIGHT JOIN c 是 a 乘上 b RIGHT JOIN c)。内连接的条件写在 ON 还是 WHERE,优化器一视同仁,结果 + 看行出来先后的一段也照做;只有 SQLite 里结果看先后、又写了 CROSS JOIN 的一段不动(SQLite 不调换 CROSS JOIN 两边、 + 会调换逗号连接)。""" from sqlglot import exp for select in list(tree.find_all(exp.Select)): joins = select.args.get("joins") or [] - if not joins or select.args.get("from_") is None or sens.of(select) or \ + if not joins or select.args.get("from_") is None or \ any(str(j.side or "").upper() in ("RIGHT", "FULL") for j in joins): continue + if read in (None, "sqlite") and sens.of(select) and \ + any(str(j.kind or "").upper() == "CROSS" or j.meta.get("sql_semantics_join") for j in joins): + continue moved: list[Any] = [] for join in joins: if not _inner(join): @@ -3641,7 +3712,8 @@ def _flatten_prefix(tree: Any, sens: _Sensitivity) -> None: moved += _conjuncts(on) join.set("on", None) join.set("kind", None) - join.meta.pop("sql_semantics_join", None) + if not sens.of(select): + join.meta.pop("sql_semantics_join", None) if moved: where = select.args.get("where") select.set("where", exp.Where(this=_and_all((_conjuncts(where.this) if where else []) + moved))) diff --git a/backend/tests/fixtures/sql_pairs.py b/backend/tests/fixtures/sql_pairs.py index 452071fb..056b1491 100644 --- a/backend/tests/fixtures/sql_pairs.py +++ b/backend/tests/fixtures/sql_pairs.py @@ -739,18 +739,12 @@ DIALECT_SAME: list[tuple[str, str, str, str]] = [ ("names_given_to_derived_tables_are_no_difference", "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"), - ("the_same_derived_column_written_differently_is_no_difference", "mysql", - "SELECT SUM(x.total) FROM (SELECT p.user_id, p.total FROM (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) p JOIN (SELECT user_id, SUM(amount) AS total FROM refunds GROUP BY user_id) r ON r.user_id = p.user_id) x", - "SELECT SUM(y.total) FROM (SELECT a.user_id, a.total FROM (SELECT user_id, SUM(amount) AS total FROM refunds GROUP BY user_id) b JOIN (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) a ON a.user_id = b.user_id) y"), ("derived_tables_reading_the_same_table_written_differently_ar", "mysql", "WITH paid AS (SELECT * FROM orders WHERE status = 'paid'), open AS (SELECT * FROM orders WHERE status = 'open') SELECT SUM(p.amount) FROM paid p JOIN open o ON o.user_id = p.user_id", "WITH settled AS (SELECT * FROM orders WHERE status = 'paid'), pending AS (SELECT * FROM orders WHERE status = 'open') SELECT SUM(s.amount) FROM settled s JOIN pending q ON q.user_id = s.user_id"), ("names_given_to_derived_tables_are_no_difference", "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)"), - ("derived_tables_reading_the_same_table_written_differently_ar", "postgres", - "SELECT SUM(p.amount) FROM (SELECT * FROM orders WHERE status = 'paid') p JOIN (SELECT * FROM orders WHERE status = 'open') o ON o.user_id = p.user_id", - "SELECT SUM(x.amount) FROM (SELECT * FROM orders WHERE status = 'open') y JOIN (SELECT * FROM orders WHERE status = 'paid') x ON x.user_id = y.user_id"), ("the_same_output_computation_written_differently_is_no_differ", "postgres", "SELECT order_id, RANK() OVER (PARTITION BY user_id ORDER BY amount DESC) AS k FROM orders", "SELECT o.order_id, RANK() OVER (PARTITION BY o.user_id ORDER BY o.amount DESC) FROM orders o"), @@ -2149,6 +2143,17 @@ REGRESSIONS_R2: list[tuple[str, str, str, str]] = [ ("float_folding", "postgres", "SELECT COUNT(*) FROM orders WHERE amount = 0.1 + 0.2", "SELECT COUNT(*) FROM orders WHERE amount = 0.3"), ("double_negation", "mysql", "SELECT id, -(-name) FROM users", "SELECT id, name FROM users"), + # 本轮自己在私起的 PostgreSQL 18、MySQL 8.0 上跑出来的:调换 UNION ALL 各支,浮点数加的先后变了 + # (1902.2 对 1902.2000000000003);MySQL 把外层只涉及分组键的条件推进子查询的 WHERE,和 HAVING 结果不同 + ("float_sum_union_branch_order", "postgres", + "SELECT SUM(x) FROM (SELECT amount AS x FROM orders UNION ALL SELECT amount AS x FROM refunds) t", + "SELECT SUM(x) FROM (SELECT amount AS x FROM refunds UNION ALL SELECT amount AS x FROM orders) t"), + ("float_sum_union_branch_order", "mysql", + "SELECT SUM(x) FROM (SELECT amount AS x FROM orders UNION ALL SELECT amount AS x FROM refunds) t", + "SELECT SUM(x) FROM (SELECT amount AS x FROM refunds UNION ALL SELECT amount AS x FROM orders) t"), + ("outer_where_on_group_key_vs_having", "mysql", + "SELECT COUNT(*) FROM (SELECT city, COUNT(*) AS n FROM users GROUP BY city) t WHERE BINARY t.city = 'SH'", + "SELECT COUNT(*) FROM (SELECT city, COUNT(*) AS n FROM users GROUP BY city HAVING BINARY city = 'SH') t"), ] #: 意思一样、规范化认得出的(PostgreSQL / MySQL 的写法) @@ -2167,8 +2172,16 @@ DIALECT_SAME_R2: list[tuple[str, str, str, str]] = [ "SELECT SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) FROM orders"), ] -#: 意思一样、规范化故意不认的(认了就要假设「相等就是一样」,见模块说明):只给兜底说明,不给具体说法 +#: 意思一样、规范化故意不认的(认了就要假设「相等就是一样」、或者 PostgreSQL / MySQL 里浮点数求和的先后不变,见模块 +#: 说明):只给兜底说明,不给具体说法 KNOWN_UNRECOGNIZED: list[tuple[str, str, str, str]] = [ + # PostgreSQL / MySQL 里对浮点数求和的一段调换关联先后:加的先后变了,结果的最后几位可能不同 + ("the_same_derived_column_written_differently_is_no_difference", "mysql", + "SELECT SUM(x.total) FROM (SELECT p.user_id, p.total FROM (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) p JOIN (SELECT user_id, SUM(amount) AS total FROM refunds GROUP BY user_id) r ON r.user_id = p.user_id) x", + "SELECT SUM(y.total) FROM (SELECT a.user_id, a.total FROM (SELECT user_id, SUM(amount) AS total FROM refunds GROUP BY user_id) b JOIN (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) a ON a.user_id = b.user_id) y"), + ("derived_tables_reading_the_same_table_written_differently_ar", "postgres", + "SELECT SUM(p.amount) FROM (SELECT * FROM orders WHERE status = 'paid') p JOIN (SELECT * FROM orders WHERE status = 'open') o ON o.user_id = p.user_id", + "SELECT SUM(x.amount) FROM (SELECT * FROM orders WHERE status = 'open') y JOIN (SELECT * FROM orders WHERE status = 'paid') x ON x.user_id = y.user_id"), ("having_on_group_key_vs_where", "sqlite", "WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders WHERE user_id > 100 GROUP BY user_id) SELECT SUM(total) FROM t", "WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) SELECT SUM(total) FROM t WHERE user_id > 100"),