SQL 语义复核修正(第 3 轮):两边的 CTE、子查询先按结构配好对再比,不再拿「不写编号」的写法补配

上一轮给派生来源(CTE、FROM 里的子查询)只编了位置编号,编号错开时再按不写编号的写法(派生来源一律写「@」)补配一遍。复验发现三处问题,根因相同:派生来源没有结构上的标识。一是漏报:两个 CTE 都算出 total(付款合计、退款合计),编号一致时 SUM(p.total) 对 SUM(r.total)、WHERE p.total > 100 对 r.total > 100、ORDER BY、HAVING、差值写反,都被补配吞掉、返回空列表。二是误报:盲算多一个排在前面的 CTE(vip),原流程的 t(@1)按编号配给了盲算的 vip(@1),报出「关联 t 表的条件是 orders.user_id = users.id,盲算是 users.id = users.id」「盲算关联了 t 表」。三是误报:内连接换了先后(FROM paid JOIN refunded 换成 FROM refunded JOIN paid),编号跟着对调,比较的两边按编号排成两个方向,报出两条自相矛盾的话。

现在 diff_facets 先给两边每个派生来源算一份结构标识(_Derived):里面读到的实表(FROM / JOIN,往里套着的也算)、选出来的各列规范化后的定义式(total 是 SUM(orders.amount))、分组键、里面的条件、整段规范化后的写法和起的名字;按从严到宽几级一对一地配:整段一样,表和各列定义、分组一样,表和列名一样,表和名字一样,最后三级只在两边各剩一个时才配(只是表一样、只是名字一样、只是列名一样);同一级几对都配得上时先配同名的、再配条件重合多的、最后看先后,哪一级都配不上的不配,没用到的 CTE 也参加配对。配好之后第二条 SQL 的派生来源改用第一条里对应的编号(配不上的另起编号),facets 记下原文(Facets._origin,不进比较和 as_dict),按共同的编号重新分析一遍再比:比较两边谁在前、关联条件的先后都按共同编号排,不再受位置编号左右。_Compare 去掉了不写编号的补配(删掉 Part.anon),loose 里派生来源的编号照写,筛选按同一列配对时列也带编号。

连接:内连接、CROSS JOIN 按涉及的来源和条件比(JoinFacet.sides),不按哪一侧是关联进来的,实表的内连接换了先后也不再报;配成一对的两边名字不同时两个名字都说;一边独有的内连接、关联进来的那一侧另一边也有时,报它接上的另一头(FROM stores JOIN orders、另一边只查 orders:多出来的是 stores,不是 orders;Facets.sources 记各段 FROM / JOIN 的来源拿来对照,条件里子查询的来源不算),已经说了「关联了」的表不再另说一遍「用到了」。取行:CTE、子查询里的取行按所属派生来源配好的编号(LimitFacet.scope_key)配对,一边多一个限了行数的 CTE 不再和另一边的 CTE 凑对;LATERAL 子查询照写它的别名。筛选、HAVING:两边各自独有、读起来却一样的条件(p.total > 100 和 r.total > 100 都只读得出 total > 100)配成一对、改用带来源名字的写法说,不再各说一句。

