diff --git a/backend/app/data/sql_semantics.py b/backend/app/data/sql_semantics.py index 0ebb3e77..f9d229c3 100644 --- a/backend/app/data/sql_semantics.py +++ b/backend/app/data/sql_semantics.py @@ -63,8 +63,15 @@ NOT NOT 条件去掉、德摩根律(三值逻辑下照样成立);LIMIT / O 写成 \\\\Z 的撞成同一个值);数字后面紧跟着字母、下划线(1_000 是一千、0o17 是 15,sqlglot 切成数字加别名);两个字符串 常量挨着写(MySQL 里是拼接,SQLite 里是别名);同一层括号、同一段条件里比较运算和 IS / IN / LIKE / BETWEEN / 另一个 比较运算没加括号地混用(a = b IS NULL、a < 100 IN (0, 1)、a = 100 < 200:各方言的结合顺序和 sqlglot 的不一样); - 按位运算(& | ^ << >> ~,PostgreSQL 的 # 这类)和算术、拼接、另一种按位运算没加括号地混用;XOR;不知道是哪种库时 - 的 ||、MySQL 以外的 &&。 + 按位运算(& | ^ << >> ~,PostgreSQL 的 # 这类)和算术、拼接、另一种按位运算没加括号地混用;写成符号、sqlglot 却当成 + LIKE 那一级来读的运算符(PostgreSQL 的 ~、~*、~~、~~*、@@、-|-……)和别的二元运算混用(PostgreSQL 里它们和 ||、& + 同级、从左往右:status ~ 'p' || 'x' 是 (status ~ 'p') || 'x',sqlglot 读成 status ~ ('p' || 'x');写成关键字的 + LIKE / ILIKE 本来就比 || 松,不算);PostgreSQL 的前缀 @(取绝对值,sqlglot 读成参数)、|/ 和 ||/(双精度的开方, + sqlglot 写成 numeric 的 SQRT)、COLLATE 后面同一段里的 ::;XOR;不知道是哪种库时的 ||、#、块注释里套着的 /*(PostgreSQL + 嵌套、SQLite / MySQL 不嵌套)、后面紧跟着字的 --(MySQL 里是两个减号)、双引号括起来的名字(MySQL 默认的 sql_mode 里 + 是字符串),MySQL 以外的 &&。第 5 轮在真实的 + PostgreSQL 18.6、MySQL 8.0、SQLite 上做过差分压测(不加括号的随机运算符混用对 sqlglot 读出来的树每个操作数都加 + 括号,各几千条):读错了的都在这里拦下。 - 丢掉的:一元加号(SQLite 里 +a 去掉了类型亲和)、0x10 和 x'10' 这类只留下值的字面量、换了名字的类型名、MySQL 会执行 的注释(/*! … */)、写成 ! 的 NOT;解析时换了写法又没留位置的函数名(见 _mark_surfaces:SQLite 的 MOD() 和 % 解析成 同一个节点,记下原文写的名字就不会判成等价)。MySQL 里后面不跟空白的 -- 是两个减号,先拆开再解析。 @@ -95,7 +102,8 @@ PostgreSQL numeric 的 2.0 和 2.00、算出来的表达式),以及 PostgreS **假设(做不到的)**:SQLite 没给表结构时按列有声明的类型、默认的 BINARY 排序规则算——声明了 COLLATE NOCASE 的列、 没有类型亲和的列(能同时存 1 和 1.0)表结构里看不出来;比较两边都是列时对调两边(SQLite 比较两列用左边那列声明的 排序规则)。SQLite 的求和当成和先后无关。PostgreSQL、MySQL 没给表结构时一律不当成「相等就是一样」、也不当成精确数。 -MySQL 的表名按 Linux 上分大小写算。 +MySQL 的表名按 Linux 上分大小写算;MySQL 按默认的 sql_mode 读:双引号括起来的是字符串、|| 是 OR(开了 ANSI_QUOTES、 +PIPES_AS_CONCAT 的库这两处的意思不同,sqlglot 的 MySQL 读法和这里都按默认的算)。 等价的结论另有执行比对守门(tests/test_sql_equivalence.py):虚构库上五组随机数据、一组极端数据、一组专挑写法差异的 数据,判等价的两条每组结果都得一样,结果不一样的绝不能判等价;复验报过的反例都收在语料里。 @@ -106,22 +114,32 @@ LIMIT 是至少为 1 的常数;没有认不准的名字、SELECT 列表里没 WHERE / GROUP BY 里用列别名的),而且只说四类: - 时间列(time):两边各在一张底层表的一列上按时间筛、列不同(另一边哪儿都没用这一列筛选,两边的时间条件都不是恒为 - 真的);一边按时间筛了、另一边哪儿都没有像时间的列和常量的,也说成 time(「A 按 paid_at 筛时间,B 没有按时间 - 筛选」)。detail 给两边的 [{table, column}]。 + 真的,时间列不在 LEFT JOIN 的可空一侧);一边按时间筛了、另一边哪儿都没有可能是时间窗口的条件的(见下),也说成 + time(「A 按 paid_at 筛时间,B 没有按时间筛选」)。时间条件是一列(可以套着日期函数、CAST)和常量比较、IN、BETWEEN, + 列名像时间或者常量像日期;或者时间列 LIKE 一个像日期的模式(SQLite 里日期存成文字,paid_at LIKE '2026-09%' 是取月)。 + 两边的时间列在不同的表上时写成「表.列」。detail 给两边的 [{table, column}]。 + 「另一边可能按时间筛了」(_Usage.temporal)认得很宽:条件里(WHERE、ON、HAVING,聚合的参数、CASE、FILTER 里也算) + 用到可能是时间的列(date_key、stat_month、pt、fiscal_year 这类,见 _timeish_name)、日期函数、像日期的常量 + (20260901、202609 这类整数,'2026'、'2026Q3'、'2026-09%'),或者对同一个对象有上下界(BETWEEN、>= 和 < 一对)。 - 多一个筛选条件(filter):一边多出来、只涉及一张底层表的列和常量比的条件——另一边整条语句(WHERE、ON、HAVING、CASE / - FILTER 里,判空、或者条件只筛掉空值时连聚合的参数)对这些列没有任何条件;这一边对这些列的条件全是另一边没有的 - (不是被另一边也有的条件蕴含的);比的对象就是那一列(不是 LOWER(x) 这类认不出取值范围的);按取值逐段算过不是 - 恒为真的(_Range.always:表结构里确定不为空、又不在真的可能整行为空的外连接一侧的列不取空值)。detail 给条件涉及的 - 实表列。 + FILTER 里,聚合的参数里套在别的运算、函数里的:SUM(status = 'paid')、COUNT(… OR NULL)、COUNT(NULLIF(…))、 + SUM(amount * (1 - is_deleted));判空、或者条件只筛掉空值时连光一列的聚合参数)对这些列没有任何条件;这一边对这些列 + 的条件全是另一边没有的(不是被另一边也有的条件蕴含的);比的对象就是那一列(不是 LOWER(x) 这类认不出取值范围的); + 按取值逐段算过不是恒为真的(_Range.always:表结构里确定不为空、又不在真的可能整行为空的外连接一侧的列不取空值); + 不用到 LEFT JOIN 可空一侧的列(WHERE r.id IS NULL 是反连接,说的是「没关联上」;r.order_id IS NULL 和 ON 里的 + r.order_id = o.id 一起已经蕴含 r.id IS NULL);不是可能的时间窗口(_window_like:date_key BETWEEN 20260901 AND + 20260930、stat_month = 202609——认不准是不是时间列,说成有效行过滤会被写进数据目录)。detail 给条件涉及的实表列。 - 聚合与去重(aggregate / group_by):选出来的列只差在顶层的聚合函数上(同一列做参数,SUM 对 AVG;COUNT(*) 对 SUM(x)) 或者去重上(COUNT / SUM / AVG 去不去重、COUNT(*) 对 COUNT(DISTINCT x)、SELECT DISTINCT 对不去重),分组一样。参数 - 得是光一列、在每组里不是定死的(不被 WHERE 定成常量、不等于分组键);去重不起作用的不说(MIN / MAX;不分组的聚合只出 + 得是光一列、在每组里不是定死的(不被 WHERE 定成常量、不等于分组键、表结构里它那张表的键没有在组里定死——按 u.id 分组 + 时 MAX(u.city) 和 MIN(u.city) 一样,LEFT JOIN 把行复制了也一样);去重不起作用的不说(MIN / MAX;不分组的聚合只出 一行;已经按选出来的列分组;只取一行;表结构里的键说明每行本来就不一样);每组只有一行时 SUM、AVG、MIN、MAX 不说。 - 关联的表、关联类型(tables / join):换了一张表;一边多关联了一张表(表结构里有指向它的外键、关联条件又用到它的键的 不说);一边多 LEFT JOIN 了一张表(在 ON 之外没用到、结果看得见重复的行、表结构里这张表的键不在 ON 里);同一张表、 同样的条件,LEFT JOIN 对内连接(可空一侧在 WHERE、HAVING、后面的连接里都没用到——后面的内连接引用可空一侧时没关联上 的行反正留不下来;结果看得见没关联上的那些行,不是只取可空一侧跳过空值的聚合、FILTER;原文写的是 RIGHT JOIN、或者 - 被 WHERE / 聚合改成内连接的,说法和原文对不上,不说)。 + 被 WHERE / 聚合改成内连接的,说法和原文对不上,不说)。另一边原文也 LEFT JOIN 了这张表、只是规范化因为结果不怕重复 + 把它去掉了的,不说「另一边没有」。 每一句都先确认这处不同真会影响结果:两边的 WHERE、HAVING、ON 里有可能恒为假的条件(常量条件,同一个对象上取不到值的 几个条件)时什么都不说;结果看不见行的(SELECT COUNT(*) > -1 这类只出一行、值又和行无关的)不说筛选、关联上的不同; @@ -148,11 +166,19 @@ WHERE / GROUP BY 里用列别名的),而且只说四类: 可空一侧聚合的 LEFT JOIN 写成内连接。 - 只给兜底的:不是简单形状的(CTE、子查询、UNION、窗口函数,规范化没把它们并掉的);四类以外的不同(同一列上的时间 范围、同一列上换了筛选的值、关联条件、分组、HAVING、排序、取几行、选出来的列、聚合的参数);几张表、几列交错的条件; - 自己关联自己、同名的表关联了几次的;换了表又有关联条件不同的。 + 自己关联自己、同名的表关联了几次的;换了表又有关联条件不同的;条件写在聚合里的一边对条件写在 WHERE 里的一边(SUM(status + = 'paid') 对 COUNT(*) … WHERE status = 'paid':真正的不同是计数为 0 的组、空集时是 0 还是 NULL,说不清);反连接; + 可能是时间窗口、又认不准是不是时间列的条件(业务日期键、统计月份、分区,第几天这类范围)。 - 已知的漏洞(只会说出「真会影响结果」以外的话,不会判错等价):几列交错的矛盾条件(a > b AND b > a)认不出恒为假, 两边都带着它时别处的不同其实看不出来;结果看不看得见行只按「SELECT 列表里有没有一项本身就是聚合」粗看,CASE WHEN COUNT(*) > 0 THEN 1 END 这类恒为一个值的表达式不算聚合,认作看不见(少说),可 MAX(x) 旁边并排的那一项恒为一个值时 - 仍按看得见说;表结构里没写外键、没写键的,按「可能对不上、可能重复」说(库里实际有约束时这句话可能不起作用)。 + 仍按看得见说;表结构里没写外键、没写键的,按「可能对不上、可能重复」说(库里实际有约束时这句话可能不起作用);没给 + 表结构时按主键分组的 MAX(x) 对 MIN(x)、AVG(DISTINCT x) 对 AVG(x) 照说(那时不知道键);「可能是时间」只认列名里的 + 词和像日期的常量,一边按 id 的单边范围、按批次号这类筛出来的其实是一段时间时,另一边仍会被说成「没有按时间筛选」。 +- 收窄换来的代价:一边的聚合参数里用到某一列(SUM(amount - discount)),另一边多一个这一列上的条件(WHERE discount + > 0)时,这个条件不再说成「另一边没有这个条件」(认不出参数里的运算是不是在筛);列名带 day / month / period 这类 + 词、常量像日期的条件(delivery_days < 3、period_type = 'monthly')不再说成有效行过滤;另一边有任何上下界(amount + BETWEEN 10 AND 100)时不再说它「没有按时间筛选」。都只给兜底。 **和 sqlcheck 一样宁可漏报。** 解析不了(语法错、方言特性、多条语句、不是查询)就只给一条 aspect="parse";从不抛异常, 规范化或说明本身出了意外就当作这一级没有、说不清,记日志。只读语法树,不执行 SQL。 @@ -832,6 +858,80 @@ def _temporal_name(name: str) -> bool: return catalog.is_date_column(name, None) or bool(_CN_TIME.search(name)) +#: 切词后有一个词是这些的列名「可能是时间」:数仓的业务日期键 date_key、统计月份 stat_month、分区 pt / ds、财年 +#: fiscal_year、第几天 day_no……认不准是不是时间列(_temporal_name 认的是一定像日期的),只拿来少说:这样的列上的 +#: 窗口不说成有效行过滤,另一边有它时也不说「没有按时间筛选」 +_TIMEISH_WORDS = frozenset({"date", "dates", "time", "times", "day", "days", "dt", "ds", "ts", "timestamp", "datetime", + "month", "months", "mon", "year", "years", "yr", "fy", "fiscal", "week", "weeks", "wk", + "quarter", "qtr", "period", "hour", "hr", "minute", "ym", "ymd", "yyyymm", "yyyymmdd", + "yyyy", "pt", "datekey", "datekeys"}) +_CN_TIMEISH = re.compile(r"日期|时间|时刻|年月|月份|年度|季度|账期|期间|统计期|周期|[年月日周]$") +#: 可能是日期的数字写法:yyyymm、yyyymmdd(20260901、202609,数仓常这么存日期键、月份) +_YMD_DIGITS = re.compile(r"^(?:19|20|21)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])?$") +#: 可能是日期的文字:年('2026')、季度('2026Q3'、'2026-Q3')、周('2026-W36') +_PARTIAL_DATE_TEXT = re.compile(r"^\s*(?:19|20|21)\d{2}(?:[-/]?[Qq][1-4]|-?[Ww]\d{1,2})?\s*$") + + +def _timeish_name(name: str) -> bool: + """列名可能是时间(见 _TIMEISH_WORDS):比 _temporal_name 宽。""" + return _temporal_name(name) or any(w in _TIMEISH_WORDS for w in catalog.name_words(name)) or \ + bool(_CN_TIMEISH.search(name)) + + +def _date_like_literal(node: Any) -> bool: + """常量可能是日期:像日期的字符串(2026-09-01、2026-09、2026、20260901、2026Q3),yyyymm / yyyymmdd 的整数, + LIKE 的模式去掉两头的 % / _ 之后像日期('2026-09%')。""" + from sqlglot import exp + + node = _unparen(node) + if not isinstance(node, exp.Literal): + return False + text = str(node.this).strip() + if node.is_string: + text = text.strip("%_") + return bool(_DATE_TEXT.match(text) or _PARTIAL_DATE_TEXT.match(text) or _YMD_DIGITS.match(text)) + return bool(_YMD_DIGITS.match(text)) + + +#: 拿列和值比的谓词(判空不算:x IS NOT NULL 是有效行过滤,不是时间窗口) +_WINDOW_PREDICATES = ("EQ", "NEQ", "GT", "GTE", "LT", "LTE", "In", "Between", "Like", "ILike", "RegexpLike", + "RegexpILike", "Glob", "SimilarTo") + + +def _window_like(node: Any) -> bool: + """node 里有可能是时间窗口的比较:拿可能是时间的列(见 _timeish_name)、日期函数、像日期的常量(见 + _date_like_literal、_datey)去比。判空不算。只拿来少说:这样的条件不说成有效行过滤,另一边有它时也不说「没有按 + 时间筛选」。""" + from sqlglot import exp + + for n in node.walk(): + if type(n).__name__ not in _WINDOW_PREDICATES: + continue + if _datey(n) or any(_date_like_literal(x) for x in n.find_all(exp.Literal)) or \ + any(c.name and _timeish_name(c.name) for c in n.find_all(exp.Column)): + return True + return False + + +def _bounded_both_ways(tree: Any) -> bool: + """语句里有对同一个对象的上下界(BETWEEN,或者 x >= 常量 和 x < 常量 都有):可能是认不出的时间窗口(第几天 + day_no BETWEEN 20697 AND 20726)。只拿来少说。""" + from sqlglot import exp + + if tree.find(exp.Between) is not None: + return True + low: set[str] = set() + high: set[str] = set() + for n in tree.find_all(exp.GT, exp.GTE, exp.LT, exp.LTE): + left, right = _literal_value(n.this) is not None, _literal_value(n.expression) is not None + if left == right: + continue + subject = n.expression if left else n.this + upward = isinstance(n, (exp.GT, exp.GTE)) != left + (low if upward else high).add(_fingerprint(subject)) + return bool(low & high) + + def _join_kind(join: Any) -> str: side = str(join.args.get("side") or "").lower() kind = str(join.args.get("kind") or "").lower() @@ -2604,6 +2704,24 @@ _OPERAND_ENDS = frozenset({"VAR", "IDENTIFIER", "NUMBER", "R_PAREN", "R_BRACKET" #: 字符串里的反斜杠是转义的方言(MySQL / MariaDB,以及不知道是哪种库时):sqlglot 不认 \Z(Ctrl+Z)、\0(NUL), #: 原样留着反斜杠,和写成 \\Z 的撞成同一个值。SQLite、PostgreSQL 的普通字符串里反斜杠就是反斜杠 _PLAIN_BACKSLASH = frozenset({"sqlite", "postgres"}) +#: sqlglot 读注释的方式和库一样的方言:块注释 SQLite、MySQL 不嵌套(sqlglot 的这两种读法也不嵌套),PostgreSQL 嵌套 +#: (sqlglot 也嵌套)。别的方言、不知道是哪种库时,块注释里再写 /* 就说不准注释到哪儿为止 +_COMMENTS_AS_SQLGLOT = frozenset({"sqlite", "mysql", "postgres"}) + + +def _symbolic(word: str) -> bool: + """运算符写成符号(~、~*、@@、-|-),不是写成关键字(LIKE、ILIKE、REGEXP)。""" + return bool(word) and not any(ch.isalnum() or ch == "_" for ch in word) + + +def _range_tokens(read: str | None) -> frozenset[str]: + """这个方言里 sqlglot 当成 LIKE、IN 那一级(比 ||、+ 松)来读的词的类型名(RANGE_PARSERS)。""" + from sqlglot.dialects.dialect import Dialect + + try: + return frozenset(t.name for t in Dialect.get_or_raise(read).parser_class.RANGE_PARSERS) + except Exception: # noqa: BLE001 - 取不到:只靠下面列出来的几类 + return frozenset() def _misread(tokens: list[Any], text: str, read: str | None) -> str | None: @@ -2619,12 +2737,31 @@ def _misread(tokens: list[Any], text: str, read: str | None) -> str | None: a < 100 IN (0, 1)、a = 100 < 200):SQLite 里 IS、IN 和 = 同级从左往右,PostgreSQL 里 IS 比 = 松,MySQL 里 比较运算都同级从左往右,sqlglot 让 IS / IN 先结合; - 同一层括号、同一段里,按位运算(& | ^ << >> ~ 和 PostgreSQL 的 # 这类)、COLLATE 和别的二元运算(算术、拼接、 - 另一种按位运算)混用。 + 另一种按位运算)混用; + - 写成符号、sqlglot 却当成 LIKE / IN 那一级来读的运算符(PostgreSQL 的 ~、~*、~~、~~*、@@、-|-、&<……)和别的二元 + 运算混用:PostgreSQL 里它们是「别的运算符」,和 ||、&、# 同级、从左往右结合,status ~ 'p' || 'x' 是 + (status ~ 'p') || 'x'(得到 'truex' 这样的文字),sqlglot 读成 status ~ ('p' || 'x')。写成关键字的 LIKE、ILIKE + 本来就比 || 松,PostgreSQL 和 sqlglot 读得一样,不算; + - PostgreSQL(以及不知道是哪种库时)的前缀 @(取绝对值,@ amount - 20 是 @ (amount - 20),sqlglot 读成参数 + $amount)、|/ 和 ||/(双精度的开方,sqlglot 写成 SQRT / CBRT,numeric 的 SQRT 结果多出几位小数);COLLATE 后面 + 同一段里又有 ::(PostgreSQL 里 x COLLATE "C"::text 的 :: 套在整个 COLLATE 上,sqlglot 读成套在排序规则的名字上); + - 不知道是哪种库时:块注释里再写 /*(PostgreSQL 嵌套、SQLite / MySQL 不嵌套,见 _COMMENTS_AS_SQLGLOT)、后面紧跟着 + 字的 --(MySQL 里 amount--1 是 amount - (-1),别的库是注释)、#(MySQL 里是注释,PostgreSQL 里是按位异或)、双引号 + 括起来的名字(MySQL 默认的 sql_mode 里 "status" 是字符串,别的库和 sqlglot 的通用读法里是列名)。 一段条件到 AND、OR、逗号、子句关键字为止(BETWEEN 自己的 AND 不算;IS [NOT] DISTINCT FROM 的 FROM 不算)。""" if read not in _PLAIN_BACKSLASH and any( t.token_type.name.endswith("STRING") and "\\" in text[t.start:t.end + 1] for t in tokens): return "字符串里的反斜杠转义" + if read not in _COMMENTS_AS_SQLGLOT and any("/*" in c for t in tokens for c in (t.comments or [])): + return "块注释里套着 /*" + if read is None and (_mysql_dashes(text) != text or any(t.token_type.name == "HASH" for t in tokens)): + return "-- 和 # 在这个方言里是不是注释说不准" + if read is None and any(t.token_type.name == "IDENTIFIER" and text[t.start:t.start + 1] == '"' for t in tokens): + # sqlglot 的通用读法(和 PostgreSQL、SQLite 一样)把 "status" 读成名字,MySQL 默认的 sql_mode 里它是字符串: + # WHERE "status" = 'paid' 恒为假,和 WHERE status = 'paid' 读成了同一棵树 + return "双引号括起来的是名字还是字符串说不准" + ranged = _range_tokens(read) for prev, token in zip(tokens, tokens[1:]): if prev.token_type.name.endswith("STRING") and token.token_type.name.endswith("STRING"): return "两个字符串常量挨着写" @@ -2634,7 +2771,7 @@ def _misread(tokens: list[Any], text: str, read: str | None) -> str | None: frames: list[dict[str, Any]] = [{}] def fresh(frame: dict[str, Any]) -> None: - frame.update(compare=0, between=0, bitwise=set(), arithmetic=False) + frame.update(compare=0, between=0, bitwise=set(), arithmetic=False, collated=False) def done(frame: dict[str, Any]) -> str | None: if frame["compare"] >= 2: @@ -2658,6 +2795,15 @@ def _misread(tokens: list[Any], text: str, read: str | None) -> str | None: return "XOR" if (kind == "DPIPE" and read is None) or (kind == "DAMP" and read != "mysql"): return f"{token.text} 在这个方言里的意思说不准" + if kind == "PARAMETER" and token.text == "@" and read in ("postgres", None): + # PostgreSQL 里前缀的 @ 是取绝对值(@ amount - 20 是 @ (amount - 20)),sqlglot 读成参数 $amount + return "PostgreSQL 的前缀 @" + if kind in ("PIPE_SLASH", "DPIPE_SLASH"): + # PostgreSQL 的 |/、||/ 是双精度的开方,sqlglot 写成 SQRT / CBRT:numeric 的 SQRT 多出几位小数 + return f"PostgreSQL 的 {token.text}" + if kind == "DCOLON" and frame["collated"]: + # x COLLATE "C"::text:PostgreSQL 里 :: 套在整个 COLLATE 上,sqlglot 读成套在排序规则的名字上 + return "COLLATE 后面跟着 ::" if kind in ("L_PAREN", "L_BRACKET", "CASE"): frames.append({}) fresh(frames[-1]) @@ -2688,6 +2834,9 @@ def _misread(tokens: list[Any], text: str, read: str | None) -> str | None: elif kind in _MISREAD_PREDICATES and not (called and kind not in ("IN", "BETWEEN", "IS")): # MySQL 的 ISNULL(x)、SQLite 的 like(x, y)、MATCH (…) AGAINST 这类写在值的位置、后面跟括号的是函数 frame["compare"] += 1 + if _symbolic(token.text): + # 写成符号的 ~、~*、~~、~~*:PostgreSQL 里和 ||、& 同级,sqlglot 当成 LIKE 那一级(比 || 松) + frame["bitwise"].add(kind) if kind == "BETWEEN": frame["between"] += 1 elif kind == "IS": @@ -2703,12 +2852,14 @@ def _misread(tokens: list[Any], text: str, read: str | None) -> str | None: elif kind == "NOT" and nxt is not None and nxt.token_type.name == "NULL": # SQLite 的后缀 x NOT NULL(前面是 IS 的已经整个算过了) frame["compare"] += 1 - elif kind in _MISREAD_BITWISE: + elif kind in _MISREAD_BITWISE or (kind in ranged and _symbolic(token.text)): + # 后一种:@@、-|-、&<、@? 这些写成符号的运算符,sqlglot 当成 LIKE 那一级,PostgreSQL 里和 ||、& 同级 frame["bitwise"].add(kind) elif kind == "COLLATE": # MySQL 的 COLLATE 比 + 结合得紧,sqlglot 的 MySQL 读法让 a + b COLLATE x 整个套上 COLLATE:和算术、拼接、 # 按位运算混用时当成按位运算一样拦下 frame["bitwise"].add(kind) + frame["collated"] = True elif kind in _MISREAD_ARITHMETIC: frame["arithmetic"] = True i += 1 @@ -2729,8 +2880,9 @@ def _lost_in_parsing(text: str, read: str | None, tree: Any) -> bool: sqlglot 都按 NOT 读; - 类型名:解析后重新生成换了名字、又不是这个方言里的同义写法(SQLite 的 CAST(x AS STRING) 是数值亲和,解析成 和 CAST(x AS TEXT) 一样的节点); - - 已知会被读错的写法(MySQL 字符串里的反斜杠转义、1_000 这类数字、没加括号地混用的比较 / 按位运算、XOR,见 - _misread)。""" + - 已知会被读错的写法(MySQL 字符串里的反斜杠转义、1_000 这类数字、没加括号地混用的比较 / 按位运算、PostgreSQL 写成 + 符号的匹配运算符和 || 混用、前缀的 @、|/、XOR,不知道是哪种库时套着的块注释、紧跟着字的 --、双引号括起来的名字, + 见 _misread)。""" try: tokens = _tokens(text, read) except Exception: # noqa: BLE001 - 切词都不行的,解析也过不了,那边会说 @@ -6217,11 +6369,21 @@ class _Usage: """一条语句里每一列(按定义找到实表列,认不出来源的只记列名)用在了哪儿: - cond:WHERE、ON、HAVING 里,条件里的子查询(IN、EXISTS、标量)选出来的列; - - case:CASE / IF / FILTER 里(SUM(CASE WHEN status = 'paid' THEN amount END) 等于先按 status 筛再求和); - - agg:聚合函数、窗口函数的参数(COUNT(x)、SUM(x) 跳过空值,等于先筛掉 x 为空的行)。 + - case:CASE / IF / FILTER 里(SUM(CASE WHEN status = 'paid' THEN amount END) 等于先按 status 筛再求和),以及聚合 + 的参数里套在别的运算、函数里的列(SUM(status = 'paid')、COUNT(status = 'paid' OR NULL)、COUNT(NULLIF(status, + 'void'))、SUM(amount * (status = 'paid'))、SUM(amount * (1 - is_deleted)):乘上 0、变成空值的行就是被筛掉了, + 和写 CASE 是一回事。认不出是不是在筛,一律当成在筛,只会少说); + - agg:光一列做聚合函数、窗口函数的参数(COUNT(x)、SUM(x) 跳过空值,等于先筛掉 x 为空的行)。 光选出来、分组、排序的列不记。说「另一边没有这个条件」「另一边没有按时间筛选」之前,先看另一边在这一段能影响到的 - 地方(整条语句,除了合并里别的支)用没用到这些列。""" + 地方(整条语句,除了合并里别的支)用没用到这些列。 + + temporal:这条语句哪儿可能按时间筛了——在条件里(cond / case)用到可能是时间的列(_timeish_name:paid_at,也包括 + date_key、stat_month 这类认不准的);WHERE、HAVING、ON 里有日期函数、像日期的常量;任何地方(聚合的参数、CASE、 + FILTER 里也算)有可能是时间窗口的比较(_window_like:20260901 这类整数、'2026-09%' 这类模式);对同一个对象有 + 上下界(BETWEEN、x >= … 和 x < … 都有,见 _bounded_both_ways)。这时不说它「没有按时间筛选」:第 5 轮复验抓到 + SUM(paid_at >= '2026-09-01' AND paid_at < '2026-10-01')、date_key BETWEEN 20260901 AND 20260930 都被说成了没有按时间 + 筛选。""" def __init__(self, tree: Any) -> None: from sqlglot import exp @@ -6243,38 +6405,46 @@ class _Usage: bases.append((None, col.name)) for table, column in bases: self.nodes.setdefault(_usage_key(table, column), {})[id(col)] = kind - if kind != "agg" and _temporal_name(column): + if kind != "agg" and (_timeish_name(column) or _timeish_name(col.name)): self.temporal = True for node in tree.find_all(exp.Where, exp.Having, exp.Join): target = node.args.get("on") if isinstance(node, exp.Join) else node.this if target is not None and _datey(target): self.temporal = True + if _window_like(tree) or _bounded_both_ways(tree): + self.temporal = True @staticmethod - def _owner(node: Any) -> tuple[Any, str | None, bool, bool]: - """node 属于哪一段 SELECT、在它的哪个子句里、路上有没有 CASE / IF / FILTER、有没有聚合 / 窗口函数。""" + def _owner(node: Any) -> tuple[Any, str | None, bool, bool, bool]: + """node 属于哪一段 SELECT、在它的哪个子句里、路上有没有 CASE / IF / FILTER、有没有聚合 / 窗口函数,以及在聚合的 + 参数里是不是套在别的运算、函数里(不是光一列:括号、DISTINCT、CAST 不算)。""" from sqlglot import exp - case = agg = False + case = agg = computed = False child, parent = node, node.parent while parent is not None: if isinstance(parent, (exp.Case, exp.If, exp.Filter)): case = True if isinstance(parent, (exp.AggFunc, exp.Window)): agg = True + elif not agg and not isinstance(parent, (exp.Paren, exp.Distinct, exp.Cast, exp.TryCast)): + # 只换类型(CAST)还是那一列;别的运算、函数(比较、NULLIF、乘上标记……)可能就是在筛 + computed = True if isinstance(parent, exp.Select): - return parent, child.arg_key, case, agg + return parent, child.arg_key, case, agg, computed child, parent = parent, parent.parent - return None, None, case, agg + return None, None, case, agg, computed def _kind(self, col: Any) -> str | None: - select, key, case, agg = self._owner(col) + select, key, case, agg, computed = self._owner(col) if select is None: return None if key == "expressions": if _scalar_context(select) or _condition_query(select): return "cond" - return "case" if case else ("agg" if agg else None) + if case or (agg and computed): + return "case" + return "agg" if agg else None if key in ("group", "order", "distinct"): return "case" if case else None return "cond" @@ -6558,21 +6728,25 @@ class _Explainer: 只在两边都是简单形状时说(见 _simple_block),也只说下面四类;别的不同(同一列上的时间范围、同一列上换了筛选的值、 关联条件、分组、HAVING、排序、取几行、选出来的列、聚合的参数……)一律算没说到,交给兜底说明: - - time:底层表上按哪一列筛时间不同(两边各在一列上按时间筛,列不同,另一边哪儿都没用这一列筛选);一边按时间筛了、 - 另一边哪儿都没有像时间的列和常量的,算多出来的筛选,也说成 time。 + - time:底层表上按哪一列筛时间不同(两边各在一列上按时间筛,列不同,另一边哪儿都没用这一列筛选,时间列不在 LEFT + JOIN 的可空一侧);一边按时间筛了、另一边哪儿都没有可能是时间窗口的条件的(见 _Usage.temporal),算多出来的 + 筛选,也说成 time。时间条件见 timed(比较、IN、BETWEEN,时间列 LIKE '2026-09%')。 - filter:一边多一个只涉及一张底层表的列、和常量比的筛选条件:另一边整条语句(WHERE、ON、HAVING、CASE / FILTER 里, - 判空、或者条件只筛掉空值时连聚合的参数)对这些列没有任何条件;这一边对这些列的条件全是另一边没有的(不是被另一边 - 也有的条件蕴含的,x > 5 之外多写的 x IS NOT NULL 不说);比的对象就是那一列(LOWER(x) <> 'PAID' 这类认不出取值 - 范围、可能恒为真);按取值逐段算过不是恒为真的(见 _Range.always,一定不为空的列不算空值,见 _never_null)。 + 聚合的参数里套在别的运算、函数里的,判空、或者条件只筛掉空值时连光一列的聚合参数)对这些列没有任何条件;这一边 + 对这些列的条件全是另一边没有的(不是被另一边也有的条件蕴含的,x > 5 之外多写的 x IS NOT NULL 不说);比的对象就是 + 那一列(LOWER(x) <> 'PAID' 这类认不出取值范围、可能恒为真);按取值逐段算过不是恒为真的(见 _Range.always,一定 + 不为空的列不算空值,见 _never_null);不用到 LEFT JOIN 可空一侧的列(反连接,见 _on_nullable_side);不是认不准 + 的时间窗口(见 _window_like)。 - aggregate / group_by:选出来的列只差在顶层的聚合函数(同一列做参数,SUM 对 AVG;COUNT(*) 对 SUM(x))或者去重上 (COUNT / SUM / AVG 去不去重,COUNT(*) 对 COUNT(DISTINCT x),SELECT DISTINCT 对不去重),分组一样。参数在组里 - 定死的不说(被 WHERE / ON 定成常量、等于分组键、本身是常量,见 _fixed_in_group);去重不起作用的不说:MIN / MAX; - 不分组的聚合只出一行;已经按选出来的列分组;只取一行;表结构里的键说明每行本来就不一样(见 _maybe_unique)。每组 - 只有一行时 SUM、AVG、MIN、MAX 都是那一行的值,也不说。 + 定死的不说(被 WHERE / ON 定成常量、等于分组键、本身是常量、它那张表的键在组里定死了,见 _fixed_in_group);去重 + 不起作用的不说:MIN / MAX;不分组的聚合只出一行;已经按选出来的列分组;只取一行;表结构里的键说明每行本来就不一样 + (见 _maybe_unique)。每组只有一行时 SUM、AVG、MIN、MAX 都是那一行的值,也不说。 - join / tables:关联的表不同(换了一张表、一边多关联了一张表)或者关联类型不同(同一个条件,LEFT JOIN 对内连接)。 LEFT JOIN 对内连接要可空一侧在别处(WHERE、HAVING、后面的连接)都没用到、结果看得见没关联上的那些行(不是只取可空 一侧跳过空值的聚合、FILTER);一边多出来的 LEFT JOIN 要那张表在 ON 之外没用到、结果怕重复的行;表结构说关联起来 - 每行正好一行(外键、键)的不说。原文写的是 RIGHT JOIN、被 WHERE 或者聚合改成内连接的,说法和原文对不上,不说。 + 每行正好一行(外键、键)的不说。原文写的是 RIGHT JOIN、被 WHERE 或者聚合改成内连接的,说法和原文对不上,不说; + 另一边原文也 LEFT JOIN 了这张表、被规范化去掉了的(见 _drop_unused_left_joins),不说「另一边没有」。 两边的 WHERE、HAVING、ON 里有可能恒为假的条件(常量条件、同一个对象上取不到值的几个条件)时什么都不说:结果都是空的, 别处的不同看不出来。结果看不见行的(只出一行、SELECT 列表里又没有一项本身就是聚合,见 _rows_visible)不说筛选、 @@ -6698,10 +6872,13 @@ class _Explainer: return self.show(conds[0], block) return self.show(exp.and_(*[c.copy() for c in conds], copy=False), block) - def named(self, table: str, column: str) -> str: - """列带中文名(有的话):「支付时间 paid_at」。""" + def named(self, table: str, column: str, *, qualified: bool = False) -> str: + """列带中文名(有的话):「支付时间 paid_at」。qualified 时写成「表.列」:两边的时间列在不同的表上(A 按 + orders.paid_at、B 按 users.created_at),或者说「另一边没有按时间筛选」的这一边关联了好几张表时,光写 created_at + 分不清是订单的下单时间还是用户的注册时间。""" label = self.label((table, column)) - return _say(label, column) if label else column + shown = f"{table}.{column}" if qualified else column + return _say(label, shown) if label else shown @staticmethod def what(rel: _Rel) -> str: @@ -6882,7 +7059,10 @@ class _Explainer: conds = [c for c in big.where if rel.alias in {col.table for col in c.find_all(exp.Column)}] skip = {self.fp(c) for c in conds} skips = (skip, set()) if side == 0 else (set(), skip) - if not self.visible or self._lossless(big, rel, conds): + # 另一边原文也 LEFT JOIN 了这张表、规范化因为结果不怕重复把它去掉了(见 _drop_unused_left_joins):不能说「另一边 + # 没有」(第 5 轮复验:COUNT(DISTINCT …) … LEFT JOIN refunds 对内连接 refunds,以前说「A 没有」关联 refunds) + dropped = small.select.meta.get(_DROPPED_JOINS) or [] + if not self.visible or rel.name in dropped or self._lossless(big, rel, conds): return False, {}, *skips shown = _say("(关联的条件是", self.show_all(conds, big), ")") if conds and \ all(self.base_columns(c, big) is not None for c in conds) else "" @@ -7039,29 +7219,66 @@ class _Explainer: # ---- WHERE def timed(self, cond: Any, block: _Block) -> tuple[str, str] | None: - """时间条件:一列(可以套着日期函数、CAST)和常量比较,列名像时间或者常量像日期。返回 (实表, 列)。""" + """时间条件:一列(可以套着日期函数、CAST)和常量比较(=、<>、<、<=、>、>=、IN、BETWEEN),列名像时间或者常量 + 像日期;或者时间列(列名像时间、套着日期函数)LIKE 一个像日期的模式(SQLite 里日期存成文字,paid_at LIKE + '2026-09%' 是取月)。返回 (实表, 列)。只认一定是时间的:date_key BETWEEN 20260901 AND 20260930 这类认不准的 + (见 _window_like)不算,调用方只给兜底。""" from sqlglot import exp - if not isinstance(cond, (exp.EQ, exp.NEQ, exp.GT, exp.GTE, exp.LT, exp.LTE, exp.In)): - return None + like = isinstance(cond, (exp.Like, exp.ILike)) and not cond.args.get("escape") if isinstance(cond, exp.In): column_side, others = cond.this, list(cond.expressions) + elif isinstance(cond, exp.Between): + if cond.args.get("symmetric"): + return None + column_side, others = cond.this, [cond.args.get("low"), cond.args.get("high")] + elif like: + column_side, others = cond.this, [cond.expression] + elif not isinstance(cond, (exp.EQ, exp.NEQ, exp.GT, exp.GTE, exp.LT, exp.LTE)): + return None elif _fixed(cond.expression): column_side, others = cond.this, [cond.expression] elif _fixed(cond.this): column_side, others = cond.expression, [cond.this] else: return None - if not others or not all(_fixed(o) for o in others): + if not others or not all(o is not None and _fixed(o) for o in others): return None cols = self.base_columns(column_side, block) if not cols or len({(r.alias, c.name) for r, c in cols}) != 1 or _value_column(column_side) is None: return None rel, col = cols[0] + if like: + # 模式得像日期(去掉两头的 %、_ 之后),对象得是时间列:status LIKE '2026%' 不是时间窗口 + if not _date_like_literal(others[0]) or not (_temporal_name(col.name) or _datey(column_side)): + return None + return rel.name, col.name if _temporal_name(col.name) or any(_datey(o) for o in others) or _datey(column_side): return rel.name, col.name return None + @staticmethod + def _left_joined(block: _Block) -> set[str]: + """这一段里 LEFT JOIN 进来的来源(可空一侧)的别名。""" + return {j.this.alias_or_name for j in block.joins if str(j.side or "").upper() == "LEFT"} + + def _on_nullable_side(self, conds: list[Any], block: _Block) -> bool: + """几个条件用到了 LEFT JOIN 可空一侧的列:可空一侧的列为空说的是「没关联上」(反连接),不为空的条件又让 LEFT + JOIN 变得像内连接——都是关联上的事,不是有效行过滤、时间窗口。认不出列的来源时也算用到了(少说)。""" + from sqlglot import exp + + left = self._left_joined(block) + if not left: + return False + for cond in conds: + for col in cond.find_all(exp.Column): + if col.is_star: + continue + cols = self.base_columns(col, block) + if not cols or cols[0][0].alias in left: + return True + return False + def _unmatched(self, xs: list[Any], ys: list[Any], mapping: Mapping[str, str]) -> tuple[list[Any], list[Any]]: rest = list(ys) only_x = [] @@ -7107,11 +7324,15 @@ class _Explainer: all_b = {t for c in B.where if (t := self.timed(c, B)) is not None} on_a = [c for c in A.where if (self.refs(c, A) or set()) & {ka}] on_b = [c for c in B.where if (self.refs(c, B) or set()) & {kb}] + # 时间列在 LEFT JOIN 的可空一侧:条件管的是关联上的那些行,说成「按它筛时间」说不准 if all_a != cols_a or all_b != cols_b or len(on_a) != len(conds_a) or len(on_b) != len(conds_b) or \ self._touched(self.usage_b, {ka}) or self._touched(self.usage_a, {kb}) or \ + self._on_nullable_side(conds_a, A) or self._on_nullable_side(conds_b, B) or \ self._always(conds_a, A) is True or self._always(conds_b, B) is True: return only_a, only_b, False - text = _say(self.la, "按", self.named(*ka), "筛时间,", self.lb, "按", self.named(*kb)) + qualified = not self.swapped and ka[0] != kb[0] + text = _say(self.la, "按", self.named(*ka, qualified=qualified), "筛时间,", self.lb, "按", + self.named(*kb, qualified=qualified)) if self._time_shape(conds_a) != self._time_shape(conds_b): text += _say("(", self.la, "是", self.show_all(_by_bound(conds_a), A), ",", self.lb, "是", self.show_all(_by_bound(conds_b), B), ")") @@ -7124,9 +7345,15 @@ class _Explainer: """block 这一边多出来的筛选条件(rest 是它没配上的 WHERE 项)。按用到的列连成一组(x > 5 和 x IS NOT NULL 是一组)一组一组看,说得准的说「A 只保留 … 的行,B 没有这个条件」「A 排除了 … 的行,B 没有」「A 按 X 筛时间, B 没有按时间筛选」,说不准的算没说到。说得准要:这一组全是另一边没有的(有一项配上了,说明另一边对这些列也有 - 条件,多出来的可能被它蕴含);只涉及一张实表的列;另一边整条语句对这些列没有任何条件(见 _Usage);不是时间 - 条件的,比的对象就是那几列本身(LOWER(x)、LENGTH(x) 的取值范围认不出,x <> 'PAID' 可能恒为真);按取值逐段算 - 过不是恒为真的(见 _always)。""" + 条件,多出来的可能被它蕴含);只涉及一张实表的列;另一边整条语句对这些列没有任何条件(见 _Usage:写在聚合的参数 + 里的 SUM(status = 'paid')、SUM(amount * (1 - is_deleted)) 也算有);不是时间条件的,比的对象就是那几列本身 + (LOWER(x)、LENGTH(x) 的取值范围认不出,x <> 'PAID' 可能恒为真);按取值逐段算过不是恒为真的(见 _always)。 + + 这几种不说(第 5 轮复验抓到的):用到 LEFT JOIN 可空一侧的列的(WHERE r.id IS NULL 是反连接,说的是「没关联 + 上」;r.order_id IS NULL 和 ON 里的 r.order_id = o.id 一起已经蕴含 r.id IS NULL,说「另一边没有这个条件」把留得 + 多的一边说成了严的一边);可能是时间窗口、又认不准是哪一列的时间的(date_key BETWEEN 20260901 AND 20260930、 + stat_month = 202609,见 _window_like:说成有效行过滤会被写进数据目录);说「另一边没有按时间筛选」时另一边可能 + 按时间筛了(见 _Usage.temporal)。""" usage = self.usage_b if side == 0 else self.usage_a me, them = (self.la, self.lb) if side == 0 else (self.lb, self.la) covered = True @@ -7135,11 +7362,16 @@ class _Explainer: group = self._linked(pending[0], where, block) pending = [c for c in pending if all(c is not g for g in group)] refs = [self.refs(g, block) for g in group] - if any(all(g is not r for r in rest) for g in group) or any(r is None for r in refs) or usage is None: + if any(all(g is not r for r in rest) for g in group) or any(r is None for r in refs) or usage is None or \ + self._on_nullable_side(group, block): covered = False continue cols = set().union(*refs) times = {t for g in group if (t := self.timed(g, block)) is not None} + if not times and any(_window_like(g) for g in group): + # 可能是时间窗口,又认不准是不是时间列:既不说成有效行过滤,也不说成按它筛时间 + covered = False + continue always = self._always(group, block) kinds = _kinds_for(group) if "agg" not in kinds and self._always(group, block, assume_values=True) is True: @@ -7154,8 +7386,9 @@ class _Explainer: columns = {"columns": [{"table": t, "column": c} for t, c in sorted(cols)]} if times: (key,) = times - text = _say(me, "按", self.named(*key), "筛时间,", them, "没有按时间筛选") if side == 0 else \ - _say(them, "没有按时间筛选,", me, "按", self.named(*key), "筛时间") + shown_key = self.named(*key, qualified=not self.swapped and len(block.all_rels()) > 1) + text = _say(me, "按", shown_key, "筛时间,", them, "没有按时间筛选") if side == 0 else \ + _say(them, "没有按时间筛选,", me, "按", shown_key, "筛时间") self.add("time", key[1] if side == 0 else None, key[1] if side == 1 else None, text, {"a" if side == 0 else "b": [{"table": key[0], "column": key[1]}], "b" if side == 0 else "a": []}) @@ -7352,12 +7585,18 @@ class _Explainer: def _fixed_in_group(self, block: _Block, col: Any) -> bool: """col 在每一组里是不是只有一个值:被 WHERE、ON 里的「列 = 常量」定死,或者等于某个分组键(顺着 WHERE、ON 里 - 两列相等传过去)。认不出是哪一列的也当成定死了(少说)。""" + 两列相等传过去),或者它那张表的某个键(主键、唯一约束)在组里定死了(同一组里这张表就只有一行的值:按 u.id 分组 + 时 u.city 只有一个值,LEFT JOIN、内连接把行复制了也一样,MAX(u.city) 和 MIN(u.city)、AVG(DISTINCT o.amount) 和 + AVG(o.amount) 都一样——第 5 轮复验抓到以前照说不误)。认不出是哪一列的也当成定死了(少说)。 + + 表结构里的键只拿来少说:类型亲和、COLLATE 让「唯一」不成立时,这里多当成定死了,只会少说一句,不会说错。""" from sqlglot import exp key = self._col_key(col, block) fixed = self._pinned(block) | {self._col_key(_unparen(g), block) for g in block.group if isinstance(_unparen(g), exp.Column)} + keys = {r.alias: self._keys(r) for r in block.all_rels() if r.table} + whole: set[str] = set() changed = True while changed: changed = False @@ -7367,10 +7606,15 @@ class _Explainer: isinstance(_unparen(c.expression), exp.Column): left, right = self._col_key(_unparen(c.this), block), self._col_key(_unparen(c.expression), block) for x, y in ((left, right), (right, left)): - if x in fixed and y is not None and y not in fixed: + if x is not None and (x in fixed or x[0] in whole) and y is not None and y not in fixed: fixed.add(y) changed = True - return key is None or key in fixed + for alias, groups in keys.items(): + if alias not in whole and any(key_cols and all((alias, k) in fixed for k in key_cols) + for key_cols in groups): + whole.add(alias) + changed = True + return key is None or key in fixed or key[0] in whole # ---- 表结构里的键:只拿来少说 @@ -7539,7 +7783,8 @@ def explain_sql_diff(a: str, b: str, dialect: str | None = None, *, schema: Mapp 只做保持语义的变换;同一级的两个规范形式完全一样(比语法树,见 _Form.key)才算等价,否则一律不等价,items 一定 不为空。schema 是数据源的 schema_cache,给了就按它认列、展开 SELECT *(表结构要和库对得上);里面的约束(主键、 唯一约束、外键、NOT NULL)不拿来判等价,只拿来让说明少说。原文里有 sqlglot 会读错、会丢掉的写法的(MySQL 字符串 - 里的反斜杠转义、1_000、没加括号地混用的比较和按位运算、XOR、一元加号、0x10、换了名字的类型名……,见 + 里的反斜杠转义、1_000、没加括号地混用的比较和按位运算、PostgreSQL 写成符号的 ~ / ~~ / @@ 和 || 混用、前缀的 @、XOR、 + 一元加号、0x10、换了名字的类型名,不知道是哪种库时套着的块注释、紧跟着字的 --、双引号括起来的名字……,见 _lost_in_parsing、_misread)不在语法树上判等价,只给兜底和原文的逐行对照。 不等价时怎么说:两边的规范形式都是简单形状时(一段 SELECT、实表、普通的连接,见 _simple_block),只把四类有把握、 @@ -7549,7 +7794,8 @@ def explain_sql_diff(a: str, b: str, dialect: str | None = None, *, schema: Mapp 的行(没有规范形式可比时给原文的逐行对照)。 labels 是两边的叫法(缺省 A / B,双盲复算传「原流程」「盲算」);names 是列的中文名({「表.列」或「列」: 中文名}, - 可以用 catalog_labels 从数据目录取),时间列写成「支付时间 paid_at」。任一边解析不了:只给一条 aspect="parse"。 + 可以用 catalog_labels 从数据目录取),时间列写成「支付时间 paid_at」(两边的时间列在不同的表上时写「表.列」)。任一边 + 解析不了:只给一条 aspect="parse"。 """ la, lb = labels read = _read(dialect) diff --git a/backend/tests/fixtures/sql_pairs.py b/backend/tests/fixtures/sql_pairs.py index 04ec2088..68a4e3d2 100644 --- a/backend/tests/fixtures/sql_pairs.py +++ b/backend/tests/fixtures/sql_pairs.py @@ -16,8 +16,9 @@ OR 里的标量子查询改成 CROSS JOIN,都是复验时真踩到的),所 DIALECT_SAME_R2、KNOWN_UNRECOGNIZED(各方言,只查结论)里;第 3 轮的在 _DIFF_R3、_SAME_R3、_SAME_TYPED_R3(要给表结构才认得出,Pair.typed)、_SAME_SCHEMA_R3(要靠 表结构里的主键才看得出等价,Pair.needs_schema)、_SAME_HARD_R3(执行)和 REGRESSIONS_R3、DIALECT_SAME_R3(只查 结论)里;第 4 轮的在 _DIFF_R4、_SAME_HARD_R4、_SAME_SCHEMA_R4(执行)和 REGRESSIONS_R4、DIALECT_SAME_R4、 -CONSTRAINT_TRAPS_R4(只查结论,靠约束改写的陷阱另造一个小库执行)里。规范化不再靠约束改写,needs_schema 的对一律 -不判等价,给了表结构时只许给兜底。LIMIT 都带能定出唯一顺序的排序,没有 ORDER BY 的 LIMIT 不进语料(EXISTS 里的 LIMIT 1 OFFSET 1 只看有没有 +CONSTRAINT_TRAPS_R4(只查结论,靠约束改写的陷阱另造一个小库执行)里;第 5 轮的在 _DIFF_R5、_DIFF_TIME_R5、 +_SAME_HARD_R5、_SAME_SCHEMA_R5(执行)和 REGRESSIONS_R5、EXPLAIN_TRAPS_R5(只查结论)里。规范化不再靠约束改写, +needs_schema 的对一律不判等价,给了表结构时只许给兜底。LIMIT 都带能定出唯一顺序的排序,没有 ORDER BY 的 LIMIT 不进语料(EXISTS 里的 LIMIT 1 OFFSET 1 只看有没有 第二行,不看先后)。过去复验报过的反例都收进来了:第 5 轮回退的「只在 IN 子查询里用到的 CTE」在 PAIRS 里;复验者留在 /private/tmp 的脚本里、在这几组数据上 结果不同的对是 VALIDATOR_DIFF(也进 PAIRS 执行);旧测试(五轮复核修正按复验报告补的用例,各方言,用到的列这个库里 不全有)里报过不同的对是 REGRESSIONS、意思一样的对是 DIALECT_SAME,这两份不执行,只查判没判等价。 @@ -3002,6 +3003,205 @@ def coupon_schema(dialect: str) -> dict: return _coupon_schema(mysql=dialect == "mysql") +# ---------------------------------------------------------------------------- 第 5 轮复验的反例 +# 收尾第 1 轮之后的复验(回归、漏报、错报三位复验者)报的全部 blocker / major,外加自己在真实的 PostgreSQL 18.6 上 +# 差分压测(不加括号的运算符混用对 sqlglot 读出来的树每个操作数都加括号)找到的同一类写法。复验者的 channel 列改写成 +# 这里的 region,customers / invoices 改写成 users / orders;数仓的 sales_fact 这个库里没有,只查结论(见 +# EXPLAIN_TRAPS_R5)。 + +#: 不许判等价、也不许给具体说法的(SQLite 上执行):两条确实不同,以前却说错了不同在哪—— +#: - 条件写在聚合的参数里(SUM(status = 'paid')、COUNT(… OR NULL)、SUM(… IN (…))、COUNT(NULLIF(…))、 +#: SUM(amount * (status = 'paid'))、SUM(amount * (1 - is_deleted))):这一边也只数、只加满足条件的行,说「这一边没有 +#: 这个条件」等于说它把所有行都算进去了(真正的不同是另一边没有计数为 0 的组、空集时是 NULL 还是 0); +#: - 时间条件写在聚合的参数里(SUM(paid_at >= … AND paid_at < …)):这一边是按 paid_at 归月的,不能说「没有按时间 +#: 筛选」; +#: - 反连接(LEFT JOIN … WHERE 可空一侧的列 IS NULL):说的是「没关联上」,不是有效行过滤; +#: - 规范化因为结果不怕重复去掉了的 LEFT JOIN:原文明明写了 LEFT JOIN refunds,不能说「这一边没有关联 refunds 表」。 +_DIFF_R5 = [ + ("r5-sum-of-condition", "SELECT region, SUM(status = 'paid') FROM orders GROUP BY region", + "SELECT region, COUNT(*) FROM orders WHERE status = 'paid' GROUP BY region"), + ("r5-count-condition-or-null", "SELECT region, COUNT(status = 'paid' OR NULL) FROM orders GROUP BY region", + "SELECT region, COUNT(*) FROM orders WHERE status = 'paid' GROUP BY region"), + ("r5-sum-of-in-list", "SELECT region, SUM(status IN ('paid', 'shipped')) FROM orders GROUP BY region", + "SELECT region, COUNT(*) FROM orders WHERE status IN ('paid', 'shipped') GROUP BY region"), + ("r5-count-nullif", "SELECT region, COUNT(NULLIF(status, 'void')) FROM orders GROUP BY region", + "SELECT region, COUNT(*) FROM orders WHERE status <> 'void' GROUP BY region"), + ("r5-sum-times-condition", "SELECT SUM(amount * (status = 'paid')) FROM orders", + "SELECT SUM(amount) FROM orders WHERE status = 'paid'"), + ("r5-sum-times-flag", "SELECT region, SUM(amount * (1 - is_deleted)) FROM orders GROUP BY region", + "SELECT region, SUM(amount) FROM orders WHERE is_deleted = 0 GROUP BY region"), + ("r5-sum-times-flag-ungrouped", "SELECT SUM(amount * (1 - is_deleted)) FROM orders", + "SELECT SUM(amount) FROM orders WHERE is_deleted = 0"), + ("r5-time-window-inside-sum", "SELECT region, SUM(paid_at >= '2026-09-01' AND paid_at < '2026-10-01') FROM orders " + "GROUP BY region", + "SELECT region, COUNT(*) FROM orders WHERE created_at >= '2026-09-01' AND created_at < '2026-10-01' " + "GROUP BY region"), + ("r5-time-lower-bound-inside-sum", "SELECT SUM(paid_at >= '2026-09-01') FROM orders", + "SELECT COUNT(*) FROM orders WHERE paid_at >= '2026-09-01'"), + ("r5-anti-join-vs-inner-join", "SELECT COUNT(*) FROM orders o LEFT JOIN payments p ON p.order_id = o.id " + "WHERE p.id IS NULL", + "SELECT COUNT(*) FROM orders o JOIN payments p ON p.order_id = o.id"), + ("r5-anti-join-key-vs-nullable-column", "SELECT u.id, u.name FROM users u LEFT JOIN orders o ON o.user_id = u.id " + "WHERE o.id IS NULL", + "SELECT u.id, u.name FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE o.created_at IS NULL"), + ("r5-dropped-left-join-vs-inner-join", "SELECT COUNT(DISTINCT o.user_id) FROM orders o LEFT JOIN refunds r " + "ON r.order_id = o.id", + "SELECT COUNT(DISTINCT o.user_id) FROM orders o JOIN refunds r ON r.order_id = o.id"), +] + +#: 时间列上的日期范围(BETWEEN、LIKE '2026-09%')是统计窗口,不是有效行过滤(SQLite 上执行,不许判等价):以前在结果 +#: 看行先后的一段里(按日期表达式分组、取前 N 行)BETWEEN 不拆,被说成两条「只保留 … 的行」,时间列这一事实丢了; +#: LIKE 取月也被说成有效行过滤。现在说成 time,或者只给兜底,不许说成 filter。每一项 (编号, A, B, A 的时间列, B 的 +#: 时间列):SQLite 里说成 time 时 detail 里就是这两列(没有按时间筛选的一边是空列表) +_DIFF_TIME_R5 = [ + ("r5-between-on-two-time-columns-grouped-by-date", + "SELECT date(paid_at) AS d, SUM(amount) FROM orders WHERE paid_at BETWEEN '2026-09-01' AND '2026-09-30 23:59:59' " + "GROUP BY 1", + "SELECT date(paid_at) AS d, SUM(amount) FROM orders " + "WHERE created_at BETWEEN '2026-09-01' AND '2026-09-30 23:59:59' GROUP BY 1", ["paid_at"], ["created_at"]), + ("r5-between-on-two-time-columns-top-n", + "SELECT region, SUM(amount) AS revenue FROM orders WHERE paid_at BETWEEN '2026-09-01' AND '2026-09-30 23:59:59' " + "GROUP BY region ORDER BY revenue DESC, region LIMIT 3", + "SELECT region, SUM(amount) AS revenue FROM orders " + "WHERE created_at BETWEEN '2026-09-01' AND '2026-09-30 23:59:59' " + "GROUP BY region ORDER BY revenue DESC, region LIMIT 3", ["paid_at"], ["created_at"]), + ("r5-between-window-on-one-side-grouped-by-date", + "SELECT date(paid_at) AS d, SUM(amount) FROM orders WHERE status = 'paid' GROUP BY 1", + "SELECT date(paid_at) AS d, SUM(amount) FROM orders WHERE status = 'paid' " + "AND paid_at BETWEEN '2026-09-01' AND '2026-09-30 23:59:59' GROUP BY 1", [], ["paid_at"]), + ("r5-like-month-vs-no-window", "SELECT COUNT(*) FROM orders", + "SELECT COUNT(*) FROM orders WHERE paid_at LIKE '2026-09%'", [], ["paid_at"]), + ("r5-range-vs-like-month-on-another-column", + "SELECT COUNT(*) FROM orders WHERE paid_at >= '2026-09-01' AND paid_at < '2026-10-01'", + "SELECT COUNT(*) FROM orders WHERE created_at LIKE '2026-09%'", ["paid_at"], ["created_at"]), +] + +#: 意思一样、要按语义推理才看得出的(SQLite 上执行):可空一侧的关联键 u.id 为空就是没关联上,整行都是空值,多写的 +#: u.name IS NULL 被 u.id IS NULL 蕴含——不靠约束。以前说「B 只保留 users.name IS NULL 的行,A 没有这个条件」 +_SAME_HARD_R5 = [ + ("r5-anti-join-extra-null-test", + "SELECT o.id FROM orders o LEFT JOIN users u ON u.id = o.user_id WHERE u.id IS NULL", + "SELECT o.id FROM orders o LEFT JOIN users u ON u.id = o.user_id WHERE u.id IS NULL AND u.name IS NULL"), +] + +#: 意思一样、要靠表结构里的主键才看得出的(SQLite 上执行,needs_schema):给了表结构时只许给兜底。 +#: - 两种反连接的写法:关联键 r.order_id 为空就是没关联上,蕴含 r.id 为空;r.id 是主键(不为空)时反过来也成立。没给 +#: 表结构时也不许说具体的:r.id IS NULL 那一边留下的行更多(关联上了、r.id 却为空的行),说它「只保留 … 的行、另一边 +#: 没有这个条件」把留得多的一边说成了严的一边(见 test_anti_joins_are_never_called_a_filter); +#: - 按主键分组(或者经关联条件等于主键):同一组里这张表的列只有一个值,LEFT JOIN、内连接把行复制了也一样,MAX 和 +#: MIN 一样、AVG(DISTINCT) 和 AVG 一样。没给表结构时说「A 取最大值,B 取最小值」按已知信息成立 +_SAME_SCHEMA_R5 = [ + ("r5-anti-join-key-vs-join-column", "SELECT COUNT(*) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id " + "WHERE r.id IS NULL", + "SELECT COUNT(*) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id WHERE r.order_id IS NULL"), + ("r5-max-vs-min-of-a-column-the-group-key-determines", "SELECT u.id, MAX(u.city), COUNT(o.id) FROM users u " + "LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id", + "SELECT u.id, MIN(u.city), COUNT(o.id) FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id"), + ("r5-avg-distinct-of-a-column-the-group-key-determines", "SELECT o.id, AVG(DISTINCT o.amount), COUNT(r.id) " + "FROM orders o LEFT JOIN refunds r ON r.order_id = o.id GROUP BY o.id", + "SELECT o.id, AVG(o.amount), COUNT(r.id) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id GROUP BY o.id"), + ("r5-max-vs-min-through-the-join-key-top-n", "SELECT o.user_id, MAX(u.name) AS name, SUM(o.amount) AS total " + "FROM orders o JOIN users u ON u.id = o.user_id WHERE o.status = 'paid' GROUP BY o.user_id " + "ORDER BY total DESC, o.user_id LIMIT 5", + "SELECT o.user_id, MIN(u.name) AS name, SUM(o.amount) AS total FROM orders o JOIN users u ON u.id = o.user_id " + "WHERE o.status = 'paid' GROUP BY o.user_id ORDER BY total DESC, o.user_id LIMIT 5"), +] + +#: 只在 PostgreSQL 才有的写法(不执行,只查不判等价、只给兜底、附原文的逐行对照):复验者和自己在真实的 PostgreSQL +#: 18.6 上跑出结果不同。PostgreSQL 里 ~、~*、~~、~~*、@@ 这些写成符号的匹配运算符和 ||、&、# 同级(「别的运算符」), +#: 从左往右结合:status ~ 'p' || 'x' 是 (status ~ 'p') || 'x',得到 'truex' 这样的文字;sqlglot 把它们放在比 || 松的 +#: 一级,读成 status ~ ('p' || 'x')。前缀的 @ 是取绝对值、和 - 20 一起是 @ (amount - 20),sqlglot 读成参数 $amount; +#: |/ 是双精度的平方根,sqlglot 写成 SQRT(numeric 的 SQRT 多出几位小数);x COLLATE "C"::text 的 :: 套在整个 COLLATE +#: 上,sqlglot 读成套在排序规则的名字上。不知道是哪种库时:块注释里再写 /*(PostgreSQL 嵌套、SQLite / MySQL 不嵌套)、 +#: 后面紧跟着字的 --(MySQL 里是两个减号)各库读法不同。每一项 (编号, 方言, A, B),方言为空串是不知道 +REGRESSIONS_R5: list[tuple[str, str, str, str]] = [ + ("pg_regex_match_then_concat", "postgres", "SELECT id, status ~ 'p' || 'x' FROM orders", + "SELECT id, status ~ ('p' || 'x') FROM orders"), + ("pg_regex_imatch_then_concat_grouped", "postgres", + "SELECT status ~* '^PA' || '' AS f, COUNT(*) FROM orders GROUP BY 1", + "SELECT status ~* ('^PA' || '') AS f, COUNT(*) FROM orders GROUP BY 1"), + ("pg_regex_match_then_concat_in_cte", "postgres", + "WITH t AS (SELECT id, status ~ '^p' || '' AS f FROM orders) SELECT f, COUNT(*) FROM t GROUP BY f", + "WITH t AS (SELECT id, status ~ ('^p' || '') AS f FROM orders) SELECT f, COUNT(*) FROM t GROUP BY f"), + ("pg_regex_match_then_concat_with_a_time_filter", "postgres", + "SELECT id, status ~ 'p' || 'x' FROM orders WHERE created_at >= '2026-09-01'", + "SELECT id, status ~ ('p' || 'x') FROM orders"), + ("pg_like_operator_then_concat", "postgres", "SELECT id, status ~~ 'p%' || 'x' FROM orders", + "SELECT id, status ~~ ('p%' || 'x') FROM orders"), + ("pg_ilike_operator_then_concat", "postgres", "SELECT id, status ~~* 'P%' || 'x' FROM orders", + "SELECT id, status ~~* ('P%' || 'x') FROM orders"), + ("pg_text_search_then_concat", "postgres", "SELECT id, to_tsvector(status) @@ 'paid' || '' FROM orders", + "SELECT id, to_tsvector(status) @@ ('paid' || '') FROM orders"), + ("pg_absolute_value_prefix", "postgres", "SELECT id, @ amount - 20 FROM orders", + "SELECT id, (@ amount) - 20 FROM orders"), + ("pg_square_root_prefix", "postgres", "SELECT id, |/ amount FROM orders", "SELECT id, SQRT(amount) FROM orders"), + ("pg_collate_then_cast", "postgres", "SELECT id, status COLLATE \"C\"::text FROM orders", + "SELECT id, status COLLATE (\"C\"::text) FROM orders"), + # 嵌套着读是一整段注释(SELECT id);不嵌套着读,/* a /* b */ 到第一个 */ 就完了,+ 1 是代码,-- */ 是行注释 + ("unknown_dialect_nested_block_comment", "", "SELECT id /* a /* b */ + 1 -- */\nFROM orders", + "SELECT id\nFROM orders"), + # sqlglot 当成注释吃掉 --1;MySQL 里 amount--1 是 amount - (-1) + ("unknown_dialect_dashes_without_space", "", "SELECT id, amount--1\nFROM orders", "SELECT id, amount\nFROM orders"), + # sqlglot 的通用读法(和 PostgreSQL、SQLite 一样)把 "status" 读成列名;MySQL 默认的 sql_mode 里双引号括起来的是 + # 字符串,"status" = 'paid' 恒为假、SELECT "status" 每行都是 'status'(自查时发现,规格外,按最保守的做法拦下) + ("unknown_dialect_double_quoted_name", "", "SELECT COUNT(*) FROM orders WHERE \"status\" = 'paid'", + "SELECT COUNT(*) FROM orders WHERE status = 'paid'"), + ("unknown_dialect_double_quoted_select", "", "SELECT \"status\" FROM orders", "SELECT status FROM orders"), +] + +#: 第 5 轮:要专门的表结构才看得出的(不执行,只查给的都是兜底)。每一项 (编号, 方言, A, B, 表结构, 没给表结构时也查): +#: - 数仓事实表的业务日期键(date_key BETWEEN 20260901 AND 20260930、stat_month = 202609):认不出是不是时间列时, +#: 不许说另一边「没有按时间筛选」,也不许把它说成有效行过滤; +#: - 反连接的两种写法:关联上的行里主键、NOT NULL 的列不为空,两种写法都是「没关联上」; +#: - 经关联条件等于分组键、又是那张表的主键:同一组里那张表的列只有一个值,MAX 和 MIN 一样(没给表结构时这么说按已知 +#: 信息成立,不查)。 +_SALES_FACT = {"tables": {"sales_fact": {"columns": [ + {"name": "id", "type": "INTEGER", "nullable": False}, {"name": "date_key", "type": "INTEGER", "nullable": True}, + {"name": "stat_month", "type": "INTEGER", "nullable": True}, {"name": "amount", "type": "REAL", "nullable": True}, + {"name": "created_at", "type": "TEXT", "nullable": True}], "primary_key": ["id"]}}} +_INVOICES = {"tables": { + "customers": {"columns": [{"name": "cid", "type": "INTEGER", "nullable": False}, + {"name": "cname", "type": "TEXT", "nullable": True}, + {"name": "region", "type": "TEXT", "nullable": True}], "primary_key": ["cid"]}, + "invoices": {"columns": [{"name": "iid", "type": "INTEGER", "nullable": False}, + {"name": "cid", "type": "INTEGER", "nullable": True}, + {"name": "issued_at", "type": "TEXT", "nullable": False}, + {"name": "amount", "type": "REAL", "nullable": True}, + {"name": "state", "type": "TEXT", "nullable": True}], "primary_key": ["iid"]}}} +_SALES_FACT_SEPTEMBER = ("SELECT SUM(amount) FROM sales_fact " + "WHERE created_at >= '2026-09-01' AND created_at < '2026-10-01'") +EXPLAIN_TRAPS_R5: list[tuple[str, str, str, str, dict, bool]] = [ + ("date_key_between_vs_created_at_range", "sqlite", + "SELECT SUM(amount) FROM sales_fact WHERE date_key BETWEEN 20260901 AND 20260930", + _SALES_FACT_SEPTEMBER, _SALES_FACT, True), + ("date_key_between_vs_created_at_range", "postgres", + "SELECT SUM(amount) FROM sales_fact WHERE date_key BETWEEN 20260901 AND 20260930", + _SALES_FACT_SEPTEMBER, _SALES_FACT, True), + ("date_key_between_vs_created_at_range", "", + "SELECT SUM(amount) FROM sales_fact WHERE date_key BETWEEN 20260901 AND 20260930", + _SALES_FACT_SEPTEMBER, _SALES_FACT, True), + ("stat_month_vs_created_at_range", "sqlite", "SELECT SUM(amount) FROM sales_fact WHERE stat_month = 202609", + _SALES_FACT_SEPTEMBER, _SALES_FACT, True), + ("anti_join_primary_key_vs_not_null_column", "sqlite", + "SELECT c.cid, c.cname FROM customers c LEFT JOIN invoices i ON i.cid = c.cid WHERE i.iid IS NULL", + "SELECT c.cid, c.cname FROM customers c LEFT JOIN invoices i ON i.cid = c.cid WHERE i.issued_at IS NULL", + _INVOICES, True), + ("anti_join_join_column_vs_primary_key", "postgres", + "SELECT c.cid FROM customers c LEFT JOIN invoices i ON i.cid = c.cid WHERE i.cid IS NULL", + "SELECT c.cid FROM customers c LEFT JOIN invoices i ON i.cid = c.cid WHERE i.iid IS NULL", _INVOICES, True), + ("max_vs_min_through_the_join_key_top_n", "mysql", + "SELECT i.cid, MAX(c.cname) AS name, SUM(i.amount) AS total FROM invoices i JOIN customers c ON c.cid = i.cid " + "WHERE i.state = 'paid' GROUP BY i.cid ORDER BY total DESC, i.cid LIMIT 5", + "SELECT i.cid, MIN(c.cname) AS name, SUM(i.amount) AS total FROM invoices i JOIN customers c ON c.cid = i.cid " + "WHERE i.state = 'paid' GROUP BY i.cid ORDER BY total DESC, i.cid LIMIT 5", _INVOICES, False), + ("max_vs_min_of_a_column_the_group_key_determines", "sqlite", + "SELECT c.cid, MAX(c.region) AS region, COUNT(i.iid) AS n FROM customers c LEFT JOIN invoices i ON i.cid = c.cid " + "GROUP BY c.cid", + "SELECT c.cid, MIN(c.region) AS region, COUNT(i.iid) AS n FROM customers c LEFT JOIN invoices i ON i.cid = c.cid " + "GROUP BY c.cid", _INVOICES, False), +] + + def build_coupon_database() -> sqlite3.Connection: """CONSTRAINT_TRAPS_R4 的小库(SQLite,内存):TEXT 主键里同时有 '1' 和 '01'、'paid' 和 'Paid',部分唯一索引下同一个 code 有一行已删、一行未删。""" @@ -3028,11 +3228,19 @@ PAIRS: list[Pair] = [Pair(f"same:{i}", a, b, "same") for i, a, b in _SAME] + \ [Pair(f"hard:{i}", a, b, "same") for i, a, b in _SAME_HARD_R3] + \ [Pair(f"diff:{i}", a, b, "diff") for i, a, b in _DIFF_R4] + \ [Pair(f"hard:{i}", a, b, "same") for i, a, b in _SAME_HARD_R4] + \ - [Pair(f"hard:{i}", a, b, "same", needs_schema=True) for i, a, b in _SAME_SCHEMA_R4] + [Pair(f"hard:{i}", a, b, "same", needs_schema=True) for i, a, b in _SAME_SCHEMA_R4] + \ + [Pair(f"diff:{i}", a, b, "diff") for i, a, b in _DIFF_R5] + \ + [Pair(f"diff:{i}", a, b, "diff") for i, a, b, _, _ in _DIFF_TIME_R5] + \ + [Pair(f"hard:{i}", a, b, "same") for i, a, b in _SAME_HARD_R5] + \ + [Pair(f"hard:{i}", a, b, "same", needs_schema=True) for i, a, b in _SAME_SCHEMA_R5] #: 第 4 轮复验报的、在 SQLite 上执行的那几对的编号(测试里按类别引用) ROUND_FOUR_DIFF = tuple(f"diff:{i}" for i, _, _ in _DIFF_R4) ROUND_FOUR_SAME = tuple(f"hard:{i}" for i, _, _ in _SAME_HARD_R4 + _SAME_SCHEMA_R4) +#: 第 5 轮:真不同、只许给兜底的;时间窗口(只许说成 time 或者只给兜底)和各自的时间列;意思一样的 +ROUND_FIVE_DIFF = tuple(f"diff:{i}" for i, _, _ in _DIFF_R5) +ROUND_FIVE_TIME = {f"diff:{i}": (ta, tb) for i, _, _, ta, tb in _DIFF_TIME_R5} +ROUND_FIVE_SAME = tuple(f"hard:{i}" for i, _, _ in _SAME_HARD_R5 + _SAME_SCHEMA_R5) #: 按编号找语料里的一对(测试里引用第 2 轮的反例用) PAIRS_BY_ID: dict[str, Pair] = {p.id: p for p in PAIRS} diff --git a/backend/tests/test_sql_equivalence.py b/backend/tests/test_sql_equivalence.py index 6f6c3620..dbb2a726 100644 --- a/backend/tests/test_sql_equivalence.py +++ b/backend/tests/test_sql_equivalence.py @@ -20,6 +20,11 @@ schema_cache)两种情况下各判一次,两种都要守住上面的规矩 MySQL 字符串里的反斜杠转义、数字写法)一律不判等价、只给兜底;靠约束改写的陷阱(TEXT 主键对数值列、COLLATE、部分唯一 索引、MySQL 的隐式转换)在现造的小库上执行、一律不判等价;在 SQLite 上执行的那几对再拿 PostgreSQL / MySQL 的读法 各判一遍,意思一样的只许给兜底。 + +第 5 轮的反例同样分两种:PostgreSQL 写成符号的 ~ / ~* / ~~ / ~~* / @@ 和 || 混用、前缀的 @、|/、COLLATE 后面跟 ::, +不知道是哪种库时套着的块注释、紧跟着字的 --、双引号括起来的名字,一律不判等价、只给兜底;具体说法说错了的(条件写在聚合里被说成「另一边 +没有这个条件」、时间窗口被说成有效行过滤或者「没有按时间筛选」、反连接被说成有效行过滤、按主键定下来的列说 MAX 对 +MIN、被规范化去掉的 LEFT JOIN 说成「另一边没有」)在 SQLite 上执行,再拿三种方言各判一遍。 """ from __future__ import annotations @@ -228,3 +233,133 @@ def test_sqlite_sql_the_parser_reads_wrongly_gets_only_the_fallback(databases, p _check_shape(diff, pair.a, pair.b) assert not diff.equivalent and _only_fallback(diff), (pid, diff.items) assert diff.text_diff == (f"- {pair.a}", f"+ {pair.b}") + + +# -------------------------------------------------------------------------- +# 第 5 轮复验的反例(收尾第 1 轮之后) +# -------------------------------------------------------------------------- + + +def _raw_line_diff(diff, x: str, y: str) -> bool: + """text_diff 是原文的逐行对照:「- 」开头的是 x 的原文行,「+ 」开头的是 y 的原文行。""" + lines_x = {line.rstrip() for line in x.strip().splitlines()} + lines_y = {line.rstrip() for line in y.strip().splitlines()} + return bool(diff.text_diff) and all( + (line[2:] in lines_x) if line.startswith("- ") else (line.startswith("+ ") and line[2:] in lines_y) + for line in diff.text_diff) + + +@pytest.mark.parametrize("name,dialect,a,b", sql_pairs.REGRESSIONS_R5, + ids=[f"{n}-{d or 'unknown'}" for n, d, _, _ in sql_pairs.REGRESSIONS_R5]) +def test_round_five_parser_misreads_are_never_equivalent_and_get_only_the_fallback(name, dialect, a, b): + """第 5 轮 blocker(PostgreSQL):~、~*、~~、~~*、@@ 这些写成符号的匹配运算符和 || 同级、从左往右结合,sqlglot 让 || + 先结合,status ~ 'p' || 'x' 和 status ~ ('p' || 'x') 读成了同一棵树(真实的 PostgreSQL 上一个是 'truex'、一个是 + false);自己差分压测又找到前缀的 @(取绝对值,sqlglot 读成参数)、|/(双精度的平方根,sqlglot 写成 SQRT)、 + COLLATE 后面跟 ::。不知道是哪种库时,块注释里再写 /*、紧跟着字的 --、双引号括起来的名字(MySQL 默认的 sql_mode 里 + 是字符串)各库读法不同。原文里有这些写法就不在语法树上判等价、也不给具体说法,只给兜底,附原文的逐行对照。""" + for schema in (None, SCHEMA): + for x, y in ((a, b), (b, a)): + diff = explain_sql_diff(x, y, dialect or None, schema=schema) + _check_shape(diff, x, y) + assert not diff.equivalent, f"{name} 不该判等价(schema={bool(schema)})" + assert _only_fallback(diff), (name, diff.items) + assert _raw_line_diff(diff, x, y), (name, diff.text_diff) + + +@pytest.mark.parametrize("pid", [p for p in sql_pairs.ROUND_FIVE_DIFF + if p != "diff:r5-dropped-left-join-vs-inner-join"]) +@pytest.mark.parametrize("dialect", ("sqlite", "postgres", "mysql")) +def test_round_five_differences_are_not_explained_with_the_wrong_difference(pid, dialect): + """第 5 轮 major:两条确实不同,以前却说错了不同在哪——条件、时间窗口写在聚合的参数里(SUM(status = 'paid')、 + SUM(amount * (1 - is_deleted))、SUM(paid_at >= …))被说成「这一边没有这个条件」「没有按时间筛选」;反连接(LEFT + JOIN … WHERE 可空一侧 IS NULL)被说成有效行过滤。这几处现在只给兜底。""" + pair = sql_pairs.PAIRS_BY_ID[pid] + for schema in (None, SCHEMA): + for x, y in ((pair.a, pair.b), (pair.b, pair.a)): + diff = explain_sql_diff(x, y, dialect, schema=schema) + _check_shape(diff, x, y) + assert not diff.equivalent and _only_fallback(diff), (pid, dialect, bool(schema), diff.items) + + +@pytest.mark.parametrize("dialect", ("sqlite", "postgres", "mysql")) +def test_a_left_join_the_normalization_dropped_is_not_called_missing(dialect): + """第 5 轮 minor:COUNT(DISTINCT …) 不怕重复,规范化去掉了没用到的 LEFT JOIN refunds;另一边内连接 refunds 时,以前 + 说「B 关联了 refunds 表,A 没有」,可 A 的原文明明写了 LEFT JOIN refunds。不许说「没有」(PostgreSQL、MySQL 没给表 + 结构时不去掉这次 LEFT JOIN,「A 用 LEFT JOIN 关联 refunds 表,B 用 INNER JOIN」和原文对得上,可以说)。""" + pair = sql_pairs.PAIRS_BY_ID["diff:r5-dropped-left-join-vs-inner-join"] + for schema in (None, SCHEMA): + for x, y in ((pair.a, pair.b), (pair.b, pair.a)): + diff = explain_sql_diff(x, y, dialect, schema=schema) + _check_shape(diff, x, y) + assert not diff.equivalent + assert not any(d.aspect in ("join", "tables") and "没有" in d.text for d in diff.items), diff.items + + +@pytest.mark.parametrize("pid", sorted(sql_pairs.ROUND_FIVE_TIME)) +@pytest.mark.parametrize("dialect", ("sqlite", "postgres", "mysql")) +def test_round_five_time_windows_are_never_called_a_filter(pid, dialect): + """第 5 轮 major:时间列上的日期范围是统计窗口,不是有效行过滤。以前在结果看行先后的一段里(按日期表达式分组、取前 + N 行)BETWEEN 不拆,被说成两条「只保留 … 的行,另一边没有这个条件」,fallback 为假、正好够写进数据目录;LIKE + '2026-09%' 取月也被说成有效行过滤。现在不许说成 filter;说成 time 时两边的时间列得对(没有按时间筛选的一边是空的)。 + SQLite 上一定说成 time。""" + pair = sql_pairs.PAIRS_BY_ID[pid] + cols_a, cols_b = sql_pairs.ROUND_FIVE_TIME[pid] + for schema in (None, SCHEMA): + for (x, y), (ca, cb) in (((pair.a, pair.b), (cols_a, cols_b)), ((pair.b, pair.a), (cols_b, cols_a))): + diff = explain_sql_diff(x, y, dialect, schema=schema) + _check_shape(diff, x, y) + assert not diff.equivalent + assert not any(d.aspect == "filter" for d in diff.items), (pid, dialect, diff.items) + times = [d for d in diff.items if d.aspect == "time"] + for item in times: + assert [c["column"] for c in item.detail["a"]] == ca and \ + [c["column"] for c in item.detail["b"]] == cb, (pid, dialect, item) + if dialect == "sqlite": + assert len(times) == 1 and not diff.fallback, (pid, diff.items) + + +def _round_five_same() -> list[tuple[str, str, str, bool]]: + return [(p.id, p.a, p.b, p.needs_schema) for p in (sql_pairs.PAIRS_BY_ID[i] for i in sql_pairs.ROUND_FIVE_SAME)] + + +@pytest.mark.parametrize("pid,a,b,needs_schema", _round_five_same(), ids=[p[0] for p in _round_five_same()]) +@pytest.mark.parametrize("dialect", ("sqlite", "postgres", "mysql")) +def test_round_five_equivalent_rewrites_get_at_most_the_fallback_in_every_dialect(pid, a, b, needs_schema, dialect): + """第 5 轮 major:意思一样,以前却说出了具体的不同——反连接里被 u.id IS NULL 蕴含的 u.name IS NULL、两种反连接的 + 写法(查主键为空、查关联键为空)、按主键分组时 MAX 对 MIN、AVG(DISTINCT) 对 AVG(同一组里那张表的列只有一个值, + LEFT JOIN 把行复制了也一样)。认得出就判等价,认不出只许给兜底;要靠主键的,没给表结构时说得出具体的不同不算错。""" + for schema in (None, SCHEMA): + if needs_schema and schema is None: + continue + for x, y in ((a, b), (b, a)): + diff = explain_sql_diff(x, y, dialect, schema=schema) + _check_shape(diff, x, y) + assert diff.equivalent or _only_fallback(diff), (pid, dialect, bool(schema), diff.items) + + +@pytest.mark.parametrize("dialect", ("sqlite", "postgres", "mysql")) +def test_anti_joins_are_never_called_a_filter(dialect): + """第 5 轮 major:LEFT JOIN … WHERE r.id IS NULL 和 … WHERE r.order_id IS NULL 是反连接最常见的两种写法。关联键为空 + 就是没关联上,蕴含 r.id 为空;r.id 是主键时两者一样,没有主键时查 r.id 的一边留下的行更多(关联上了、r.id 却为空的)。 + 以前说「A 只保留 refunds.id IS NULL 的行,B 没有这个条件」:把留得多的一边说成了严的一边,还当成有效行过滤。给不给 + 表结构都不许判等价(要靠主键),也只许给兜底。""" + pair = sql_pairs.PAIRS_BY_ID["hard:r5-anti-join-key-vs-join-column"] + for schema in (None, SCHEMA): + for x, y in ((pair.a, pair.b), (pair.b, pair.a)): + diff = explain_sql_diff(x, y, dialect, schema=schema) + _check_shape(diff, x, y) + assert not diff.equivalent and _only_fallback(diff), (dialect, bool(schema), diff.items) + + +@pytest.mark.parametrize("name,dialect,a,b,schema,bare", sql_pairs.EXPLAIN_TRAPS_R5, + ids=[f"{n}-{d or 'unknown'}" for n, d, *_ in sql_pairs.EXPLAIN_TRAPS_R5]) +def test_round_five_traps_that_need_their_own_schema_get_only_the_fallback(name, dialect, a, b, schema, bare): + """第 5 轮 major(要专门的表结构,不执行):数仓事实表按业务日期键(date_key BETWEEN 20260901 AND 20260930、 + stat_month = 202609)筛的一边,以前被说成「没有按时间筛选」、日期键的范围被说成有效行过滤;反连接查主键、查 NOT NULL + 的列、查关联键,以前被说成两条有效行过滤;经关联条件等于分组键的主键定下来的列,以前说「A 取最大值,B 取最小值」。 + 这些都只给兜底(经主键推出来的那几对,没给表结构时这么说按已知信息成立,不查)。""" + for given in ((schema, None) if bare else (schema,)): + for x, y in ((a, b), (b, a)): + diff = explain_sql_diff(x, y, dialect or None, schema=given) + _check_shape(diff, x, y) + assert not diff.equivalent and _only_fallback(diff), (name, dialect, bool(given), diff.items) diff --git a/backend/tests/test_sql_semantics.py b/backend/tests/test_sql_semantics.py index 0b31ed51..1d274fbf 100644 --- a/backend/tests/test_sql_semantics.py +++ b/backend/tests/test_sql_semantics.py @@ -1439,6 +1439,14 @@ def test_differences_that_cannot_change_the_result_are_not_described(a, b): ("SELECT COUNT(DISTINCT status) FROM orders WHERE id = 5", "SELECT COUNT(status) FROM orders WHERE id = 5"), ("SELECT o.id FROM orders o LEFT JOIN users u ON u.id = o.user_id", "SELECT o.id FROM orders o"), ("SELECT COUNT(*) FROM orders o JOIN users u ON u.id = o.user_id", "SELECT COUNT(*) FROM orders o"), + # 第 5 轮:按主键分组(或者经关联条件等于主键),同一组里这张表的列只有一个值——LEFT JOIN 把行复制了也一样 + ("SELECT u.id, MAX(u.city), COUNT(o.id) FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id", + "SELECT u.id, MIN(u.city), COUNT(o.id) FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id"), + ("SELECT o.id, AVG(DISTINCT o.amount), COUNT(r.id) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id " + "GROUP BY o.id", + "SELECT o.id, AVG(o.amount), COUNT(r.id) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id GROUP BY o.id"), + ("SELECT o.user_id, MAX(u.name), SUM(o.amount) FROM orders o JOIN users u ON u.id = o.user_id GROUP BY o.user_id", + "SELECT o.user_id, MIN(u.name), SUM(o.amount) FROM orders o JOIN users u ON u.id = o.user_id GROUP BY o.user_id"), ], ids=lambda v: None) def test_constraints_only_make_the_explanation_say_less(a, b): """表结构里的约束不拿来判等价,只拿来少说:主键不为空(id IS NOT NULL 恒为真)、主键定下来每组只有一行(SUM 和 @@ -1453,3 +1461,161 @@ def test_constraints_only_make_the_explanation_say_less(a, b): diff = explain_sql_diff(x, y, "sqlite") check_shape(diff) assert any(d.aspect != "other" for d in diff.items), (x, y, diff.items) + + +# -------------------------------------------------------------------------- +# 第 5 轮:PostgreSQL 写成符号的运算符、各库读法不同的注释;条件写在聚合里、时间窗口、反连接、经主键定下来的列 +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("dialect,sql", [ + ("postgres", "SELECT id, status ~ 'p' || 'x' FROM orders"), + ("postgres", "SELECT id, status ~* 'p' || 'x' FROM orders"), + ("postgres", "SELECT id, status ~~ 'p%' || 'x' FROM orders"), + ("postgres", "SELECT id, status ~~* 'P%' || 'x' FROM orders"), + ("postgres", "SELECT id, to_tsvector(status) @@ 'paid' || '' FROM orders"), + ("postgres", "SELECT id, tags @> 'a' || 'b' FROM orders"), + ("postgres", "SELECT id, @ amount - 20 FROM orders"), + ("postgres", "SELECT id, |/ amount FROM orders"), + ("postgres", "SELECT id, status COLLATE \"C\"::text FROM orders"), + (None, "SELECT id /* a /* b */ + 1 -- */\nFROM orders"), + (None, "SELECT id, amount--1\nFROM orders"), + (None, "SELECT COUNT(*) FROM orders WHERE \"status\" = 'paid'"), +], ids=["regex-concat", "iregex-concat", "like-op-concat", "ilike-op-concat", "text-search-concat", "contains-concat", + "absolute-value", "square-root", "collate-cast", "unknown-nested-comment", "unknown-dashes", + "unknown-double-quotes"]) +def test_operators_postgres_reads_differently_from_sqlglot_are_held_back(dialect, sql): + """PostgreSQL 里 ~、~*、~~、~~*、@@、@> 这些写成符号的运算符和 ||、&、# 同级(「别的运算符」)、从左往右结合,sqlglot + 把它们当成 LIKE、IN 那一级、比 || 松:status ~ 'p' || 'x' 在 PostgreSQL 里是 (status ~ 'p') || 'x'。前缀的 @ 是取 + 绝对值(sqlglot 读成参数),|/ 是双精度的平方根(sqlglot 写成 SQRT),x COLLATE "C"::text 的 :: 套在整个 COLLATE 上。 + 不知道是哪种库时,块注释里的 /*、紧跟着字的 --、双引号括起来的名字(MySQL 默认的 sql_mode 里是字符串)各库读法不同。 + 这些都不在语法树上判等价。""" + from app.data.sql_semantics import _lost_in_parsing, _parse, _read + + read = _read(dialect) + tree, error = _parse(sql, read) + assert error is None and _lost_in_parsing(sql, read, tree) + + +@pytest.mark.parametrize("dialect,sql", [ + ("postgres", "SELECT id FROM orders WHERE status LIKE 'p' || '%'"), + ("postgres", "SELECT id FROM orders WHERE status ILIKE 'p' || '%' AND amount > 1"), + ("postgres", "SELECT id FROM orders WHERE status ~ ('p' || 'x')"), + ("postgres", "SELECT id FROM orders WHERE status ~ '^p' AND amount + 1 > 2"), + ("postgres", "SELECT id, status::text || 'x' FROM orders WHERE status COLLATE \"C\" > 'a'"), + ("sqlite", "SELECT id /* a /* b */ + 1 -- */\nFROM orders"), + ("postgres", "SELECT id /* a /* b */ + 1 -- */\nFROM orders"), + ("mysql", "SELECT id /* a /* b */ + 1 -- */\nFROM orders"), + ("sqlite", "SELECT id, amount--1\nFROM orders"), + ("postgres", "SELECT id, amount--1\nFROM orders"), + ("postgres", "SELECT COUNT(*) FROM orders WHERE \"status\" = 'paid'"), + ("mysql", "SELECT COUNT(*) FROM orders WHERE status = \"paid\""), +], ids=["pg-like-concat", "pg-ilike-concat", "pg-regex-parenthesized", "pg-regex-own-segment", "pg-cast-collate-apart", + "sqlite-comment", "pg-comment", "mysql-comment", "sqlite-dashes", "pg-dashes", "pg-double-quotes", + "mysql-double-quotes"]) +def test_writings_each_engine_reads_like_sqlglot_are_not_held_back(dialect, sql): + """写成关键字的 LIKE / ILIKE 本来就比 || 松(PostgreSQL 和 sqlglot 读得一样);加了括号、AND 隔开的不算混用;知道是 + SQLite、PostgreSQL、MySQL 时,注释怎么嵌套、-- 后面要不要空白、双引号括起来的是名字还是字符串,sqlglot 和库读得一样: + 照常规范化,加个空格照样认得出等价。""" + from app.data.sql_semantics import _lost_in_parsing, _parse, _read + + read = _read(dialect) + tree, error = _parse(sql, read) + assert error is None and not _lost_in_parsing(sql, read, tree) + assert explain_sql_diff(sql, sql.replace(" FROM", " FROM"), dialect).equivalent + + +@pytest.mark.parametrize("pid,expected", [ + ("diff:r5-between-on-two-time-columns-grouped-by-date", "A 按 paid_at 筛时间,B 按 created_at"), + ("diff:r5-between-on-two-time-columns-top-n", "A 按 paid_at 筛时间,B 按 created_at"), + ("diff:r5-between-window-on-one-side-grouped-by-date", "A 没有按时间筛选,B 按 paid_at 筛时间"), + ("diff:r5-like-month-vs-no-window", "A 没有按时间筛选,B 按 paid_at 筛时间"), + ("diff:r5-range-vs-like-month-on-another-column", + "A 按 paid_at 筛时间,B 按 created_at(A 是 paid_at >= '2026-09-01' AND paid_at < '2026-10-01'," + "B 是 created_at LIKE '2026-09%')"), +]) +def test_time_windows_written_as_between_or_like_are_told_as_time_columns(pid, expected): + """时间列上的 BETWEEN、LIKE '2026-09%'(SQLite 里日期存成文字,取月常这么写)是统计窗口:说成按哪一列筛时间,不说成 + 「只保留 … 的行」(那是有效行过滤,会写进数据目录)。结果看行先后的一段(按日期表达式分组、取前 N 行)里 BETWEEN + 不拆成两个比较,照样认得出。""" + pair = sql_pairs.PAIRS_BY_ID[pid] + for schema in (None, sql_pairs.schema_cache()): + diff = explain_sql_diff(pair.a, pair.b, "sqlite", schema=schema) + check_shape(diff) + assert [(d.aspect, d.text) for d in diff.items] == [("time", expected)], diff.items + + +@pytest.mark.parametrize("a,b,expected", [ + # 聚合的参数就是那一列(不是写在参数里的条件):另一边确实没有按 status 筛 + ("SELECT region, SUM(amount) FROM orders GROUP BY region", + "SELECT region, SUM(amount) FROM orders WHERE status = 'paid' GROUP BY region", + [("filter", "B 只保留 status = 'paid' 的行,A 没有这个条件")]), + ("SELECT SUM(amount) FROM orders", "SELECT SUM(amount) FROM orders WHERE amount > 0", + [("filter", "B 只保留 amount > 0 的行,A 没有这个条件")]), + # 只是选出来给人看的标记(不聚合,行都在结果里):另一边确实把别的行也列出来了 + ("SELECT id, status = 'paid' AS is_paid FROM orders", + "SELECT id, status = 'paid' AS is_paid FROM orders WHERE status = 'paid'", + [("filter", "B 只保留 status = 'paid' 的行,A 没有这个条件")]), + # 判空、按状态筛这类和日期无关的条件照说;时间列上的判空是有效行过滤,不是时间窗口 + ("SELECT COUNT(*) FROM orders", "SELECT COUNT(*) FROM orders WHERE paid_at IS NOT NULL", + [("filter", "B 排除了 paid_at 为空的行,A 没有")]), + ("SELECT COUNT(*) FROM orders", "SELECT COUNT(*) FROM orders WHERE grade = 3", + [("filter", "B 只保留 grade = 3 的行,A 没有这个条件")]), +], ids=lambda v: None) +def test_filters_the_other_side_really_does_not_have_are_still_told(a, b, expected): + """收窄之后的对照:另一边确实没有这个条件时照说。""" + diff = explain_sql_diff(a, b, "sqlite") + check_shape(diff) + assert [(d.aspect, d.text) for d in diff.items] == expected, diff.items + + +@pytest.mark.parametrize("a,b", [ + # 条件、时间窗口写在聚合的参数里:这一边也只算满足条件的行 + ("SELECT region, SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) FROM orders GROUP BY region", + "SELECT region, COUNT(*) FROM orders WHERE status = 'paid' GROUP BY region"), + ("SELECT region, SUM(IIF(status = 'paid', amount, 0)) FROM orders GROUP BY region", + "SELECT region, SUM(amount) FROM orders WHERE status = 'paid' GROUP BY region"), + ("SELECT region, AVG(status = 'paid') FROM orders GROUP BY region", + "SELECT region, AVG(status = 'paid') FROM orders WHERE status = 'paid' GROUP BY region"), + ("SELECT region, SUM(amount * is_paid) FROM orders GROUP BY region", + "SELECT region, SUM(amount) FROM orders WHERE is_paid = 1 GROUP BY region"), + ("SELECT SUM(amount) FILTER (WHERE created_at >= '2026-09-01') FROM orders", + "SELECT SUM(amount) FROM orders WHERE paid_at >= '2026-09-01'"), + # 分不清是不是日期的列上的窗口(业务日期键、统计月份、分区):既不说成有效行过滤,也不说另一边「没有按时间筛选」 + ("SELECT SUM(amount) FROM sales WHERE stat_month = 202609", "SELECT SUM(amount) FROM sales"), + ("SELECT SUM(amount) FROM sales WHERE pt = '20260901'", "SELECT SUM(amount) FROM sales"), + ("SELECT SUM(amount) FROM sales WHERE biz_key BETWEEN 20260901 AND 20260930", + "SELECT SUM(amount) FROM sales WHERE created_at >= '2026-09-01' AND created_at < '2026-10-01'"), + ("SELECT SUM(amount) FROM sales WHERE fiscal_year = 2026", + "SELECT SUM(amount) FROM sales WHERE created_at >= '2026-01-01'"), + # 另一边对别的列有范围(BETWEEN、上下界一对):可能是认不出的时间窗口,不说「没有按时间筛选」 + ("SELECT SUM(amount) FROM sales WHERE day_no BETWEEN 20697 AND 20726", + "SELECT SUM(amount) FROM sales WHERE created_at >= '2026-09-01' AND created_at < '2026-10-01'"), + # 反连接:可空一侧的列为空说的是「没关联上」 + ("SELECT COUNT(*) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id WHERE r.state IS NULL", + "SELECT COUNT(*) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id"), + ("SELECT COUNT(*) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id WHERE r.refunded_at IS NULL", + "SELECT COUNT(*) FROM orders o LEFT JOIN refunds r ON r.order_id = o.id WHERE r.id IS NULL"), +], ids=lambda v: None) +@pytest.mark.parametrize("dialect", DIALECTS) +def test_conditions_the_other_side_has_in_another_form_are_not_called_missing(a, b, dialect): + """第 5 轮:另一边其实有这个条件(写在聚合的参数里、写在 CASE / IIF / FILTER 里、乘上 0 / 1 的标记),或者这个条件 + 可能是认不出的时间窗口,或者是反连接——说「另一边没有这个条件」「另一边没有按时间筛选」就说错了,只给兜底。""" + for x, y in ((a, b), (b, a)): + diff = explain_sql_diff(x, y, dialect) + check_shape(diff) + assert not diff.equivalent and diff.items == [FacetDiff("other", None, None, FALLBACK)], (x, y, diff.items) + + +def test_time_columns_on_different_tables_are_written_with_their_tables(): + """两边的时间列在不同的表上:光写「B 按 created_at」读起来像订单的下单时间,其实是用户的注册时间。写成「表.列」; + 说「另一边没有按时间筛选」的一边关联了好几张表时也写表名。detail 照旧给各自的表。""" + a = "SELECT COUNT(*) FROM orders o JOIN users u ON u.id = o.user_id WHERE o.paid_at >= '2026-09-01'" + b = "SELECT COUNT(*) FROM orders o JOIN users u ON u.id = o.user_id WHERE u.created_at >= '2026-09-01'" + plain = "SELECT COUNT(*) FROM orders o JOIN users u ON u.id = o.user_id" + [item] = explain_sql_diff(a, b, "sqlite", names={"users.created_at": "注册时间"}).items + assert item.text == "A 按 orders.paid_at 筛时间,B 按注册时间 users.created_at" + assert item.detail == {"a": [{"table": "orders", "column": "paid_at"}], + "b": [{"table": "users", "column": "created_at"}]} + [item] = explain_sql_diff(b, plain, "sqlite").items + assert (item.aspect, item.text) == ("time", "A 按 users.created_at 筛时间,B 没有按时间筛选")