mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:21:58 +00:00
SQL 差异说明收尾(第 2 轮):PostgreSQL 符号运算符和 || 混用不判等价;条件写在聚合里、时间窗口、反连接、经主键定死的列不再说错
- 保真守卫(_misread):PostgreSQL 写成符号、sqlglot 却当成 LIKE 那一级来读的运算符(~、~*、~~、~~*、@@、-|-、@> 等) 和别的二元运算混用时拦下(PostgreSQL 里它们和 ||、& 同级、从左往右,status ~ 'p' || 'x' 是 (status ~ 'p') || 'x'); 另拦 PostgreSQL 的前缀 @、|/、||/、COLLATE 后面的 ::;不知道是哪种库时拦块注释里套着的 /*、紧跟着字的 --、#、双引号 括起来的名字(MySQL 默认的 sql_mode 里是字符串)。都只给兜底,附原文的逐行对照。 - 条件写在聚合的参数里(SUM(status = 'paid')、COUNT(… OR NULL)、COUNT(NULLIF(…))、SUM(amount * (1 - is_deleted))) 算作这一边对这一列有条件(_Usage 的 case),另一边多出同一列上的 WHERE 条件时不再说「这一边没有这个条件」,只给兜底。 - 时间:timed 认 BETWEEN 和时间列上 LIKE 像日期的模式(paid_at LIKE '2026-09%'),时间窗口不再被说成有效行过滤; 「另一边没有按时间筛选」之前看另一边可能是时间窗口的条件(聚合参数里的时间比较、date_key、stat_month 这类列、 20260901 / 202609 这类整数、上下界一对),有就不说;认不准是不是时间列的窗口(_window_like)不说成有效行过滤。 两边的时间列在不同的表上时写成「表.列」。 - 反连接:用到 LEFT JOIN 可空一侧的列的条件(WHERE r.id IS NULL、r.order_id IS NULL)不说成有效行过滤,时间列在 可空一侧时也不说按它筛时间。 - 给了表结构时,聚合参数所在的表的键在组里定死了(按 u.id 分组时的 u.city),MAX 对 MIN、AVG(DISTINCT) 对 AVG 不说。 - 另一边原文也 LEFT JOIN 了、规范化因为结果不怕重复去掉了的表,不说「另一边没有关联」。 - 回归语料:收尾第 1 轮之后复验报的全部 blocker / major 收进 sql_pairs(_DIFF_R5、_DIFF_TIME_R5、_SAME_HARD_R5、 _SAME_SCHEMA_R5 执行;REGRESSIONS_R5、EXPLAIN_TRAPS_R5 只查结论),新增对应的测试。 - 模块说明照实补上:新增的守卫、时间条件的认法、只给兜底的情况、收窄换来的代价、MySQL 按默认 sql_mode 读的假设。 认出等价的比例:语料(SQLite,给表结构)169 对里 157 对,和改动前一样;PostgreSQL、MySQL 也不变;不知道是哪种库时 少认 1 对(same:quoted-names,双引号括起来的名字)。
This commit is contained in:
@@ -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)
|
||||
|
||||
Vendored
+211
-3
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 没有按时间筛选")
|
||||
|
||||
Reference in New Issue
Block a user