模块说明、Part / JoinFacet / LimitFacet / Facets、unmatched、joins 的文档按现在的做法改写,做不到的照实列出(外层按名字引用的列两边起名不同算两列;读同一张表、各列定义一样的两个派生来源两边都改了条件又改了名时可能配反;全换了的两个派生来源不配对;自连接分不出两个别名;LEFT JOIN 对调成 RIGHT JOIN、内连接的单表条件写在 ON 还是 WHERE、三张以上不带条件的表换先后仍算不同)。测试补上三处问题的反例(CTE、FROM 子查询、改名又改先后的写法)和上面各项,原有 99 项照旧通过。
This commit is contained in:
ThearchyHelios
2026-10-05 03:36:12 +08:00
parent 35bb23d372
commit db91c9eaf6
2 changed files with 652 additions and 129 deletions
+431 -115
View File
@@ -17,12 +17,29 @@
写在 WHERE 里的列等式算作关联条件,不算筛选。条件里套着的子查询(EXISTS、IN (SELECT …))既单算一段,也按它自己 写在 WHERE 里的列等式算作关联条件,不算筛选。条件里套着的子查询(EXISTS、IN (SELECT …))既单算一段,也按它自己
那一段的来源整个写进外层那个条件:别名在子查询里定义,拿外层的来源去认,同一个条件只因别名不同就会被报成不同。 那一段的来源整个写进外层那个条件:别名在子查询里定义,拿外层的来源去认,同一个条件只因别名不同就会被报成不同。
**各自起的名字不进比较。** 表的别名换成真实表名;CTE、FROM 里的子查询没有真实表名可换,比较时按它在语句里的位置 **各自起的名字不进比较。** 表的别名换成真实表名;CTE、FROM 里的子查询(含 LATERAL)没有真实表名可换,单看一条
编号(@1、@2…,从外往里、按第一次用到的先后),给人看时照写名字;条件里的子查询(IN、EXISTS、标量子查询)选出来 语句时按它在语句里的位置编号(@1、@2…,从外往里、按第一次用到的先后),给人看时照写名字;条件里的子查询(IN、
的列名不影响取值,比较时去掉。这些名字都是各自起的,双盲复算的盲算更是:按名字比,同一个口径会被说成换了口径。 EXISTS、标量子查询)选出来的列名不影响取值,比较时去掉。这些名字都是各自起的,双盲复算的盲算更是:按名字比,同一个
编号只看位置,一边多出一个排在前面的 CTE、子查询,后面的编号就错开了:diff_facets 先按编号比,比不上的再按不写 口径会被说成换了口径。
编号的写法一对一地配,配上的不算不同。仍按名字比的只有 CTE、子查询选出来、外层按名字引用的列(一边叫 total、
一边叫 sum_amt,算两列)。 **两边的派生来源按结构配对。** 位置编号只看先后:一边多出一个排在前面的 CTE,或者内连接两边换了先后,编号就错开
了;可也不能干脆不看是哪一个(两个 CTE 都算出 total 时,外层用的是付款合计还是退款合计,正是要报的不同)。所以
diff_facets 先给两边的每个派生来源算一份结构标识(见 _Derived):里面读到的实表(FROM / JOIN,往里套着的也算)、
选出来的各列怎么定义(规范化后的定义式,如 total 是 SUM(orders.amount))、按什么分组、整段规范化后的写法,以及
起的名字;再按从严到宽的几级一对一地配(见 _align):整段一样;读的表、各列定义、分组一样(里面的筛选条件可以不
同);读的表和选出来的列名一样(某一列的算法可以不同);读的表和名字一样;最后三级只在两边都只剩一个时才配:只是
读的表一样、只是名字一样、只是选出来的列名一样。同一级有几个都配得上时,先挑同名的,再挑里面的条件重合最多的,最后
才看位置编号的先后。哪一级都配不上的不配:一边多出来的 CTE 不会被拿去和另一边不相干的 CTE 凑成一对。配好之后,
第二条 SQL 的派生来源改用第一条里对应的那个编号(配不上的另起编号),按这个共同的编号重新分析一遍再逐个方面比:
比较的两边谁在前、关联条件的先后都按共同的编号排,内连接换了先后也排成同一个方向。连接里的内连接(含逗号连接、
CROSS JOIN)按涉及的来源和条件比,不按哪一侧是关联进来的;一边独有的一次内连接,关联进来的那一侧另一边也有时,报
的是它接上的另一头(FROM stores JOIN orders、另一边只查 orders:多出来的是 stores)。
仍做不到的:两边给 CTE、子查询选出来、外层按名字引用的列起的名字不同(一边叫 total、一边叫 sum_amt),算两列;读同
一张表、各列定义也一样的两个派生来源,两边都改了里面的条件、又各起了名字时,只能按条件的重合和先后配,可能配反;
读的表、选出来的列名、名字全换了的两个派生来源不配对,外层按它们的列写的条件照报成不同;同一张表、同一个 CTE 自己
关联自己时两个别名比较用的写法一样,p1.total > p2.total 写反了认不出;连接上做不到的见 _Compare.joins。facets 单独
返回的 key 仍是位置编号;不是 facets 算出来的 Facets(没记原文)diff_facets 也只能按位置编号比。
**和 sqlcheck 一样宁可漏报。** 解析不了(语法错、方言特性、多条语句、不是查询)就带 parse_error 返回,从不抛 **和 sqlcheck 一样宁可漏报。** 解析不了(语法错、方言特性、多条语句、不是查询)就带 parse_error 返回,从不抛
异常。没加表前缀、又有好几张表可能有这一列的,照写列名(给了表结构才去对)。只读语法树,不执行 SQL。 异常。没加表前缀、又有好几张表可能有这一列的,照写列名(给了表结构才去对)。只读语法树,不执行 SQL。
@@ -33,7 +50,7 @@ import logging
import re import re
from collections import deque from collections import deque
from collections.abc import Iterable, Iterator, Mapping from collections.abc import Iterable, Iterator, Mapping
from dataclasses import dataclass, fields, is_dataclass from dataclasses import dataclass, field, fields, is_dataclass, replace
from itertools import zip_longest from itertools import zip_longest
from typing import Any from typing import Any
@@ -51,9 +68,10 @@ MAX_SQL_CHARS = 100_000
#: 算好的来源(别名、外层):复制品没有父节点,自己找不到外层 #: 算好的来源(别名、外层):复制品没有父节点,自己找不到外层
_SEGMENT = "sql_semantics_segment" _SEGMENT = "sql_semantics_segment"
#: 派生来源(引用 CTE 的表和 CTE 的定义、FROM / JOIN 里的子查询)在语法树上记比较用编号的 meta 键(见 #: 派生来源(引用 CTE 的表和 CTE 的定义、FROM / JOIN 里的子查询)在语法树上记比较用编号的 meta 键(见
#: _Statement._number_derived)。复制出来的条件里套着子查询时,靠它把 CTE 名、子查询的别名换成编号 #: _Statement._number_derived、_Statement.relabel)。复制出来的条件里套着子查询时,靠它把 CTE 名、子查询的别名
#: 换成编号
_DERIVED = "sql_semantics_derived" _DERIVED = "sql_semantics_derived"
#: 比较用的写法里不写编号时,派生来源一律写成这个(见 Part.anon) #: 不写编号的写法(render 的 anon 模式)里派生来源一律写成这个:算结构标识(_Derived)时用,和编号无关
_ANON = "@" _ANON = "@"
@@ -214,14 +232,14 @@ ColumnRef = tuple[str | None, str]
class Part: class Part:
"""一个条件、一个分组键、一个聚合参数…… """一个条件、一个分组键、一个聚合参数……
key 用来比较:别名换成真实表名(CTE、子查询写编号),表名、列名按 name_key,等式和比较的两边排好顺序;loose 同 key 用来比较:别名换成真实表名,CTE、子查询写编号(facets 单独返回的是位置编号,diff_facets 里是两边按结构配好
key 但不写表名,两边查的表换了一张(orders 换成 orders_archive)时拿它比其余各方面,免得同一个条件只因表名不同 对的共同编号,见模块说明),表名、列名按 name_key,等式和比较的两边排好顺序;loose 同 key 但不写实表的表名(派生
被报成不同;anon 同 key,但 CTE、子查询一律写「@」、不写编号(loose 也不写),编号错开时拿它再配一遍(见 来源的编号照写:它不是表名,换了表也还是配好的那一个),两边查的表换了一张(orders 换成 orders_archive)时拿它
_Compare.unmatched)。text 给人看:好几张表都有的列写「表.列」(真实表名,不是别名),其余只写列名;full 一律 比其余各方面,免得同一个条件只因表名不同被报成不同。text 给人看:好几张表都有的列写「表.列」(真实表名,不是
写表名,两边的 text 一样、key 却不同时改用它说明。excludes 只在 WHERE 的「排除」写法上有:<>、NOT、NOT IN、 别名),其余只写列名;full 一律写表名,两边的 text 一样、key 却不同时改用它说明。excludes 只在 WHERE 的「排除」
IS NOT NULL 去掉否定之后排除的是什么(IS NOT NULL 写成「列 为空」)。column_keys 和 columns 一一对应,是各列 写法上有:<>、NOT、NOT IN、IS NOT NULL 去掉否定之后排除的是什么(IS NOT NULL 写成「列 为空」)。column_keys 和
比较用的写法(实表.列;CTE、子查询的列写成「@.列」):两边给 CTE、子查询起的名字不同、编号错开,也认得出两个 columns 一一对应,是各列比较用的写法(实表.列;CTE、子查询的列写成「编号.列」):两边对同一列的不同条件靠它配成
条件说的是同一列,配成一对说。 一对说。
""" """
key: str key: str
@@ -231,24 +249,28 @@ class Part:
loose: str = "" loose: str = ""
full: str = "" full: str = ""
column_keys: tuple[str, ...] = () column_keys: tuple[str, ...] = ()
anon: str = ""
@staticmethod @staticmethod
def plain(key: str, text: str) -> "Part": def plain(key: str, text: str) -> "Part":
"""没有列的一项(表名、COUNT(*) 的「*」):比较用的写法都是 key,给人看的都是 text。""" """没有列的一项(表名、COUNT(*) 的「*」):比较用的写法都是 key,给人看的都是 text。"""
return Part(key=key, text=text, loose=key, full=text, anon=key) return Part(key=key, text=text, loose=key, full=text)
@dataclass(frozen=True) @dataclass(frozen=True)
class JoinFacet: class JoinFacet:
"""一次连接。kind:inner / left / right / full / cross / semi / anti / natural。table 是关联进来的那一侧,other """一次连接。kind:inner / left / right / full / cross / semi / anti / natural。table 是关联进来的那一侧,other
是另一侧:关联条件里出现的别的表(给人看的真实表名);没有条件的(CROSS JOIN)是在它之前出现的各个来源。on 是 是另一侧:关联条件里出现的别的表(给人看的真实表名);没有条件的(CROSS JOIN)是在它之前出现的各个来源。on 是
关联条件的合取式排好序后用 AND 连起来的写法,没有条件是 None。""" 关联条件的合取式排好序后用 AND 连起来的写法,没有条件是 None。
sides 是这次连接涉及的来源(比较用的写法,排好序):关联进来的那一侧,加上关联条件里直接引用的这一段的来源(不
透过 CTE 找实表);没有条件的加上它之前出现的各个来源。内连接、CROSS JOIN 两边换个先后意思不变(FROM paid JOIN
refunded 和 FROM refunded JOIN paid),diff_facets 比它们时按 sides 和条件比,不按哪一侧是关联进来的。"""
kind: str kind: str
table: Part table: Part
on: Part | None = None on: Part | None = None
other: tuple[str, ...] = () other: tuple[str, ...] = ()
sides: tuple[str, ...] = ()
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -277,12 +299,15 @@ class Aggregate:
class LimitFacet: class LimitFacet:
"""一段查询取哪几行。count、offset 是行数和跳过行数的写法(没写为 None,OFFSET 0 也算没写);order 是这一段的 """一段查询取哪几行。count、offset 是行数和跳过行数的写法(没写为 None,OFFSET 0 也算没写);order 是这一段的
排序键 (Part, 是否降序):只取前几行时,按什么排决定了取到的是哪几行。scope 是哪一段:None 是最外层,CTE 写它的 排序键 (Part, 是否降序):只取前几行时,按什么排决定了取到的是哪几行。scope 是哪一段:None 是最外层,CTE 写它的
名字,FROM 里的子查询写它的别名(没有别名写「子查询」)。条件里套着的子查询不单算:它整个写在那个条件里比。""" 名字,FROM 里的子查询写它的别名(没有别名写「子查询」)。条件里套着的子查询不单算:它整个写在那个条件里比。
scope_key 是这一段所属派生来源的编号(和 Part.key 里的同一套编号;最外层、UNION 的某一支这类不是派生来源整段的
为 None):diff_facets 按它把两边同一个 CTE、子查询里的取行配成一对。"""
count: str | None count: str | None
offset: str | None = None offset: str | None = None
order: tuple[tuple[Part, bool], ...] = () order: tuple[tuple[Part, bool], ...] = ()
scope: str | None = None scope: str | None = None
scope_key: str | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -291,6 +316,14 @@ class Facets:
limit 是最外层 LIMIT 的行数(写法);limits 是各段取哪几行(最外层在前,见 LimitFacet),只有写了 LIMIT 或 limit 是最外层 LIMIT 的行数(写法);limits 是各段取哪几行(最外层在前,见 LimitFacet),只有写了 LIMIT 或
OFFSET 的段才有。 OFFSET 的段才有。
sources 是各段 FROM / JOIN 里的来源(实表、CTE、子查询;key 是比较用的写法,派生来源是编号,text 是给人看的
名字),按出现的先后、按 key 去重;条件里套着的子查询(IN (SELECT … FROM users))的来源不算,它们只筛行,不是
连接的一头。它本身不单独比较:一边独有的一次内连接要说清多出来的是哪个来源时,拿另一边的 sources 对照(见
_Compare.added)。
_origin 是 facets 记下的 (原文, 表结构),不进比较、不进 as_dict:diff_facets 把两边的派生来源按结构配好对之后,
拿它按共同的编号重新分析一遍(见模块说明)。
""" """
dialect: str | None = None dialect: str | None = None
@@ -303,7 +336,9 @@ class Facets:
having: tuple[Part, ...] = () having: tuple[Part, ...] = ()
limit: str | None = None limit: str | None = None
limits: tuple[LimitFacet, ...] = () limits: tuple[LimitFacet, ...] = ()
sources: tuple[Part, ...] = ()
parse_error: str | None = None parse_error: str | None = None
_origin: tuple[str, Mapping[str, Any] | None] | None = field(default=None, compare=False, repr=False)
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
return _jsonable(self) return _jsonable(self)
@@ -311,7 +346,7 @@ class Facets:
def _jsonable(value: Any) -> Any: def _jsonable(value: Any) -> Any:
if is_dataclass(value) and not isinstance(value, type): if is_dataclass(value) and not isinstance(value, type):
return {f.name: _jsonable(getattr(value, f.name)) for f in fields(value)} return {f.name: _jsonable(getattr(value, f.name)) for f in fields(value) if not f.name.startswith("_")}
if isinstance(value, (list, tuple)): if isinstance(value, (list, tuple)):
return [_jsonable(v) for v in value] return [_jsonable(v) for v in value]
return value return value
@@ -322,13 +357,15 @@ def facets(sql: str, dialect: str | None = None, *, schema: Mapping[str, Any] |
schema 是数据源的 schema_cache 形状({"tables": {表名: {"columns": [{"name": …}]}}}),可以不给。给了的话, schema 是数据源的 schema_cache 形状({"tables": {表名: {"columns": [{"name": …}]}}}),可以不给。给了的话,
几张表连在一起、列又没加表前缀时按它认列是哪张表的;不给就照写列名,比较时可能把同一列当成两列。 几张表连在一起、列又没加表前缀时按它认列是哪张表的;不给就照写列名,比较时可能把同一列当成两列。
返回的 Facets 记着原文和表结构(_origin):diff_facets 要把两边的派生来源按结构配好对再重新分析。
""" """
read = _read(dialect) read = _read(dialect)
root, problem = _parse(sql, read) root, problem = _parse(sql, read)
if root is None: if root is None:
return Facets(dialect=read, parse_error=problem) return Facets(dialect=read, parse_error=problem)
try: try:
return _Statement(root, read, schema).facets() return replace(_Statement(root, read, schema).facets(), _origin=(sql, schema))
except Exception: # noqa: BLE001 - 分析本身的缺陷不能连累调用方:当作分析不了,记下来修 except Exception: # noqa: BLE001 - 分析本身的缺陷不能连累调用方:当作分析不了,记下来修
logger.warning("SQL 语义分析失败", exc_info=True) logger.warning("SQL 语义分析失败", exc_info=True)
return Facets(dialect=read, parse_error="SQL 的结构无法分析") return Facets(dialect=read, parse_error="SQL 的结构无法分析")
@@ -423,6 +460,17 @@ def _derived_body(item: Any) -> Any:
return item.this if isinstance(item, exp.Subquery) else None return item.this if isinstance(item, exp.Subquery) else None
def _branches(query: Any) -> list[Any]:
"""一段查询的各支 SELECT,按出现的先后:UNION 这类拆开,外面套的括号去掉;认不出的返回空列表。"""
from sqlglot import exp
while isinstance(query, exp.Subquery):
query = query.this
if isinstance(query, exp.SetOperation):
return _branches(query.this) + _branches(query.expression)
return [query] if isinstance(query, exp.Select) else []
def _own_nodes(select: Any) -> Iterator[Any]: def _own_nodes(select: Any) -> Iterator[Any]:
"""一段 SELECT 自己的节点:不进 CTE、子查询(它们是别的段)。""" """一段 SELECT 自己的节点:不进 CTE、子查询(它们是别的段)。"""
from sqlglot import exp from sqlglot import exp
@@ -498,6 +546,69 @@ class _Source:
query: Any = None # derived:里面那段查询 query: Any = None # derived:里面那段查询
@dataclass(frozen=True)
class _Derived:
"""一个派生来源(CTE、FROM / JOIN 里的子查询、LATERAL 子查询)的结构标识,和起的名字、位置编号无关的部分都在
这里,diff_facets 拿它把两边的派生来源配成对(见 _align)。各项都用不写编号的写法(render 的 anon 模式:实表
写表名,往里套着的派生来源一律写「@」),所以两边写法一样就相同,不受先后、名字影响。"""
number: int # 位置编号(@N 的 N):别的都分不出时才看先后
name: str # CTE 名、子查询的别名(name_key),没有为空:辅助线索
tables: tuple[str, ...] # 里面读到的实表:各支 FROM / JOIN,往里套着的也算;排好序
columns: tuple[tuple[str, str], ...] # 选出来的各列 (列名, 规范化后的定义式),SELECT * 记成 ("*", "*")
group: tuple[str, ...] # 分组键,排好序
conditions: frozenset[str] # 里面各段 WHERE、HAVING 的合取式:同一级配得上好几个时比重合
body: str # 整段查询规范化后的写法
@property
def names(self) -> tuple[str, ...]:
"""选出来的列名(外层按名字引用它们)。"""
return tuple(name for name, _ in self.columns)
#: 派生来源配对的各级,从严到宽:(这一级比什么, 是否只在两边都只剩一个时才配)。比什么返回 None 表示这一级没有
#: 可比的(没有名字、没读实表),不配
_ALIGN_LEVELS: tuple[tuple[Any, bool], ...] = (
(lambda d: (d.body, d.columns), False), # 整段一样
(lambda d: (d.tables, d.columns, d.group), False), # 读的表、各列定义、分组一样
(lambda d: (d.tables, d.names), False), # 读的表、选出来的列名一样
(lambda d: (d.tables, d.name) if d.name else None, False), # 读的表、名字一样
(lambda d: d.tables or None, True), # 只是读的表一样
(lambda d: d.name or None, True), # 只是名字一样(里面换了表)
(lambda d: d.names if any(n != "*" for n in d.names) else None, True), # 只是选出来的列名一样
)
def _align(xs: list[_Derived], ys: list[_Derived]) -> dict[int, int]:
"""两条语句的派生来源配对:{第二条里的位置编号: 第一条里的位置编号},配不上的不在里面。
按 _ALIGN_LEVELS 从严到宽一级一级地配,每一级一对一:严的先配走,宽的只收拾剩下的。同一级几对都配得上时,先配
同名的,再配里面的条件重合最多的,最后按位置编号的先后(几个一模一样的派生来源按先后一一对应);这一级所有
配得上的对一起排,不是左边挨个挑,免得前一个挑走后一个更合适的。最后三级证据弱,只在两边各只剩一个这样的时才
配,免得猜错。哪一级都配不上的就不配:一边多出来的 CTE(盲算先筛一遍 VIP 用户)和另一边不相干的 CTE 凑成一对,
会把外层不相干的条件说成「关联条件不同」。
"""
rest_x, rest_y = sorted(xs, key=lambda d: d.number), sorted(ys, key=lambda d: d.number)
pairs: dict[int, int] = {}
for view, unique in _ALIGN_LEVELS:
candidates = []
for x in rest_x:
seen = view(x)
if seen is None:
continue
found = [y for y in rest_y if view(y) == seen]
if unique and (len(found) != 1 or sum(1 for z in rest_x if view(z) == seen) != 1):
continue
candidates += [((y.name != x.name, -len(y.conditions & x.conditions), x.number, y.number), x, y)
for y in found]
for _, x, y in sorted(candidates, key=lambda c: c[0]):
if x.number not in pairs.values() and y.number not in pairs:
pairs[y.number] = x.number
rest_x = [x for x in rest_x if x.number not in pairs.values()]
rest_y = [y for y in rest_y if y.number not in pairs]
return pairs
class _Statement: class _Statement:
"""一条语句的分析:CTE、实表、每段 SELECT 的来源,以及把表达式写成 key / text 的规则。""" """一条语句的分析:CTE、实表、每段 SELECT 的来源,以及把表达式写成 key / text 的规则。"""
@@ -552,14 +663,23 @@ class _Statement:
CTE 名、子查询的别名是各自起的(双盲复算的盲算更是),拿它作 key,同一个口径只因起的名字不同就被报成不同; CTE 名、子查询的别名是各自起的(双盲复算的盲算更是),拿它作 key,同一个口径只因起的名字不同就被报成不同;
拿里面那段查询的写法作 key 也不行:里面差一个条件,外层按它的列写的条件就全跟着变,同一处不同报好几遍,还会 拿里面那段查询的写法作 key 也不行:里面差一个条件,外层按它的列写的条件就全跟着变,同一处不同报好几遍,还会
出现两边各说一句「只保留 total > 100 的行」的自相矛盾。所以只按位置编:从最外层往里一层一层地编,同一层按 出现两边各说一句「只保留 total > 100 的行」的自相矛盾。所以单看一条语句时按位置编:从最外层往里一层一层地
出现的先后;CTE 在第一次用到的地方编号,在哪儿用到都是这一个编号。写成 CTE 还是 FROM 里的子查询、CTE 定义 编,同一层按出现的先后;CTE 在第一次用到的地方编号,在哪儿用到都是这一个编号。写成 CTE 还是 FROM 里的子查询、
的先后都不影响编号,里层多一段、少一段也不挪动外层的编号。 CTE 定义的先后都不影响编号,里层多一段、少一段也不挪动外层的编号。两条语句之间编号错开(一边多一个 CTE、
内连接换了先后)由 diff_facets 按结构配对之后改成共同的编号(见 relabel)。
""" """
from sqlglot import exp from sqlglot import exp
ctes: dict[str, str] = {} ctes: dict[str, int] = {}
count = 0 count = 0
#: 编了号的派生来源 [(位置编号, 起的名字, 里面那段查询)],按编号排:diff_facets 按它们的结构配对(见 derived)
self._derived: list[tuple[int, str, Any]] = []
#: 记了编号的节点 {id: (节点, 位置编号)}:配好对之后按共同的编号改写(见 relabel)
self._marked: dict[int, tuple[Any, int]] = {}
def mark(node: Any, number: int) -> None:
node.meta[_DERIVED] = f"@{number}"
self._marked[id(node)] = (node, number)
def visit(select: Any) -> list[Any]: def visit(select: Any) -> list[Any]:
"""给这一段还没编号的 FROM / JOIN 项编号,返回要接着往里看的查询(子查询、第一次用到的 CTE 的定义)。""" """给这一段还没编号的 FROM / JOIN 项编号,返回要接着往里看的查询(子查询、第一次用到的 CTE 的定义)。"""
@@ -570,17 +690,19 @@ class _Statement:
if body is not None: if body is not None:
if _DERIVED not in item.meta: if _DERIVED not in item.meta:
count += 1 count += 1
item.meta[_DERIVED] = f"@{count}" mark(item, count)
self._derived.append((count, _alias(item), body))
inner.append(body) inner.append(body)
elif isinstance(item, exp.Table) and isinstance(item.this, exp.Identifier) and item.name \ elif isinstance(item, exp.Table) and isinstance(item.this, exp.Identifier) and item.name \
and self._is_cte(item): and self._is_cte(item):
name = name_key(item.name) name = name_key(item.name)
if name not in ctes: if name not in ctes:
count += 1 count += 1
ctes[name] = f"@{count}" ctes[name] = count
self._cte_defs[name].meta[_DERIVED] = ctes[name] mark(self._cte_defs[name], count)
self._derived.append((count, self._cte_defs[name].alias, self.ctes[name]))
inner.append(self.ctes[name]) inner.append(self.ctes[name])
item.meta[_DERIVED] = ctes[name] mark(item, ctes[name])
return inner return inner
queue: deque[Any] = deque([self.root]) queue: deque[Any] = deque([self.root])
@@ -594,6 +716,86 @@ class _Statement:
# 没走到的(没用到的 CTE 里的、表函数参数里的)按出现的先后补上:每个派生来源都得有编号 # 没走到的(没用到的 CTE 里的、表函数参数里的)按出现的先后补上:每个派生来源都得有编号
for select in self.root.find_all(exp.Select, bfs=False): for select in self.root.find_all(exp.Select, bfs=False):
visit(select) visit(select)
# 没用到的 CTE 也编上号(排在最后):外层不引用它,可它照样要参加两边的配对。两边都定义了付款、退款两个 CTE,
# 一边关联付款的、一边关联退款的,没用到的那个不参加的话,两边各剩一个只有列名对得上的,会被凑成一对
for name, cte in self._cte_defs.items():
if name not in ctes:
count += 1
ctes[name] = count
mark(cte, count)
self._derived.append((count, cte.alias, self.ctes[name]))
def relabel(self, ids: Mapping[int, str]) -> None:
"""派生来源改用 ids 给的编号({位置编号: 比较用的编号},没给的照旧写位置编号)。diff_facets 把两边的派生
来源配好对之后,第二条语句里配上的写第一条里对应的编号,两边比较用的写法才对得上。算好的来源、共有的列名
都带着旧编号,跟着清掉重算。"""
for node, number in self._marked.values():
node.meta[_DERIVED] = ids.get(number, f"@{number}")
self._memo.clear()
self._shared = None
def derived(self) -> list[_Derived]:
"""各个派生来源的结构标识(见 _Derived),按位置编号排。"""
from sqlglot import exp
out = []
for number, name, body in self._derived:
columns: list[tuple[str, str]] = []
group: list[str] = []
conditions: set[str] = set()
for i, select in enumerate(_branches(body)):
scope = self.sources(select)
for clause in (select.args.get("where"), select.args.get("having")):
if clause is not None:
conditions.update(self.render(self.canonical(c, scope), scope, "anon")
for c in _conjuncts(clause.this))
if i:
continue # UNION 选出来的列名、分组按第一支的算
for item in select.selects:
if isinstance(item, exp.Star) or (isinstance(item, exp.Column) and item.is_star):
columns.append(("*", "*"))
continue
target = item.this if isinstance(item, exp.Alias) else item
columns.append((name_key(item.alias_or_name),
self.render(self.canonical(target, scope), scope, "anon")))
if (clause := select.args.get("group")) is not None:
selects, aliases = select.selects, self._aliases(select)
group += [self.render(self.canonical(self._target(g, selects, aliases), scope), scope, "anon")
for g in clause.expressions]
out.append(_Derived(number=number, name=name_key(name) if name else "",
tables=tuple(sorted(self._row_tables(body, frozenset()))),
columns=tuple(sorted(columns)), group=tuple(sorted(group)),
conditions=frozenset(conditions),
body=self.render(self.canonical(body, {}), {}, "anon")))
return out
def _row_tables(self, query: Any, seen: frozenset[str]) -> set[str]:
"""query 的行从哪些实表来:各支 FROM / JOIN 里的实表,往里套着的子查询、CTE 也算。条件里的子查询不算(它只
决定留哪些行,算进来的话一边多一个 IN (SELECT … FROM users) 的筛选,结构标识就整个变了)。seen 是这条路上
已经进过的 CTE:递归 CTE 引用自己时不再往里走。"""
from sqlglot import exp
out: set[str] = set()
for select in _branches(query):
for item in self._items(select):
body = _derived_body(item)
if body is not None:
out |= self._row_tables(body, seen)
elif isinstance(item, exp.Table) and isinstance(item.this, exp.Identifier) and item.name:
if not self._is_cte(item):
out.add(self._table(item).key)
elif (name := name_key(item.name)) not in seen:
out |= self._row_tables(self.ctes[name], seen | {name})
return out
@classmethod
def reparse(cls, f: Facets) -> "_Statement | None":
"""按 facets 记下的原文、表结构重新分析一遍(见 Facets._origin);没记、解析不了返回 None。"""
if f._origin is None:
return None
sql, schema = f._origin
root, _ = _parse(sql, f.dialect)
return cls(root, f.dialect, schema) if root is not None else None
@staticmethod @staticmethod
def _table(table: Any) -> _Source: def _table(table: Any) -> _Source:
@@ -721,9 +923,9 @@ class _Statement:
return default if owner is None else self.sources(self.segments[owner.meta[_SEGMENT]]) return default if owner is None else self.sources(self.segments[owner.meta[_SEGMENT]])
def render(self, node: Any, sources: dict[str, _Source], mode: str = "text") -> str: def render(self, node: Any, sources: dict[str, _Source], mode: str = "text") -> str:
"""五种写法(见 Part):key、anon、loose 比较用,名字按 name_key,key、anon 写真实表名、loose 不写(CTE、子查询 """五种写法:key、loose(见 Part)、anon 比较用,名字按 name_key;key、anon 写实表的表名,loose 不写;CTE、
只有 key 写编号);text、full 给人看,text 只在好几张表都有这一列时写真实表名,full 一律写(关联条件说的就是 子查询 key 和 loose 写编号,anon 一律写「@」(算结构标识用,和编号无关,见 _Derived)。text、full 给人看
两张表各出哪一列,用 full)。 (见 Part),text 只在好几张表都有这一列时写真实表名,full 一律写(关联条件说的就是两张表各出哪一列,用 full)。
node 里套着的子查询逐段改写:每一列按它所在那一段的来源认,子查询里的表去掉别名(列已经写成了真实表名)。 node 里套着的子查询逐段改写:每一列按它所在那一段的来源认,子查询里的表去掉别名(列已经写成了真实表名)。
子查询里拿两张表的列互相比的(相关子查询),给人看时和关联条件一样写表名。比较用的写法里各自起的名字也去掉 子查询里拿两张表的列互相比的(相关子查询),给人看时和关联条件一样写表名。比较用的写法里各自起的名字也去掉
@@ -754,7 +956,7 @@ class _Statement:
if isinstance(ident, exp.Identifier): if isinstance(ident, exp.Identifier):
table.set(arg, exp.to_identifier(name_key(ident.name))) table.set(arg, exp.to_identifier(name_key(ident.name)))
if mode in ("key", "loose", "anon"): if mode in ("key", "loose", "anon"):
self._drop_names(copy, numbered=mode == "key") self._drop_names(copy, numbered=mode != "anon")
return copy.sql(dialect=self.read, comments=False) return copy.sql(dialect=self.read, comments=False)
@staticmethod @staticmethod
@@ -762,7 +964,8 @@ class _Statement:
"""比较用的写法里抹掉套着的子查询里各自起的名字(就地改):引用 CTE 的表、FROM 里的子查询(含 LATERAL)、 """比较用的写法里抹掉套着的子查询里各自起的名字(就地改):引用 CTE 的表、FROM 里的子查询(含 LATERAL)、
子查询里自带的 CTE 定义写编号(numbered 为假时写「@」;引用它们的列已经照这样写了);只看取值的子查询(IN、 子查询里自带的 CTE 定义写编号(numbered 为假时写「@」;引用它们的列已经照这样写了);只看取值的子查询(IN、
EXISTS、标量子查询)去掉选出来的列名。FROM 里的子查询、CTE 定义选出来的列名留着:外层按这个名字引用它们, EXISTS、标量子查询)去掉选出来的列名。FROM 里的子查询、CTE 定义选出来的列名留着:外层按这个名字引用它们,
去掉了就分不出外层用的是哪一列。""" 去掉了就分不出外层用的是哪一列。复制出来的那段本身没有父节点,认不出是 FROM 里的子查询,它选出来的列名也会
去掉(_Derived.body 另记了各列,见 derived)。"""
from sqlglot import exp from sqlglot import exp
for node in copy.find_all(exp.Table, exp.Subquery, exp.Lateral, exp.CTE): for node in copy.find_all(exp.Table, exp.Subquery, exp.Lateral, exp.CTE):
@@ -788,8 +991,12 @@ class _Statement:
quoted = isinstance(col.this, exp.Identifier) and bool(col.this.args.get("quoted")) quoted = isinstance(col.this, exp.Identifier) and bool(col.this.args.get("quoted"))
if mode in ("key", "loose", "anon"): if mode in ("key", "loose", "anon"):
owner = None if source is None or mode == "loose" else \ if source is None:
_ANON if mode == "anon" and source.kind == "derived" else source.key owner = None
elif source.kind == "derived":
owner = _ANON if mode == "anon" else source.key
else:
owner = None if mode == "loose" else source.key
col.set("this", exp.to_identifier(name_key(name))) col.set("this", exp.to_identifier(name_key(name)))
col.set("table", exp.to_identifier(owner) if owner else None) col.set("table", exp.to_identifier(owner) if owner else None)
col.set("db", None) col.set("db", None)
@@ -828,23 +1035,23 @@ class _Statement:
return node.transform(fix) return node.transform(fix)
def _rank(self, node: Any, sources: dict[str, _Source]) -> tuple[str, str]: def _rank(self, node: Any, sources: dict[str, _Source]) -> tuple[str, str]:
"""比较的两边谁排前面:先按不写编号的写法,分不出(同名的列)再按编号。只按编号排的话,编号错开的两条语句里 """比较的两边谁排前面:先按不写编号的写法(实表的列、派生来源的列分得开),分不出(两个派生来源的同名列)
同一个比较会排成两个方向,按不写编号的写法也配不上了。""" 再按编号。diff_facets 重新分析时用的是两边按结构配好对的共同编号(见 relabel):p.total > r.total 和换了
关联先后、位置编号对调的同一句排出来是同一个方向,配对之前的位置编号决定不了先后。"""
return self.render(node, sources, "anon"), self.render(node, sources, "key") return self.render(node, sources, "anon"), self.render(node, sources, "key")
def part(self, node: Any, sources: dict[str, _Source], *, excludes: bool = False) -> Part: def part(self, node: Any, sources: dict[str, _Source], *, excludes: bool = False) -> Part:
refs: dict[tuple[str | None, str], str] = {} refs: dict[tuple[str | None, str], str] = {}
for col in _own_columns(node): for col in _own_columns(node):
source, name = self.resolve(col, sources) source, name = self.resolve(col, sources)
owner = "" if source is None else _ANON if source.kind == "derived" else source.key refs.setdefault((source.name if source is not None else None, name),
refs.setdefault((source.name if source is not None else None, name), f"{owner}.{name_key(name)}") f"{source.key if source is not None else ''}.{name_key(name)}")
full = self.render(node, sources, "full") full = self.render(node, sources, "full")
# 拿两张表的列互相比的(相关子查询里的 o.id = r.order_id)和关联条件一样,给人看也写表名 # 拿两张表的列互相比的(相关子查询里的 o.id = r.order_id)和关联条件一样,给人看也写表名
across = len({table for table, _ in refs}) > 1 across = len({table for table, _ in refs}) > 1
return Part(key=self.render(node, sources, "key"), text=full if across else self.render(node, sources), return Part(key=self.render(node, sources, "key"), text=full if across else self.render(node, sources),
columns=tuple(refs), excludes=self._excludes(node, sources) if excludes else None, columns=tuple(refs), excludes=self._excludes(node, sources) if excludes else None,
loose=self.render(node, sources, "loose"), full=full, column_keys=tuple(refs.values()), loose=self.render(node, sources, "loose"), full=full, column_keys=tuple(refs.values()))
anon=self.render(node, sources, "anon"))
def _excludes(self, node: Any, sources: dict[str, _Source]) -> str | None: def _excludes(self, node: Any, sources: dict[str, _Source]) -> str | None:
"""排除写法去掉否定之后排除的是什么:a <> 1 → a = 1;NOT IN、NOT LIKE、NOT BETWEEN 同理;IS NOT NULL → 「a 为空」。 """排除写法去掉否定之后排除的是什么:a <> 1 → a = 1;NOT IN、NOT LIKE、NOT BETWEEN 同理;IS NOT NULL → 「a 为空」。
@@ -877,8 +1084,11 @@ class _Statement:
group: list[Part] = [] group: list[Part] = []
aggregates: list[Aggregate] = [] aggregates: list[Aggregate] = []
having: list[Part] = [] having: list[Part] = []
used: list[Part] = []
for select in self.root.find_all(exp.Select, bfs=False): for select in self.root.find_all(exp.Select, bfs=False):
sources = self.sources(select) sources = self.sources(select)
if select is self.root or not self._in_condition(select):
used += [Part.plain(s.key, s.name) for s in sources.values()]
drafts = self._join_drafts(select, sources) drafts = self._join_drafts(select, sources)
clause = select.args.get("where") clause = select.args.get("where")
for cond in _conjuncts(clause.this) if clause is not None else []: for cond in _conjuncts(clause.this) if clause is not None else []:
@@ -903,6 +1113,7 @@ class _Statement:
having=_dedupe(having, lambda p: p.key), having=_dedupe(having, lambda p: p.key),
limit=self._limit(), limit=self._limit(),
limits=tuple(self._limits()), limits=tuple(self._limits()),
sources=_dedupe(used, lambda p: p.key),
) )
def _join_drafts(self, select: Any, sources: dict[str, _Source]) -> list[dict[str, Any]]: def _join_drafts(self, select: Any, sources: dict[str, _Source]) -> list[dict[str, Any]]:
@@ -922,8 +1133,9 @@ class _Statement:
conds = [self.canonical(exp.EQ(this=exp.column(ident.name, table=first_alias), conds = [self.canonical(exp.EQ(this=exp.column(ident.name, table=first_alias),
expression=exp.column(ident.name, table=alias)), sources) expression=exp.column(ident.name, table=alias)), sources)
for ident in join.args["using"]] for ident in join.args["using"]]
before = [s for key, s in list(sources.items())[:i + 1] if key != slot]
drafts.append({"kind": _join_kind(join), "slot": slot, "source": sources.get(slot), "conds": conds, drafts.append({"kind": _join_kind(join), "slot": slot, "source": sources.get(slot), "conds": conds,
"before": [s.name for key, s in list(sources.items())[:i + 1] if key != slot]}) "before": [s.name for s in before], "before_keys": [s.key for s in before]})
return drafts return drafts
def _joins_on_where(self, cond: Any, sources: dict[str, _Source], drafts: list[dict[str, Any]]) -> bool: def _joins_on_where(self, cond: Any, sources: dict[str, _Source], drafts: list[dict[str, Any]]) -> bool:
@@ -950,17 +1162,25 @@ class _Statement:
def _join(self, draft: dict[str, Any], sources: dict[str, _Source]) -> JoinFacet: def _join(self, draft: dict[str, Any], sources: dict[str, _Source]) -> JoinFacet:
source = draft["source"] source = draft["source"]
table = Part.plain("?", "?") if source is None else \ table = Part.plain("?", "?") if source is None else \
Part(key=source.key, text=source.name, loose=source.key, full=source.name, Part(key=source.key, text=source.name, loose=source.key, full=source.name)
anon=_ANON if source.kind == "derived" else source.key)
forms = sorted((self.render(c, sources, "key"), self.render(c, sources, "loose"), forms = sorted((self.render(c, sources, "key"), self.render(c, sources, "loose"),
self.render(c, sources, "full"), self.render(c, sources, "anon")) for c in draft["conds"]) self.render(c, sources, "full")) for c in draft["conds"])
on = Part(key=" AND ".join(f[0] for f in forms), text=" AND ".join(f[2] for f in forms), on = Part(key=" AND ".join(f[0] for f in forms), text=" AND ".join(f[2] for f in forms),
loose=" AND ".join(sorted(f[1] for f in forms)), full=" AND ".join(f[2] for f in forms), loose=" AND ".join(sorted(f[1] for f in forms)), full=" AND ".join(f[2] for f in forms)) \
anon=" AND ".join(sorted(f[3] for f in forms))) if forms else None if forms else None
mentioned = {table for c in draft["conds"] for table, _ in self.part(c, sources).columns} mentioned = {table for c in draft["conds"] for table, _ in self.part(c, sources).columns}
other = [name for name in dict.fromkeys(t for t in mentioned if t) if name != table.text] if draft["conds"] \ other = [name for name in dict.fromkeys(t for t in mentioned if t) if name != table.text] if draft["conds"] \
else draft["before"] else draft["before"]
return JoinFacet(kind=draft["kind"], table=table, on=on, other=tuple(sorted(other))) # 涉及的来源按条件里直接引用的算(slot 不为 None 的是这一段的来源):透过 CTE 找到的实表两边可能一样,
# 关联的却是两个不同的 CTE
sides = {source.key} if source is not None else set()
if draft["conds"]:
sides |= {found.key for c in draft["conds"] for col in _own_columns(c)
for slot, found in [self.locate(col, sources)] if slot is not None and found is not None}
else:
sides |= set(draft["before_keys"])
return JoinFacet(kind=draft["kind"], table=table, on=on, other=tuple(sorted(other)),
sides=tuple(sorted(sides)))
def _time(self, cond: Any, sources: dict[str, _Source]) -> TimePredicate | None: def _time(self, cond: Any, sources: dict[str, _Source]) -> TimePredicate | None:
"""时间条件:一边恰好一列(可以套着日期函数、CAST),另一边没有列;列名像日期,或者用了日期函数、像日期的字符串。""" """时间条件:一边恰好一列(可以套着日期函数、CAST),另一边没有列;列名像日期,或者用了日期函数、像日期的字符串。"""
@@ -1119,7 +1339,21 @@ class _Statement:
skip = None # OFFSET 0 等于没写 skip = None # OFFSET 0 等于没写
if limit is None and skip is None: if limit is None and skip is None:
return None return None
return LimitFacet(count=count, offset=skip, order=self._order(query), scope=self._scope_name(query)) return LimitFacet(count=count, offset=skip, order=self._order(query), scope=self._scope_name(query),
scope_key=self._scope_key(query))
@staticmethod
def _scope_key(query: Any) -> str | None:
"""query 整段是哪个派生来源(CTE 的定义、FROM 里的子查询、LATERAL 子查询)的编号;最外层、UNION 的某一支、
SELECT 列表里的子查询这类不是派生来源整段的返回 None。编号记在 CTE 定义、FROM / JOIN 项上,往外找一两层。"""
from sqlglot import exp
node = query
while isinstance(node.parent, (exp.Subquery, exp.Lateral, exp.CTE)):
node = node.parent
if found := node.meta.get(_DERIVED):
return found
return None
def _order(self, query: Any) -> tuple[tuple[Part, bool], ...]: def _order(self, query: Any) -> tuple[tuple[Part, bool], ...]:
"""这一段的排序键 (Part, 是否降序)。ORDER BY 1、ORDER BY 别名换成 SELECT 里的表达式(UNION 按第一支的)。""" """这一段的排序键 (Part, 是否降序)。ORDER BY 1、ORDER BY 别名换成 SELECT 里的表达式(UNION 按第一支的)。"""
@@ -1145,7 +1379,9 @@ class _Statement:
if query is self.root: if query is self.root:
return None return None
parent = query.parent parent = query.parent
if isinstance(parent, (exp.CTE, exp.Subquery)) and parent.alias: if isinstance(parent, exp.Subquery) and not parent.alias and isinstance(parent.parent, exp.Lateral):
parent = parent.parent # LATERAL 的别名挂在 LATERAL 上,不在里面的子查询上
if isinstance(parent, (exp.CTE, exp.Subquery, exp.Lateral)) and parent.alias:
return parent.alias return parent.alias
return "子查询" return "子查询"
@@ -1232,11 +1468,12 @@ def _labeler(names: Mapping[str, str] | None) -> Any:
def _single_column(part: Part, *, loose: bool) -> str | None: def _single_column(part: Part, *, loose: bool) -> str | None:
"""只涉及一列的条件:那一列(Part.column_keys:实表.列,CTE、子查询的列写「@.列」,两边起的名字不同、编号错开 """只涉及一列的条件:那一列(Part.column_keys:实表.列,CTE、子查询的列写「编号.列」),用来把两边对同一列的
也配得上;loose 时只看列名),用来把两边对同一列的不同写法配成一对。""" 不同写法配成一对。loose 时实表的列只看列名(表换了一张),派生来源的列照样带编号:两个 CTE 的同名列不是一列。"""
if len(part.columns) != 1 or len(part.column_keys) != 1: if len(part.columns) != 1 or len(part.column_keys) != 1:
return None return None
return name_key(part.columns[0][1]) if loose else part.column_keys[0] key = part.column_keys[0]
return name_key(part.columns[0][1]) if loose and not key.startswith("@") else key
def _verb(g: Aggregate) -> str: def _verb(g: Aggregate) -> str:
@@ -1261,7 +1498,8 @@ class _Compare:
两边各有一张只在自己这边出现、又不是关联进来的表(orders 换成了 orders_archive)时,其余各方面按不写表名的 两边各有一张只在自己这边出现、又不是关联进来的表(orders 换成了 orders_archive)时,其余各方面按不写表名的
loose 比:表换了这件事由一条「tables」说清,同一个条件、同一个聚合不再因为表名不同被重复报一遍。 loose 比:表换了这件事由一条「tables」说清,同一个条件、同一个聚合不再因为表名不同被重复报一遍。
每个方面都先按 k 比,比不上的再按 ak(CTE、子查询不写编号)一对一地配一遍(见 unmatched)。 派生来源的编号由 diff_facets 先按结构配好(两边配成一对的写同一个编号,见模块说明),这里各方面都只按 k 比:
编号不同就是不同的来源,不再拿「不写编号」的写法补配(那样会把 p.total 和 r.total 当成同一列)。
""" """
def __init__(self, a: Facets, b: Facets, labels: tuple[str, str], names: Mapping[str, str] | None) -> None: def __init__(self, a: Facets, b: Facets, labels: tuple[str, str], names: Mapping[str, str] | None) -> None:
@@ -1278,31 +1516,49 @@ class _Compare:
return None return None
return part.loose if self.swapped else part.key return part.loose if self.swapped else part.key
def ak(self, part: Part | None) -> str | None: def side(self, key: str) -> str:
"""同 k,但 CTE、子查询不写编号(见 unmatched)。""" """比较用的来源 key;换了一张表时,换的那两张记成同一个。"""
if part is None: return "?" if self.swapped and key in (self.ta[0].key, self.tb[0].key) else key
return None
return part.loose if self.swapped else (part.anon or part.key) def sides(self, j: JoinFacet) -> tuple[str, ...]:
"""连接涉及的来源(见 JoinFacet.sides)。"""
return tuple(sorted(self.side(s) for s in j.sides))
def added(self, j: JoinFacet, mine: Facets, theirs: Facets) -> tuple[str, set[str]]:
"""一边独有的一次连接,多出来的是哪个来源:(给人看的名字, 比较用的 key)。
一般是关联进来的那一侧;可内连接、CROSS JOIN 关联进来的那一侧另一边也有时,多出来的其实是这次连接接上的
另一头(FROM stores JOIN orders,另一边只查 orders:多出来的是 stores,不是 orders),说成「关联了 orders
表」就错了。这时报涉及的来源里另一边没有的那几个。"""
if j.kind in ("inner", "cross"):
have = {self.side(s.key) for s in theirs.sources}
new = [k for k in j.sides if self.side(k) not in have]
if new and self.side(j.table.key) in have:
names = {s.key: s.text for s in mine.sources}
return "、".join(names.get(k, k) for k in new), set(new)
return j.table.text, {j.table.key}
@staticmethod @staticmethod
def unmatched(xs: Iterable[Any], ys: Iterable[Any], key: Any, anon: Any) -> tuple[list[Any], list[Any]]: def unmatched(xs: Iterable[Any], ys: Iterable[Any], key: Any) -> tuple[list[Any], list[Any]]:
"""两边各自独有的项,各按原来的先后。先按 key 比;剩下的再按 anon(CTE、子查询不写编号)一对一地配,配上的 """两边各自独有的项(按 key 比),各按原来的先后。派生来源在 key 里已经是两边按结构配好的共同编号(见
也不算不同。编号只看位置,一边多出一个排在前面的 CTE、子查询,后面的编号就错开了:同一个条件不能因此被说成 diff_facets),这里不再拿不写编号的写法补配一遍:那样两个 CTE 的同名列(p.total、r.total)会被当成一列,
两边各有一个。编号对得上时 key 就分得清是哪个 CTE 的列,anon 只收拾 key 比不上的;两个 CTE 算出同名的列、 换了用哪一个的列也报不出来。"""
编号又错开时可能配错一对,宁可漏报。"""
xs, ys = list(xs), list(ys) xs, ys = list(xs), list(ys)
kx, ky = {key(x) for x in xs}, {key(y) for y in ys} kx, ky = {key(x) for x in xs}, {key(y) for y in ys}
only_x = [x for x in xs if key(x) not in ky] return [x for x in xs if key(x) not in ky], [y for y in ys if key(y) not in kx]
only_y = [y for y in ys if key(y) not in kx]
paired_x: set[int] = set() @staticmethod
paired_y: set[int] = set() def lookalikes(only_a: list[Part], only_b: list[Part]) -> list[tuple[Part, Part]]:
for i, x in enumerate(only_x): """两边各自独有、给人看的写法却一样的条件配成一对,就地从两边拿掉:同一个条件挂在不同的来源上(p.total > 100
j = next((j for j, y in enumerate(only_y) if j not in paired_y and anon(y) == anon(x)), None) 和 r.total > 100,各自只读得出 total > 100),分开各说一句「只保留 total > 100 的行」读起来自相矛盾。"""
if j is not None: pairs = []
paired_x.add(i) for p in list(only_a):
paired_y.add(j) q = next((q for q in only_b if q.text == p.text), None)
return [x for i, x in enumerate(only_x) if i not in paired_x], \ if q is not None:
[y for j, y in enumerate(only_y) if j not in paired_y] pairs.append((p, q))
only_a.remove(p)
only_b.remove(q)
return pairs
@staticmethod @staticmethod
def shown(xs: list[str], ys: list[str], full_xs: list[str], full_ys: list[str]) -> tuple[str, str]: def shown(xs: list[str], ys: list[str], full_xs: list[str], full_ys: list[str]) -> tuple[str, str]:
@@ -1332,7 +1588,7 @@ class _Compare:
a, b, la, lb = self.a, self.b, self.la, self.lb a, b, la, lb = self.a, self.b, self.la, self.lb
ca = list(_dedupe((t.column for t in a.time), self.k)) ca = list(_dedupe((t.column for t in a.time), self.k))
cb = list(_dedupe((t.column for t in b.time), self.k)) cb = list(_dedupe((t.column for t in b.time), self.k))
if any(self.unmatched(ca, cb, self.k, self.ak)): if any(self.unmatched(ca, cb, self.k)):
wa, wb = self.named(ca), self.named(cb) wa, wb = self.named(ca), self.named(cb)
if wa == wb: if wa == wb:
wa, wb = self.named(ca, full=True), self.named(cb, full=True) wa, wb = self.named(ca, full=True), self.named(cb, full=True)
@@ -1346,7 +1602,7 @@ class _Compare:
for side, parts in (("a", ca), ("b", cb))} for side, parts in (("a", ca), ("b", cb))}
return [FacetDiff("time", "、".join(p.text for p in ca) or None, "、".join(p.text for p in cb) or None, return [FacetDiff("time", "、".join(p.text for p in ca) or None, "、".join(p.text for p in cb) or None,
text, detail)] text, detail)]
if any(self.unmatched(a.time, b.time, lambda t: self.k(t.condition), lambda t: self.ak(t.condition))): if any(self.unmatched(a.time, b.time, lambda t: self.k(t.condition))):
ra = " AND ".join(t.condition.text for t in a.time) ra = " AND ".join(t.condition.text for t in a.time)
rb = " AND ".join(t.condition.text for t in b.time) rb = " AND ".join(t.condition.text for t in b.time)
return [FacetDiff("time_range", ra, rb, _say(la, "的时间范围是", ra, ",", lb, "是", rb))] return [FacetDiff("time_range", ra, rb, _say(la, "的时间范围是", ra, ",", lb, "是", rb))]
@@ -1356,8 +1612,9 @@ class _Compare:
timed = {self.k(t.condition) for t in (*self.a.time, *self.b.time)} timed = {self.k(t.condition) for t in (*self.a.time, *self.b.time)}
fa = [p for p in self.a.where if self.k(p) not in timed] fa = [p for p in self.a.where if self.k(p) not in timed]
fb = [p for p in self.b.where if self.k(p) not in timed] fb = [p for p in self.b.where if self.k(p) not in timed]
only_a, only_b = self.unmatched(fa, fb, self.k, self.ak) only_a, only_b = self.unmatched(fa, fb, self.k)
out: list[FacetDiff] = [] out: list[FacetDiff] = []
# 先配对同一列的不同写法(status = 'paid' 和 status IN (…)),再配读起来一样、挂在不同来源上的同一个条件
for p in list(only_a): for p in list(only_a):
column = _single_column(p, loose=self.swapped) column = _single_column(p, loose=self.swapped)
mine = [q for q in only_a if column and _single_column(q, loose=self.swapped) == column] mine = [q for q in only_a if column and _single_column(q, loose=self.swapped) == column]
@@ -1368,6 +1625,9 @@ class _Compare:
out.append(FacetDiff("filter", p.text, q.text, _say(self.la, "按", wa, "筛选,", self.lb, "按", wb, "筛选"))) out.append(FacetDiff("filter", p.text, q.text, _say(self.la, "按", wa, "筛选,", self.lb, "按", wb, "筛选")))
only_a.remove(p) only_a.remove(p)
only_b.remove(q) only_b.remove(q)
for p, q in self.lookalikes(only_a, only_b):
wa, wb = self.shown([p.text], [q.text], [p.full], [q.full])
out.append(FacetDiff("filter", wa, wb, _say(self.la, "按", wa, "筛选,", self.lb, "按", wb, "筛选")))
out += [FacetDiff("filter", p.text, None, self.one_sided(self.la, self.lb, p)) for p in only_a] out += [FacetDiff("filter", p.text, None, self.one_sided(self.la, self.lb, p)) for p in only_a]
out += [FacetDiff("filter", None, p.text, self.one_sided(self.lb, self.la, p)) for p in only_b] out += [FacetDiff("filter", None, p.text, self.one_sided(self.lb, self.la, p)) for p in only_b]
return out return out
@@ -1378,9 +1638,6 @@ class _Compare:
def key(g: Aggregate) -> tuple[str, str | None, bool]: def key(g: Aggregate) -> tuple[str, str | None, bool]:
return g.func, self.k(g.arg), g.distinct return g.func, self.k(g.arg), g.distinct
def anon(g: Aggregate) -> tuple[str, str | None, bool]:
return g.func, self.ak(g.arg), g.distinct
def pair(xs: list[Aggregate], ys: list[Aggregate], same: Any) -> list[tuple[Aggregate, Aggregate]]: def pair(xs: list[Aggregate], ys: list[Aggregate], same: Any) -> list[tuple[Aggregate, Aggregate]]:
pairs = [] pairs = []
for x in list(xs): for x in list(xs):
@@ -1391,7 +1648,7 @@ class _Compare:
ys.remove(y) ys.remove(y)
return pairs return pairs
ga, gb = self.unmatched(self.a.aggregates, self.b.aggregates, key, anon) ga, gb = self.unmatched(self.a.aggregates, self.b.aggregates, key)
# 先配参数相同的(同一列:函数或去重不同),再配函数相同的(同一种算法:列不同);各剩一个的也配成一对 # 先配参数相同的(同一列:函数或去重不同),再配函数相同的(同一种算法:列不同);各剩一个的也配成一对
pairs = pair(ga, gb, lambda x, y: self.k(x.arg) == self.k(y.arg)) pairs = pair(ga, gb, lambda x, y: self.k(x.arg) == self.k(y.arg))
pairs += pair(ga, gb, lambda x, y: x.func == y.func) pairs += pair(ga, gb, lambda x, y: x.func == y.func)
@@ -1419,15 +1676,26 @@ class _Compare:
return out return out
def joins(self) -> list[FacetDiff]: def joins(self) -> list[FacetDiff]:
"""连接:先把完全一样的(类型、关联的表、条件)两边配掉,剩下的按关联的是哪张表配成一对、说类型或条件哪里 """连接:先把完全一样的两边配掉,剩下的按关联的是哪张表(派生来源按配好的编号)、再按涉及的来源配成一对,
不同,再剩下的是只有一边关联了。直接按表配的话,编号错开时会把两个不相干的 CTE 配成一对,说成「关联条件 说类型或条件哪里不同,再剩下的是只有一边关联了(多出来的是哪个来源见 added)。完全一样:内连接、CROSS JOIN
不同」;先配完全一样的,同一张表关联了两次、先后不同也不算不同。""" 比类型、涉及的来源和条件(FROM paid JOIN refunded 和 FROM refunded JOIN paid 是同一次连接),别的连接比类型、
关联进来的表和条件。先配完全一样的,同一张表关联了两次、先后不同也不算不同。配成一对的两边名字不同(两边给
CTE 起的名字不同、内连接换了先后)时两个名字都说。派生来源已经按结构配好了编号:一边多出来的 CTE 编号和
另一边的都不一样,不会被配给另一边不相干的 CTE、说成「关联条件不同」。
做不到的:LEFT JOIN 和两边对调的 RIGHT JOIN 意思一样,仍报成类型不同;内连接里只涉及一张表的条件写在 ON 里
还是 WHERE 里意思一样,仍算两处不同;三张以上不带关联条件的表(逗号连接、CROSS JOIN)换了先后,涉及的来源
按「它之前的各个来源」算,仍可能算不同。"""
la, lb = self.la, self.lb la, lb = self.la, self.lb
rest_a, rest_b = self.unmatched(self.a.joins, self.b.joins,
lambda j: (j.kind, j.table.key, self.k(j.on)), def same(j: JoinFacet) -> tuple[Any, ...]:
lambda j: (j.kind, j.table.anon, self.ak(j.on))) if j.kind in ("inner", "cross"):
return j.kind, self.sides(j), self.k(j.on)
return j.kind, j.table.key, self.k(j.on)
rest_a, rest_b = self.unmatched(self.a.joins, self.b.joins, same)
pairs: list[tuple[JoinFacet, JoinFacet]] = [] pairs: list[tuple[JoinFacet, JoinFacet]] = []
for table in (lambda j: j.table.key, lambda j: j.table.anon): for table in (lambda j: j.table.key, self.sides):
for x in list(rest_a): for x in list(rest_a):
y = next((y for y in rest_b if table(y) == table(x)), None) y = next((y for y in rest_b if table(y) == table(x)), None)
if y is not None: if y is not None:
@@ -1436,26 +1704,37 @@ class _Compare:
rest_b.remove(y) rest_b.remove(y)
out: list[FacetDiff] = [] out: list[FacetDiff] = []
for x, y in pairs: for x, y in pairs:
tx, ty = x.table.text, y.table.text
if x.kind != y.kind: if x.kind != y.kind:
kx, ky = _JOIN_WORDS.get(x.kind, x.kind), _JOIN_WORDS.get(y.kind, y.kind) kx, ky = _JOIN_WORDS.get(x.kind, x.kind), _JOIN_WORDS.get(y.kind, y.kind)
out.append(FacetDiff("join", kx, ky, _say(la, "用", kx, "关联", x.table.text, "表,", lb, "用", ky))) tail = () if ty == tx else ("关联", ty, "表")
if self.k(x.on) != self.k(y.on) and self.ak(x.on) != self.ak(y.on): out.append(FacetDiff("join", kx, ky, _say(la, "用", kx, "关联", tx, "表,", lb, "用", ky, *tail)))
if self.k(x.on) != self.k(y.on):
xa, ya = (x.on.text if x.on else "(无条件)"), (y.on.text if y.on else "(无条件)") xa, ya = (x.on.text if x.on else "(无条件)"), (y.on.text if y.on else "(无条件)")
out.append(FacetDiff("join", xa, ya, _say(la, "关联", x.table.text, "表的条件是", xa, ",", lb, "是", ya))) tail = ("是", ya) if ty == tx else ("关联", ty, "表的条件是", ya)
out += [FacetDiff("join", x.table.text, None, _say(la, "关联了", x.table.text, "表,", lb, "没有")) for x in rest_a] out.append(FacetDiff("join", xa, ya, _say(la, "关联", tx, "表的条件是", xa, ",", lb, *tail)))
out += [FacetDiff("join", None, y.table.text, _say(lb, "关联了", y.table.text, "表,", la, "没有")) for y in rest_b] said: dict[str, set[str]] = {"a": set(), "b": set()}
# 关联之外的表(FROM 的表、子查询里的表)只在一边出现的 for side, rest, mine, theirs in (("a", rest_a, self.a, self.b), ("b", rest_b, self.b, self.a)):
me, other = (la, lb) if side == "a" else (lb, la)
for j in rest:
name, keys = self.added(j, mine, theirs)
said[side] |= keys
out.append(FacetDiff("join", name if side == "a" else None, name if side == "b" else None,
_say(me, "关联了", name, "表,", other, "没有")))
# 关联之外的表(FROM 的表、子查询里的表)只在一边出现的;上面已经说过「关联了它」的不再说一遍
if self.swapped: if self.swapped:
ta, tb = self.ta[0], self.tb[0] ta, tb = self.ta[0], self.tb[0]
return out + [FacetDiff("tables", ta.text, tb.text, _say(la, "查的是", ta.text, "表,", lb, "查的是", tb.text, return out + [FacetDiff("tables", ta.text, tb.text, _say(la, "查的是", ta.text, "表,", lb, "查的是", tb.text,
"表"))] "表"))]
out += [FacetDiff("tables", t.text, None, _say(la, "用到了", t.text, "表,", lb, "没有")) for t in self.ta] out += [FacetDiff("tables", t.text, None, _say(la, "用到了", t.text, "表,", lb, "没有"))
out += [FacetDiff("tables", None, t.text, _say(lb, "用到了", t.text, "表,", la, "没有")) for t in self.tb] for t in self.ta if t.key not in said["a"]]
out += [FacetDiff("tables", None, t.text, _say(lb, "用到了", t.text, "表,", la, "没有"))
for t in self.tb if t.key not in said["b"]]
return out return out
def group(self) -> list[FacetDiff]: def group(self) -> list[FacetDiff]:
a, b = self.a.group_by, self.b.group_by a, b = self.a.group_by, self.b.group_by
if not any(self.unmatched(a, b, self.k, self.ak)): if not any(self.unmatched(a, b, self.k)):
return [] return []
wa, wb = self.shown([p.text for p in a], [p.text for p in b], [p.full for p in a], [p.full for p in b]) wa, wb = self.shown([p.text for p in a], [p.text for p in b], [p.full for p in a], [p.full for p in b])
if wa and wb: if wa and wb:
@@ -1467,30 +1746,45 @@ class _Compare:
return [FacetDiff("group_by", wa or None, wb or None, text)] return [FacetDiff("group_by", wa or None, wb or None, text)]
def having(self) -> list[FacetDiff]: def having(self) -> list[FacetDiff]:
only_a, only_b = self.unmatched(self.a.having, self.b.having, self.k, self.ak) only_a, only_b = self.unmatched(self.a.having, self.b.having, self.k)
return [FacetDiff("having", p.text, None, self.one_sided(self.la, self.lb, p, group=True)) for p in only_a] + \ out = []
for p, q in self.lookalikes(only_a, only_b):
wa, wb = self.shown([p.text], [q.text], [p.full], [q.full])
out.append(FacetDiff("having", wa, wb, _say(self.la, "分组后只保留", wa, "的组,", self.lb, "只保留", wb,
"的组")))
return out + \
[FacetDiff("having", p.text, None, self.one_sided(self.la, self.lb, p, group=True)) for p in only_a] + \
[FacetDiff("having", None, p.text, self.one_sided(self.lb, self.la, p, group=True)) for p in only_b] [FacetDiff("having", None, p.text, self.one_sided(self.lb, self.la, p, group=True)) for p in only_b]
def limit(self) -> list[FacetDiff]: def limit(self) -> list[FacetDiff]:
"""取哪几行:最外层两边比;CTE、子查询里的按出现的先后一一配对(两边给它起的名字可以不同),整组一样就不报。""" """取哪几行:最外层两边比;CTE、子查询里的按所属派生来源配好的编号(LimitFacet.scope_key)配成一对,两边给
它起的名字可以不同。配不上的派生来源各说各的,不按先后凑对:一边多一个限了行数的 CTE,不能拿它和另一边的
CTE 比。不属于哪个派生来源整段的(UNION 的某一支这类)按出现的先后配。"""
def outer(f: Facets) -> LimitFacet | None: def outer(f: Facets) -> LimitFacet | None:
return next((x for x in f.limits if x.scope is None), None) return next((x for x in f.limits if x.scope is None), None)
out = self.rows(outer(self.a), outer(self.b)) out = self.rows(outer(self.a), outer(self.b))
ia = [x for x in self.a.limits if x.scope is not None] ia = [x for x in self.a.limits if x.scope is not None]
ib = [x for x in self.b.limits if x.scope is not None] ib = [x for x in self.b.limits if x.scope is not None]
if [self.lk(x) for x in ia] != [self.lk(x) for x in ib] and \ pairs: list[tuple[LimitFacet | None, LimitFacet | None]] = []
[self.lk(x, anon=True) for x in ia] != [self.lk(x, anon=True) for x in ib]: for x in list(ia):
for x, y in zip_longest(ia, ib): y = next((y for y in ib if x.scope_key is not None and y.scope_key == x.scope_key), None)
if y is not None:
pairs.append((x, y))
ia.remove(x)
ib.remove(y)
pairs += zip_longest([x for x in ia if x.scope_key is None], [y for y in ib if y.scope_key is None])
pairs += [(x, None) for x in ia if x.scope_key is not None]
pairs += [(None, y) for y in ib if y.scope_key is not None]
for x, y in pairs:
out += self.rows(x, y) out += self.rows(x, y)
return out return out
def lk(self, x: LimitFacet | None, *, anon: bool = False) -> tuple[Any, ...] | None: def lk(self, x: LimitFacet | None) -> tuple[Any, ...] | None:
"""一段取哪几行的比较用写法:行数、跳过的行数、排序键。anon 时排序键按 ak(见 unmatched)。""" """一段取哪几行的比较用写法:行数、跳过的行数、排序键。"""
if x is None: if x is None:
return None return None
key = self.ak if anon else self.k return x.count, x.offset, tuple((self.k(p), desc) for p, desc in x.order)
return x.count, x.offset, tuple((key(p), desc) for p, desc in x.order)
def sorting(self, x: LimitFacet, *, full: bool = False) -> str: def sorting(self, x: LimitFacet, *, full: bool = False) -> str:
if not x.order: if not x.order:
@@ -1500,7 +1794,7 @@ class _Compare:
def rows(self, x: LimitFacet | None, y: LimitFacet | None) -> list[FacetDiff]: def rows(self, x: LimitFacet | None, y: LimitFacet | None) -> list[FacetDiff]:
"""一对段(一边可以没有)取的行有什么不同:行数、跳过的行数,两边都限了行时再比排序。""" """一对段(一边可以没有)取的行有什么不同:行数、跳过的行数,两边都限了行时再比排序。"""
if self.lk(x) == self.lk(y) or self.lk(x, anon=True) == self.lk(y, anon=True): if self.lk(x) == self.lk(y):
return [] return []
sa, sb = (x.scope if x else None), (y.scope if y else None) sa, sb = (x.scope if x else None), (y.scope if y else None)
la = _say(self.la, "在", sa, "里") if sa else self.la la = _say(self.la, "在", sa, "里") if sa else self.la
@@ -1512,8 +1806,7 @@ class _Compare:
oa, ob = (x.offset if x else None), (y.offset if y else None) oa, ob = (x.offset if x else None), (y.offset if y else None)
if oa != ob: if oa != ob:
out.append(FacetDiff("limit", oa, ob, _say(la, _skip(oa), ",", lb, _skip(ob)))) out.append(FacetDiff("limit", oa, ob, _say(la, _skip(oa), ",", lb, _skip(ob))))
if x is not None and y is not None and self.lk(x)[2] != self.lk(y)[2] \ if x is not None and y is not None and self.lk(x)[2] != self.lk(y)[2]:
and self.lk(x, anon=True)[2] != self.lk(y, anon=True)[2]:
wa, wb = self.sorting(x), self.sorting(y) wa, wb = self.sorting(x), self.sorting(y)
if wa == wb: if wa == wb:
wa, wb = self.sorting(x, full=True), self.sorting(y, full=True) wa, wb = self.sorting(x, full=True), self.sorting(y, full=True)
@@ -1529,6 +1822,8 @@ def diff_facets(a: Facets, b: Facets, labels: tuple[str, str] = ("A", "B"), *,
labels 是两边的叫法(缺省 A / B,双盲复算可以传「原流程」「盲算」);names 是列的中文名({「表.列」或「列」: labels 是两边的叫法(缺省 A / B,双盲复算可以传「原流程」「盲算」);names 是列的中文名({「表.列」或「列」:
中文名},可以用 catalog_labels 从数据目录取),给了的话时间列写成「支付时间 paid_at」。任一边解析不了就只返回 中文名},可以用 catalog_labels 从数据目录取),给了的话时间列写成「支付时间 paid_at」。任一边解析不了就只返回
一条 parse,不逐项比较:拿半份结构去比,只会报出一堆不存在的不同。 一条 parse,不逐项比较:拿半份结构去比,只会报出一堆不存在的不同。
比之前先把两边的派生来源按结构配好对,按共同的编号重新分析 b(见模块说明、_aligned);给人看的写法不受影响。
""" """
la, lb = labels la, lb = labels
if a.parse_error or b.parse_error: if a.parse_error or b.parse_error:
@@ -1538,9 +1833,30 @@ def diff_facets(a: Facets, b: Facets, labels: tuple[str, str] = ("A", "B"), *,
side, error = (la, a.parse_error) if a.parse_error else (lb, b.parse_error) side, error = (la, a.parse_error) if a.parse_error else (lb, b.parse_error)
text = _say(side, ":", error or "", ",没有逐项比较") text = _say(side, ":", error or "", ",没有逐项比较")
return [FacetDiff("parse", a.parse_error, b.parse_error, text)] return [FacetDiff("parse", a.parse_error, b.parse_error, text)]
cmp = _Compare(a, b, labels, names) cmp = _Compare(a, _aligned(a, b), labels, names)
return cmp.time() + cmp.filters() + cmp.aggregates() + cmp.joins() + cmp.group() + cmp.having() + cmp.limit() return cmp.time() + cmp.filters() + cmp.aggregates() + cmp.joins() + cmp.group() + cmp.having() + cmp.limit()
def _aligned(a: Facets, b: Facets) -> Facets:
"""b 的派生来源按结构配给 a 的(见 _align),配上的改写成 a 里对应的编号、配不上的另起 a 里没有的编号,按这套
编号重新分析 b 的原文。a 的编号就是它的位置编号,不用重算。
任一边不是 facets 算出来的(没记原文)、重新解析不了,或者分析时出了意外,照原样返回 b:两边仍按各自的位置
编号比,一边多出排在前面的 CTE 时会多报。
"""
try:
sa, sb = _Statement.reparse(a), _Statement.reparse(b)
if sa is None or sb is None:
return b
da, db = sa.derived(), sb.derived()
pairs = _align(da, db)
fresh = iter(range(max((d.number for d in da), default=0) + 1, 1 << 30))
sb.relabel({d.number: f"@{pairs[d.number]}" if d.number in pairs else f"@{next(fresh)}" for d in db})
return replace(sb.facets(), _origin=b._origin)
except Exception: # noqa: BLE001 - 配对本身的缺陷不能连累比较:退回按各自的编号比,记下来修
logger.warning("SQL 派生来源配对失败,按各自的位置编号比较", exc_info=True)
return b
__all__ = ["MAX_SQL_CHARS", "PLACEHOLDER", "Aggregate", "ColumnRef", "FacetDiff", "Facets", "JoinFacet", "LimitFacet", __all__ = ["MAX_SQL_CHARS", "PLACEHOLDER", "Aggregate", "ColumnRef", "FacetDiff", "Facets", "JoinFacet", "LimitFacet",
"NormalizedSql", "Part", "TimePredicate", "catalog_labels", "diff_facets", "facets", "normalize_sql"] "NormalizedSql", "Part", "TimePredicate", "catalog_labels", "diff_facets", "facets", "normalize_sql"]
+220 -13
View File
@@ -4,11 +4,12 @@
不同,不能算模板变了);关键字大小写、空白、注释也不算不同;解析不了的退回逐词规范化,照样认得出只改了字面量 不同,不能算模板变了);关键字大小写、空白、注释也不算不同;解析不了的退回逐词规范化,照样认得出只改了字面量
- facets:表、连接(类型 / 关联进来的表 / ON 条件)、WHERE 合取式、时间条件(列 / 运算符 / 取值)、分组、聚合 - facets:表、连接(类型 / 关联进来的表 / ON 条件)、WHERE 合取式、时间条件(列 / 运算符 / 取值)、分组、聚合
(函数 / 参数 / 去重)、HAVING、取哪几行(各段的 LIMIT / OFFSET / 排序)。别名换成真实表名(条件里套着的子查询、 (函数 / 参数 / 去重)、HAVING、取哪几行(各段的 LIMIT / OFFSET / 排序)。别名换成真实表名(条件里套着的子查询、
HAVING 里的别名也是),CTE、子查询按位置编号、不按起的名字比,条件里的子查询选出来的列名不算;比较的两边排好 HAVING 里的别名也是),CTE、子查询不按起的名字比(单看一条按位置编号),条件里的子查询选出来的列名不算;比较的
顺序;解析不了带 parse_error,不抛 两边排好顺序;解析不了带 parse_error,不抛
- diff_facets:每处不同一句中文:「A 按支付时间 paid_at 筛时间,B 按下单时间 created_at」「A 排除了 - diff_facets:每处不同一句中文:「A 按支付时间 paid_at 筛时间,B 按下单时间 created_at」「A 排除了
is_deleted = 1 的行,B 没有」「A 对 order_id 去重计数,B 没去重」……写法不同、口径相同的不报,一边多出一个排在 is_deleted = 1 的行,B 没有」「A 对 order_id 去重计数,B 没去重」……写法不同、口径相同的不报。两边的派生来源先按
前面的 CTE、编号错开了也不报 结构(读的表、各列的定义、名字)配好对再比:一边多出一个排在前面的 CTE、内连接换了先后不报,两个 CTE 算出同名
的列时外层用的是哪一个照报
夹具全是虚构的表:订单 orders、退款 refunds、门店 stores、用户 users、付款 payments,以及一张中文表「订单」。 夹具全是虚构的表:订单 orders、退款 refunds、门店 stores、用户 users、付款 payments,以及一张中文表「订单」。
""" """
@@ -273,6 +274,7 @@ def test_facets_are_json_ready():
f = facets("SELECT COUNT(DISTINCT order_id) FROM orders WHERE paid_at >= '2026-09-01' GROUP BY region") 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)) data = json.loads(json.dumps(f.as_dict(), ensure_ascii=False))
assert data["aggregates"][0]["distinct"] is True and data["time"][0]["op"] == ">=" assert data["aggregates"][0]["distinct"] is True and data["time"][0]["op"] == ">="
assert not [k for k in data if k.startswith("_")], "拿来重新分析的原文不进 as_dict"
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -552,8 +554,9 @@ SHIFTED_B = ("WITH vip AS (SELECT id FROM users WHERE vip = 1), "
def test_a_shifted_derived_number_does_not_split_one_condition_in_two(): def test_a_shifted_derived_number_does_not_split_one_condition_in_two():
"""编号只看位置:一边多出一个排在前面的 CTE(筛 VIP 用户),后面那个 CTE 的编号就错开了。按编号比不上的再按 """位置编号只看先后:一边多出一个排在前面的 CTE(筛 VIP 用户),后面那个 CTE 的位置编号就错开了。两边的派生
不写编号的写法一对一地配,同一个条件不会被说成两边各有一个;真正的不同(VIP 用 IN 筛还是关联进来)照报。""" 来源先按结构配好对(t 配 t,vip 配不上),按共同的编号比,同一个条件不会被说成两边各有一个;真正的不同(VIP
用 IN 筛还是关联进来)照报。"""
found = diff_facets(facets(SHIFTED_A), facets(SHIFTED_B), labels=("原流程", "盲算")) found = diff_facets(facets(SHIFTED_A), facets(SHIFTED_B), labels=("原流程", "盲算"))
filters = [d.text for d in found if d.aspect == "filter"] filters = [d.text for d in found if d.aspect == "filter"]
assert filters == ["原流程只保留 user_id IN (SELECT id FROM users WHERE vip = 1) 的行,盲算没有这个条件"] assert filters == ["原流程只保留 user_id IN (SELECT id FROM users WHERE vip = 1) 的行,盲算没有这个条件"]
@@ -605,7 +608,8 @@ def test_a_shifted_derived_number_is_no_difference_in_any_aspect(aspect, a, b):
def test_a_shifted_number_inside_a_condition_subquery_is_no_difference(): def test_a_shifted_number_inside_a_condition_subquery_is_no_difference():
"""条件里的子查询用到的 CTE 编号错开了:子查询整个写进外层条件的那一份,按不写编号的写法也写「@」,照样配得上。""" """条件里的子查询用到的 CTE 位置编号错开了:两边的 t 按结构配成一对、写同一个编号,子查询整个写进外层条件的
那一份照样对得上。"""
a = ("WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) " 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)") "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), " b = ("WITH vip AS (SELECT id FROM users WHERE vip = 1), "
@@ -615,8 +619,8 @@ def test_a_shifted_number_inside_a_condition_subquery_is_no_difference():
def test_a_shifted_derived_join_is_paired_with_its_counterpart(): def test_a_shifted_derived_join_is_paired_with_its_counterpart():
"""关联进来的 CTE 编号错开了(盲算多关联了一个排在前面的 CTE):先把完全一样的关联配掉,再按关联的是哪张表配, """关联进来的 CTE 位置编号错开了(盲算多关联了一个排在前面的 CTE):两边的派生来源先按结构配好对,原流程的 t
不会把原流程的 t 和盲算的 vip 当成同一处关联、说成「关联条件不同」。""" 配盲算的 t,不会和盲算的 vip 当成同一处关联、说成「关联条件不同」。"""
a = ("WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) " 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") "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), " b = ("WITH vip AS (SELECT id FROM users WHERE vip = 1), "
@@ -627,8 +631,8 @@ def test_a_shifted_derived_join_is_paired_with_its_counterpart():
def test_swapped_join_order_of_two_derived_tables_is_no_difference(): def test_swapped_join_order_of_two_derived_tables_is_no_difference():
"""两个 CTE 关联的先后对调,编号也跟着对调:拿它们的列互相比的条件,两边先按不写编号的写法排好两边的先后, """两个 CTE 关联的先后对调,位置编号也跟着对调:两边按结构配好对、写同一个编号之后,拿它们的列互相比的条件
才配得上(只按编号排,同一个比较会排成两个方向)。""" 排成同一个方向(只按位置编号排,同一个比较会排成两个方向),关联按涉及的两边比,不按哪一侧是关联进来的。"""
ctes = ("WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id), " 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) ") "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", assert texts(ctes + "SELECT COUNT(*) FROM t JOIN u ON u.user_id = t.user_id WHERE t.total > u.cnt",
@@ -636,8 +640,8 @@ def test_swapped_join_order_of_two_derived_tables_is_no_difference():
def test_a_reversed_comparison_between_two_derived_tables_is_still_reported(): def test_a_reversed_comparison_between_two_derived_tables_is_still_reported():
"""两个 CTE 算出了同名的列:编号对得上时照样分得清是哪个 CTE 的,比较的方向反了要报出来(不写编号的写法只在 """两个 CTE 算出了同名的列:按结构配好对之后照样分得清是哪个 CTE 的,比较的方向反了要报出来(只按配好的编号
按编号比不上时才拿来配,不会把它吞掉)。""" 比,不拿不写编号的写法补配,不会把它吞掉)。"""
a = ("WITH paid AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id), " 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) " "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") "SELECT COUNT(*) FROM paid p JOIN refunded r ON r.user_id = p.user_id WHERE p.total > r.total")
@@ -660,6 +664,209 @@ def test_a_real_difference_next_to_renamed_derived_tables_is_reported_once():
assert texts(a, outer, labels=("原流程", "盲算")) == ["原流程按 total > 100 筛选,盲算按 total > 200 筛选"] assert texts(a, outer, labels=("原流程", "盲算")) == ["原流程按 total > 100 筛选,盲算按 total > 200 筛选"]
# --------------------------------------------------------------------------
# diff_facets:两边的派生来源按结构配对
# --------------------------------------------------------------------------
#: 两个 CTE 算出了同名的列 total:一个是付款合计,一个是退款合计
PAID_REFUNDED = ("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) ")
PR_JOIN = "FROM paid p JOIN refunded r ON r.user_id = p.user_id"
#: 同样两段写成 FROM 里的子查询
PR_SUBQUERIES = ("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")
@pytest.mark.parametrize("form", ["cte", "subquery"])
@pytest.mark.parametrize("a,b,expected", [
("SELECT SUM(p.total) {j}", "SELECT SUM(r.total) {j}",
["原流程对 paid.total 求和,盲算对 refunded.total 求和"]),
("SELECT SUM(p.total - r.total) {j}", "SELECT SUM(r.total - p.total) {j}",
["原流程对 paid.total - refunded.total 求和,盲算对 refunded.total - paid.total 求和"]),
("SELECT COUNT(*) {j} WHERE p.total > 100", "SELECT COUNT(*) {j} WHERE r.total > 100",
["原流程按 paid.total > 100 筛选,盲算按 refunded.total > 100 筛选"]),
("SELECT p.user_id {j} ORDER BY p.total DESC LIMIT 10", "SELECT p.user_id {j} ORDER BY r.total DESC LIMIT 10",
["原流程取行前按 paid.total 降序排,盲算按 refunded.total 降序排"]),
("SELECT p.user_id {j} GROUP BY p.user_id HAVING MAX(p.total) > 10",
"SELECT p.user_id {j} GROUP BY p.user_id HAVING MAX(r.total) > 10",
["原流程对 paid.total 取最大值,盲算对 refunded.total 取最大值",
"原流程分组后只保留 MAX(paid.total) > 10 的组,盲算只保留 MAX(refunded.total) > 10 的组"]),
("SELECT COUNT(*) {j} WHERE p.total - r.total > 100", "SELECT COUNT(*) {j} WHERE r.total - p.total > 100",
["原流程只保留 paid.total - refunded.total > 100 的行,盲算没有这个条件",
"盲算只保留 refunded.total - paid.total > 100 的行,原流程没有这个条件"]),
], ids=["sum", "sum-of-difference", "where", "order-by", "having", "where-difference"])
def test_which_derived_table_a_column_comes_from_is_reported(form, a, b, expected):
"""两个派生来源算出了同名的列(付款合计、退款合计都叫 total),编号也对得上:外层用的是哪一个的 total 不同,
聚合、条件、排序、HAVING 里都得照报,不能因为都是「某个 CTE 的 total」就当成一样。只读得出列名的写法两边
一样时,改用带来源名字的写法说。"""
if form == "cte":
head, j = PAID_REFUNDED, PR_JOIN
else:
head, j = "", PR_SUBQUERIES
expected = [t.replace("paid.", "p.").replace("refunded.", "r.") for t in expected]
assert texts(head + a.format(j=j), head + b.format(j=j), labels=("原流程", "盲算")) == expected
def test_renamed_and_reordered_derived_tables_still_tell_their_columns_apart():
"""盲算给两个 CTE 另起了名字、定义和关联的先后也反过来:按里面读的表和各列的定义认出谁对谁,外层用的是
哪一个的列照样分得清;同一列只是名字、先后不同的不报。"""
b_head = ("WITH ref_sum AS (SELECT user_id, SUM(amount) AS total FROM refunds GROUP BY user_id), "
"pay_sum AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) ")
b_join = "FROM ref_sum r JOIN pay_sum p ON p.user_id = r.user_id"
labels = ("原流程", "盲算")
assert texts(PAID_REFUNDED + f"SELECT SUM(p.total) {PR_JOIN}",
b_head + f"SELECT SUM(r.total) {b_join}", labels=labels) == \
["原流程对 paid.total 求和,盲算对 ref_sum.total 求和"]
assert texts(PAID_REFUNDED + f"SELECT COUNT(*) {PR_JOIN} WHERE p.total > 100",
b_head + f"SELECT COUNT(*) {b_join} WHERE r.total > 100", labels=labels) == \
["原流程按 paid.total > 100 筛选,盲算按 ref_sum.total > 100 筛选"]
assert texts(PAID_REFUNDED + f"SELECT SUM(p.total) {PR_JOIN} WHERE p.total > r.total",
b_head + f"SELECT SUM(p.total) {b_join} WHERE r.total < p.total") == []
def test_an_extra_derived_table_on_one_side_is_not_paired_with_another():
"""盲算多一个排在前面的 CTE(vip),位置编号就错开了:原流程的 t 要和盲算的 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")
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.referrer_id")
assert texts(a, b, labels=("原流程", "盲算")) == [
"盲算只保留 vip = 1 的行,原流程没有这个条件",
"原流程关联 t 表的条件是 orders.user_id = users.id,盲算是 orders.user_id = users.referrer_id",
"盲算关联了 vip 表,原流程没有"]
# 两边各一个、结构毫不相干的派生来源也不配成一对:各说各的
other = ("WITH vip AS (SELECT id FROM users WHERE vip = 1) "
"SELECT COUNT(*) FROM users u JOIN vip ON vip.id = u.id")
joins = [d.text for d in diff_facets(facets(a), facets(other), labels=("原流程", "盲算")) if d.aspect == "join"]
assert joins == ["原流程关联了 t 表,盲算没有", "盲算关联了 vip 表,原流程没有"]
def test_unused_ctes_take_part_in_pairing():
"""两边都定义了付款、退款两个 CTE,原流程关联付款的、盲算关联退款的:没用到的那个也参加配对,付款配付款、退款
配退款,不会把两边各自用到的那个只凭列名都叫 user_id、total 凑成一对,说成「关联条件不同」。"""
a = PAID_REFUNDED + "SELECT COUNT(*) FROM users u JOIN paid p ON p.user_id = u.id"
b = PAID_REFUNDED + "SELECT COUNT(*) FROM users u JOIN refunded r ON r.user_id = u.id"
assert texts(a, b, labels=("原流程", "盲算")) == ["原流程关联了 paid 表,盲算没有", "盲算关联了 refunded 表,原流程没有"]
@pytest.mark.parametrize("a,b,expected", [
("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'",
["盲算关联了 stores 表,原流程没有"]),
("SELECT SUM(o.amount) FROM orders o JOIN refunds r ON r.order_id = o.id",
"SELECT SUM(o.amount) FROM users u JOIN refunds r ON r.user_id = u.id JOIN orders o ON o.id = r.order_id",
["盲算关联了 users 表,原流程没有"]),
(PAID_REFUNDED + f"SELECT COUNT(*) {PR_JOIN} WHERE p.total > r.total",
"WITH vip AS (SELECT id FROM users WHERE vip = 1), "
"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 vip v JOIN refunded r ON r.user_id = v.id JOIN paid p ON p.user_id = r.user_id "
"WHERE p.total > r.total",
["盲算只保留 vip = 1 的行,原流程没有这个条件", "盲算关联了 vip 表,原流程没有", "盲算用到了 users 表,原流程没有"]),
# 原流程只在条件的子查询里用到 users:那不是连接的一头,盲算多关联的还是 users
("SELECT SUM(o.amount) FROM orders o WHERE o.user_id IN (SELECT id FROM users WHERE vip = 1)",
"SELECT SUM(o.amount) FROM users u JOIN orders o ON o.user_id = u.id WHERE u.vip = 1",
["原流程只保留 user_id IN (SELECT id FROM users WHERE vip = 1) 的行,盲算没有这个条件",
"盲算关联了 users 表,原流程没有"]),
], ids=["real-table", "real-table-chain", "derived-shifted-and-swapped", "in-subquery-is-not-a-join"])
def test_an_extra_source_written_in_from_is_named_correctly(a, b, expected):
"""盲算多出来的来源写在 FROM 里、靠内连接接上原流程也有的表(FROM stores JOIN orders):多出来的是 stores,
不能说成「盲算关联了 orders 表」(原流程查的就是 orders);已经说了「关联了 stores 表」的,不再另说一遍「用到了
stores 表」。"""
assert texts(a, b, labels=("原流程", "盲算")) == expected
def test_a_lateral_subquery_is_named_by_its_alias():
"""LATERAL 子查询的别名挂在 LATERAL 上:取哪几行的说明里照写它,不写「子查询」。"""
a = ("SELECT o.id FROM orders o, LATERAL (SELECT amount FROM refunds r WHERE r.order_id = o.id "
"ORDER BY amount DESC LIMIT 1) l")
b = ("SELECT o.id FROM orders o CROSS JOIN LATERAL (SELECT amount FROM refunds r WHERE r.order_id = o.id "
"ORDER BY amount DESC LIMIT 2) AS x")
assert texts(a, b, "postgres") == ["A 在 l 里只取前 1 行,B 在 x 里只取前 2 行"]
def test_a_paired_derived_join_with_another_name_says_both_names():
"""配成一对的派生来源两边名字不同(原流程叫 t、盲算叫 totals):关联条件不同时两个名字都说出来。"""
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")
b = ("WITH totals AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
"SELECT COUNT(*) FROM users u JOIN totals ON totals.user_id = u.referrer_id")
assert texts(a, b, labels=("原流程", "盲算")) == [
"原流程关联 t 表的条件是 orders.user_id = users.id,盲算关联 totals 表的条件是 orders.user_id = users.referrer_id"]
left = b.replace("JOIN totals ON totals.user_id = u.referrer_id", "LEFT JOIN totals ON totals.user_id = u.id")
assert texts(a, left, labels=("原流程", "盲算")) == ["原流程用 INNER JOIN 关联 t 表,盲算用 LEFT JOIN 关联 totals 表"]
@pytest.mark.parametrize("a,b", [
(PAID_REFUNDED + f"SELECT COUNT(*) {PR_JOIN} WHERE p.total > r.total",
PAID_REFUNDED + "SELECT COUNT(*) FROM refunded r JOIN paid p ON p.user_id = r.user_id WHERE p.total > r.total"),
(f"SELECT COUNT(*) {PR_SUBQUERIES} WHERE p.total > r.total",
"SELECT COUNT(*) FROM (SELECT user_id, SUM(amount) AS total FROM refunds GROUP BY user_id) r "
"JOIN (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) p ON p.user_id = r.user_id "
"WHERE p.total > r.total"),
# 两个 CTE 读同一张表、各列的定义也一样,只差筛选条件:按整段的写法认得出谁是谁
("WITH paid AS (SELECT user_id, SUM(amount) AS total FROM orders WHERE status = 'paid' GROUP BY user_id), "
"open AS (SELECT user_id, SUM(amount) AS total FROM orders WHERE status = 'open' GROUP BY user_id) "
"SELECT COUNT(*) FROM paid p JOIN open o ON o.user_id = p.user_id WHERE p.total > o.total",
"WITH paid AS (SELECT user_id, SUM(amount) AS total FROM orders WHERE status = 'paid' GROUP BY user_id), "
"open AS (SELECT user_id, SUM(amount) AS total FROM orders WHERE status = 'open' GROUP BY user_id) "
"SELECT COUNT(*) FROM open o JOIN paid p ON p.user_id = o.user_id WHERE o.total < p.total"),
# 实表也一样:内连接换个先后写,意思不变
("SELECT SUM(o.amount) FROM orders o JOIN refunds r ON r.order_id = o.id WHERE o.amount > r.amount",
"SELECT SUM(o.amount) FROM refunds r JOIN orders o ON o.id = r.order_id WHERE o.amount > r.amount"),
("SELECT SUM(o.amount) FROM orders o, refunds r WHERE r.order_id = o.id",
"SELECT SUM(o.amount) FROM refunds r, orders o WHERE r.order_id = o.id"),
], ids=["cte", "subquery", "same-shape-ctes", "real-tables", "comma-join"])
def test_swapping_the_order_of_an_inner_join_is_no_difference(a, b):
"""内连接两边换个先后(FROM paid JOIN refunded 换成 FROM refunded JOIN paid)意思不变:派生来源按结构配对,
比较的两边按配好的编号排,不会排成两个方向、报出两条自相矛盾的话;连接按涉及的两边和条件比,不按哪一侧是
关联进来的。"""
assert texts(a, b) == []
def test_two_same_shape_derived_tables_are_paired_by_their_conditions():
"""两个 CTE 读同一张表、各列定义也一样(付款的、未付的),盲算两个都另起了名字、都多加了 is_deleted = 0、关联的
先后也反过来:结构上只剩里面的条件分得开,按条件的重合配(status = 'paid' 配 status = 'paid'),只报多出来的那个
条件,外层拿两者比的条件不跟着报。"""
a = ("WITH paid AS (SELECT user_id, SUM(amount) AS total FROM orders WHERE status = 'paid' GROUP BY user_id), "
"open AS (SELECT user_id, SUM(amount) AS total FROM orders WHERE status = 'open' GROUP BY user_id) "
"SELECT COUNT(*) FROM paid p JOIN open o ON o.user_id = p.user_id WHERE p.total > o.total")
b = ("WITH unpaid AS (SELECT user_id, SUM(amount) AS total FROM orders WHERE status = 'open' AND is_deleted = 0 "
"GROUP BY user_id), "
"settled AS (SELECT user_id, SUM(amount) AS total FROM orders WHERE status = 'paid' AND is_deleted = 0 "
"GROUP BY user_id) "
"SELECT COUNT(*) FROM unpaid u JOIN settled s ON s.user_id = u.user_id WHERE u.total < s.total")
assert texts(a, b, labels=("原流程", "盲算")) == ["盲算只保留 is_deleted = 0 的行,原流程没有这个条件"]
def test_limits_inside_derived_tables_are_paired_by_structure():
"""CTE 里取哪几行按配好对的派生来源比,不按出现的先后:盲算多一个排在前面、也限了行数的 CTE,不能拿它和
原流程的 top 比。"""
a = ("WITH top AS (SELECT user_id, amount FROM orders ORDER BY amount DESC LIMIT 10) "
"SELECT SUM(amount) FROM top")
b = ("WITH vip AS (SELECT id FROM users WHERE vip = 1 LIMIT 5), "
"top AS (SELECT user_id, amount FROM orders ORDER BY amount DESC LIMIT 10) "
"SELECT SUM(top.amount) FROM top JOIN vip ON vip.id = top.user_id")
found = [d.text for d in diff_facets(facets(a), facets(b), labels=("原流程", "盲算")) if d.aspect == "limit"]
assert found == ["原流程不限行数,盲算在 vip 里只取前 5 行"]
def test_a_changed_column_inside_a_renamed_derived_table_is_said_once():
"""CTE 里一列的算法变了(对 amount 求和换成对 paid_amount 求和),名字也另起了:按读的表和选出来的列名仍配成
一对,只报里面那处不同,外层按这一列写的条件不跟着报成两边各有一个。"""
a = ("WITH t AS (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) "
"SELECT COUNT(*) FROM t WHERE total > 100")
b = ("WITH vip AS (SELECT id FROM users WHERE vip = 1), "
"x AS (SELECT user_id, SUM(paid_amount) AS total FROM orders GROUP BY user_id) "
"SELECT COUNT(*) FROM vip JOIN x ON x.user_id = vip.id WHERE total > 100")
found = diff_facets(facets(a), facets(b), labels=("原流程", "盲算"))
assert "原流程对 amount 求和,盲算对 paid_amount 求和" in [d.text for d in found]
assert not [d for d in found if d.aspect == "filter" and "total" in d.text]
@pytest.mark.parametrize("a,b,text", [ @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 10) SELECT SUM(amount) FROM top",
"WITH top AS (SELECT amount FROM orders ORDER BY amount DESC LIMIT 100) SELECT SUM(amount) FROM top", "WITH top AS (SELECT amount FROM orders ORDER BY amount DESC LIMIT 100) SELECT SUM(amount) FROM top",