- 全源码逐行通读(src 16079 行 / tests 20223 行 / 文档 4349 行),7 个子系统并行深度审计 - PLAN-v0.7.5.md:8 大根因分析 + 3 条并行工作流 + 55 项缺陷总账 + 6 道硬门禁 - AUDIT-aria-lsm / AUDIT-query-layer / AUDIT-storage-engines:逐条 file:line 证据 - 全部关键结论经可执行探针实测复现(探针已删除,仓库无残留)
347 lines
38 KiB
Markdown
347 lines
38 KiB
Markdown
# MetonaSqlark v0.7.4 — 查询层(AST / Builder / Compiler / Executor / Where-Matcher)深度审计报告
|
||
|
||
审计范围:`src/query/{ast,builder,compiler,executor,where-matcher,index}.ts`(逐行读完)、
|
||
`tests/query/query-system.test.ts`、`tests/{join,subquery,groupby}.test.ts`、
|
||
`tests/v07{0,1,2,3,4}-*.test.ts`、`tests/{foreign-key-cascade,transaction-rollback}.test.ts`、
|
||
README(核心特性 / API 速览 / 已知限制)、CHANGELOG 1–300 行。
|
||
|
||
方法:先逐行读码定位可疑路径,再写临时 jest 探针(`tests/zz-audit-probe*.test.ts`,已删除)实跑验证。
|
||
下文每条标注 **[已验证]**(有实跑输出)或 **[读码确认]**(未实跑,但根因路径可指到具体行)。
|
||
|
||
---
|
||
|
||
## 1. 架构概览
|
||
|
||
### 1.1 SQL 字符串 → 解析 → 编译 → 引擎
|
||
|
||
| 阶段 | 位置 | 实际做的事 |
|
||
|---|---|---|
|
||
| 入口 | `core.ts:207-242` | `bindParameters`(`sql/params.ts`)→ `parseAll` → 逐条 `executor.execute(stmt)`;**多语句顺序执行、返回最后一条结果、无事务包裹** |
|
||
| 解析 | `sql/parser.ts`(递归下降,1331 行) | 产出 `query/ast.ts` 的 AST。子查询不建关系算子,而是打成 `$subquery` / `$col` / `$exists` 标记(parser.ts:845/920/961/977) |
|
||
| 编译 | `query/compiler.ts:21-57` | **只做平铺映射**:`{table, columns, where, orderBy, limit, offset}`。没有算子树、没有投影/表达式编译、没有代价模型;JOIN/GROUP BY/DISTINCT/HAVING/聚合全部被丢弃。只有 SELECT/DELETE/UPDATE 可编译,其余抛 `COMPILE_ERROR` |
|
||
| 执行 | `query/executor.ts` | 关系语义全部在 JS 里做:JOIN、GROUP BY、DISTINCT、HAVING、关联子查询、UNION、投影 |
|
||
| Builder 旁路 | `builder.ts:97-113` | `SelectQueryBuilder.execute()`:**有 JOIN 才走 Executor,无 JOIN 直接 `engine.find`**(builder.ts:105);`UpdateQueryBuilder`/`DeleteQueryBuilder` 永远直通引擎(`builder.ts:160` / `builder.ts:199`)→ 同一语义两套路径 |
|
||
|
||
`compileStatement` 的产物 `QueryPlan` 只描述单表扫描;`stmt.joins/groupBy/distinct/having` 从不进 plan。因此"编译"在本项目里约等于"把 WHERE 交给引擎",不存在下推优化器(唯一的优化是 JOIN 查询里主表别名等值条件的抽取下推,`executor.ts:413-417 / 457-471`)。
|
||
|
||
### 1.2 Executor 如何逐子句处理(`executeSelect`,`executor.ts:282-400`)
|
||
|
||
决策树(282-356):
|
||
|
||
1. `fromSubquery`(派生表)→ 先跑子查询取行;有 JOIN 则给行加 `<别名>.` 前缀后进入 JOIN 路径(297-307)。
|
||
2. 无 FROM 且无 JOIN(`SELECT 1`)→ 单行空上下文(308-310)。
|
||
3. 有 JOIN → `executeJoinSelect`(311-313)。
|
||
4. 普通单表(314-355):先把 WHERE / ORDER BY / GROUP BY / SELECT 列里的主表别名前缀剥掉(316-336);若 WHERE 命中 `$col`,走 `filterCorrelated` 逐行求值(339-345),否则 `resolveSubqueries` 后交给 `engine.find`(346-355)。
|
||
- **`needsRawRows`(有 CASE)/`hasSelectAlias`(有 `AS`)时强制 `plan.columns = ['*']`,即放弃引擎投影(352)**,投影改在 executor 端做。
|
||
- **`orderByAlias` 为真时清掉 plan 的 orderBy/limit/offset(353)**,留到投影后重排。
|
||
|
||
后处理顺序(358-398,**注意与 SQL 标准顺序不一致**):
|
||
|
||
```
|
||
聚合(无 GROUP BY) 359-361 → GROUP BY 363 → DISTINCT 364 → HAVING 365-378
|
||
→ ORDER BY 379 → 投影 383-386 → ORDER BY(别名, 第二次) 388-390
|
||
→ slice(offset, offset+limit) 391-393 → maxRowsPerQuery 截断 396-398
|
||
```
|
||
|
||
关键偏差:**DISTINCT 在投影之前**(364 vs 383)、**LIMIT/OFFSET 同时下推给引擎又在此再切一次**、**HAVING 是对投影后的组行做 `matchWhere`**。
|
||
|
||
### 1.3 子查询 / 关联引用
|
||
|
||
- `resolveSubqueries`(1336-1385)递归遍历 WHERE:`$exists` 执行子查询置换为 boolean(1345-1356);`$and/$or/$not` 递归;字段级交给 `resolveOperatorSubqueries`(1390-1439)。
|
||
- `resolveOperatorSubqueries`:`$in/$nin` 取子查询**第一列**的值列表(1419-1423),其它运算符取**第一行第一列**(1425-1431);空结果标量 → `null`。
|
||
- `$col` 绑定:`bindColumnRefs`(1291-1309)从 `contextRow` 取列值。
|
||
- 关联上下文只有一条通路:`hasCorrelatedRefs`(1159-1181,纯语法嗅探)→ `filterCorrelated`(1226-1238)逐行 `bindWhereRefs` + 执行 `$exists`。**`resolveOperatorSubqueries` 调 `executeSelect(subStmt)` 时(1417)不传外层行**,所以 `IN (SELECT … WHERE 内层列 = 外层列)` / 标量关联子查询拿不到外层上下文(见缺陷 P1-4)。
|
||
- 代价:关联路径 = 外层每行一次子查询执行,无缓存(1228-1236)。
|
||
|
||
### 1.4 JOIN 实现
|
||
|
||
- 主表:整表(或仅主表别名等值条件下推)物化并加前缀(408-418);非 JOIN 分支不做前缀。
|
||
- 每个 JOIN:`tryHashJoin`(480-566)→ 条件为"纯等值列对"且右表**至少一列有索引/主键/唯一**时,收集左表探测列去重值 → 一次 `$in` 查询右表 → 复合键 `Map`(键为 `String(v ?? '\0')` 拼接,543/553)→ 左行探测;LEFT 无命中补右表全 null 行(560-562)。
|
||
- 否则 `joinRows` 嵌套循环(569-612):`matchWhere(merged, on, {$col:true})`;LEFT 用**右表第一行的键集**补 null(593);RIGHT 再对右表全扫一遍找未匹配行(598-610),并把它们**追加在末尾**。
|
||
- 不支持哈希:CROSS / RIGHT(486),以及 ON 含 `$or`/`$not`/非等值(496/506)→ **右表无条件整表物化**(429)。
|
||
|
||
### 1.5 投影 / 排序 / 分组键编码
|
||
|
||
- 行 = 扁平 `Record<string, unknown>`;JOIN 行键为 `alias.col`(447-451),普通行为裸列名。
|
||
- 投影 `projectRow`(1010-1063):逐行用**正则重新解析** `*`、`col AS alias`、字符串常量、`CASE…END`;`projectColumns`(where-matcher.ts:214-228)先精确匹配键,否则 `key.endsWith('.'+col)` 兜底。
|
||
- 分组/去重键 `encodeGroupKey`(31-39):类型前缀(`n/u/s/d/b/o/x`),以 `\x1f` 连接(621 / 689 / 170 / 178)。
|
||
- 排序 `applyOrderBy`(where-matcher.ts:183-208):逐键比较;显式 `NULLS FIRST/LAST` 时 NULL 位置固定,未指定时 NULL 视为最大值(升序在末尾、降序在开头 = PostgreSQL 语义);类型不同回退 `localeCompare(String(a), String(b))`。
|
||
- **哈希连接、`$in` 探测、`COUNT(DISTINCT)` 不复用 `encodeGroupKey`**(543/553 用 `String(v ?? '\0')`,executor.ts:668 用 `String(v)`)。
|
||
|
||
### 1.6 四引擎的行为分叉点
|
||
|
||
Executor 本身引擎无关,分叉来自被调用的引擎方法:
|
||
|
||
| 分叉点 | Memory / Disk(KVStore) / Hybrid | Aria |
|
||
|---|---|---|
|
||
| 行顺序(无 ORDER BY) | 插入顺序(Map 迭代) | 主键升序(LSM key 顺序)**[已验证]** |
|
||
| 主键值类型 | 原类型(number 保持 number) | 全表/范围路径用 `key.slice()` → **字符串**;`$eq` 快速路径用查询字面量原类型(aria/index.ts:1998/2005 vs 2028/1223)**[已验证]** |
|
||
| 索引等值 | `Map.get(原始值)`,无条目直接 `return []`(memory.ts:760-768) | `String(value)` 索引键 + 前缀 rangeScan(aria/index.ts:2045/2062) |
|
||
| 流式 | `findStream` 同源实现(memory.ts:213-233) | 真惰性 LSM 扫描(aria/index.ts:1172-1227) |
|
||
| LIMIT/OFFSET | 引擎内 `slice`(memory.ts:203-205) | 引擎内 `slice`(aria/index.ts:696-699)—— 与 executor 重复 |
|
||
| 未解析 `$col/$subquery` 防护 | 仅 update/delete 有(memory.ts:245) | 仅 update/delete 有(aria/index.ts:721) |
|
||
|
||
---
|
||
|
||
## 2. 缺陷清单
|
||
|
||
### P0 — 崩溃
|
||
|
||
**P0-1 `MIN`/`MAX` 大分组栈溢出(崩溃)** **[已验证]**
|
||
- 文件行号:`src/query/executor.ts:677-678`
|
||
- 现象:`SELECT MAX(v) FROM big`(20 万行同组)抛 `RangeError: Maximum call stack size exceeded`;`SUM`/`AVG` 正常(reduce 实现)。
|
||
- 根因:`Math.min(...distinctNums)` / `Math.max(...distinctNums)` 把整组值展开成实参,超过 JS 引擎实参上限。
|
||
- 复现:`CREATE TABLE big (id NUMBER PRIMARY KEY, v NUMBER)`,`insertMany` 20 万行,`SELECT MAX(v) FROM big`。
|
||
- 备注:函数签名 `computeAggregate(...): number`(655)也说明聚合返回类型被硬编码为 number(见 P1-8)。
|
||
|
||
---
|
||
|
||
### P1 — 错误结果 / 静默数据丢失(全部有实跑复现)
|
||
|
||
**P1-1 LIMIT/OFFSET 被应用两次(OFFSET 结果截断)** **[已验证]**
|
||
- 行号:`executor.ts:391-393`(executor 再切一次);`compiler.ts:40-41`(limit/offset 进 plan);`engine/memory.ts:203-205`、`engine/aria/index.ts:696-699`(引擎已切过)
|
||
- 现象:id=1..4 的表,`SELECT id FROM t ORDER BY id LIMIT 2 OFFSET 1` → `[{id:3}]`(应为 2,3);`LIMIT 10 OFFSET 3` → `[]`(应为 4,5);`OFFSET 2` → 只剩 1 行。
|
||
- 根因:plan 带着 offset/limit 交给 `engine.find`(354),引擎 `slice(offset, offset+limit)` 之后 executor 又 `rows.slice(offset, offset+limit)`(391-393)。只有 `orderByUsesSelectAlias` 为真的查询在 353 行清掉了 plan 的 limit/offset 因而"碰巧正确"。
|
||
- 反证(同 executor 内自相矛盾):JOIN 路径(405-432 的 `engine.find` 不传 limit)与派生表路径结果正确;`SELECT a.id FROM a JOIN b ON a.k=b.k ORDER BY a.id LIMIT 2 OFFSET 1` → 2,3 正确。
|
||
- 复现:`SELECT id FROM t ORDER BY id LIMIT 2 OFFSET 1`。
|
||
|
||
**P1-2 LIMIT 被下推到 DISTINCT / GROUP BY / 聚合之下** **[已验证]**
|
||
- 行号:`executor.ts:352`(`plan.columns=['*']` 但保留 limit)+ `364`(DISTINCT 在切片之前)+ `391-393`
|
||
- 现象:`SELECT DISTINCT dept FROM e LIMIT 2` → 1 行(Eng);`SELECT dept, COUNT(*) AS c FROM e GROUP BY dept LIMIT 2` → 1 组(先被引擎截到 2 行原始行再分组)。
|
||
- 根因:LIMIT 属于最终结果集,却被编译进单表扫描计划(compiler.ts:41),在分组/去重之前就截断了输入行。
|
||
- 复现:5 行 3 个 dept 的表执行上述两条 SQL。
|
||
|
||
**P1-3 `WHERE 表别名.a = 表别名.b`(列对列)在普通单表查询中恒返回 0 行** **[已验证]**
|
||
- 行号:`executor.ts:339-345`(关联路径)→ `344` 把 `stripCorrelatedExists(stmt.where)` 交给引擎;`executor.ts:1205-1223`(该函数只剥 `$exists` 与 CASE 键,**保留 `$col` 字段条件**);`where-matcher.ts:139-150`(`$col` 只有在 `options.$col` 为真时才绑定)
|
||
- 现象:`SELECT * FROM t1 WHERE t1.x = t1.y`((1,5,5),(2,10,3))→ `[]`(应为 id=1)。`WHERE x = y` 直接 PARSE_ERROR(parser 只在 `ident.ident` 形式才识别列引用,parser.ts:956-963)。
|
||
- 根因:引擎层先执行了带未绑定 `$col` 的条件(`value === {$col:'y'}` 恒 false),把行全部过滤光,`filterCorrelated` 拿到的是空数组。
|
||
- 附带:`tests/v073-fixes.test.ts:267-278` 是这条语义的"回归护栏",但它只比较 `query()` 与 `queryStream()` 的行数(都是 0)→ **该测试是空断言,掩盖了错误**。
|
||
- 复现:见上(注意必须带表别名前缀,否则解析失败)。
|
||
|
||
**P1-4 关联引用出现在 `IN (SELECT …)` / 标量子查询中被静默丢弃(恒 0 行)** **[已验证]**
|
||
- 行号:`executor.ts:1415-1423`(`$in` 分支)与 `1425-1431`(标量分支)都调 `this.executeSelect(subStmt)`(1417),**不传 contextRow**;对比 `$exists` 分支(1351-1353)显式 `bindWhereRefs` 外层行。
|
||
- 现象:`SELECT id FROM u WHERE id IN (SELECT user_id FROM o WHERE o.user_id = u.id)` → `[]`(应为 1,2);EXISTS 形式(同样的 SQL 结构)能正确返回 1,2。
|
||
- 根因:子查询内 `u.id` 被当作**子查询自身行**的列(`bindColumnRefs` 用子查询行的 contextRow → `undefined`),条件恒 false。
|
||
- 复现:users(1,2,3) / orders(o1→1,o2→1,o3→2),执行上述 SQL。
|
||
|
||
**P1-5 完全没有三值逻辑:`!=` / `NOT IN` / `NOT LIKE` / `NOT(…)` 把 NULL 行当"真"** **[已验证]**
|
||
- 行号:`where-matcher.ts:161-177`(`$eq/$ne/$in/$nin/$like` 全用 JS `===`/`includes`)、`98-101`(`$not` 是布尔取反)、`132-134`(裸值走 `===`)
|
||
- 现象(表 (1,'Alice'),(2,'Bob'),(3,NULL)):
|
||
- `WHERE name != 'Alice'` → 2、3(SQL 只应 2)
|
||
- `WHERE name NOT IN ('Alice')` / `NOT LIKE 'A%'` / `NOT (name='Alice')` → 同上
|
||
- `WHERE n IN (1, NULL)`(n 为 1、2、NULL)→ 返回 1 和 NULL 行(SQL 只应 1)
|
||
- `WHERE n NOT IN (1, NULL)` → 返回 2(SQL 0 行)
|
||
- `WHERE name = NULL` → 返回 NULL 行,`!= NULL` → 返回所有非 NULL 行(`= NULL`/`!= NULL` 与 IS NULL/IS NOT NULL 不可区分)
|
||
- 根因:没有任何 UNKNOWN 态;NULL 被当作普通值参与 `===`。`$eq: null` 恰好等价于 IS NULL,是"顺带正确"。
|
||
- 复现:上述 6 条。
|
||
|
||
**P1-6 `NOT IN (子查询)` 遇子查询结果含 NULL → 多返回行** **[已验证]**
|
||
- 行号:`executor.ts:1419-1423`(原样把第一列值列表塞进 `$nin`)→ `where-matcher.ts:170`
|
||
- 现象:users(1,2,3)、orders(o1→'1', o2→NULL):`SELECT id FROM u WHERE id NOT IN (SELECT user_id FROM o)` → `[2,3]`(SQL 应为 0 行)。
|
||
- 根因:同 P1-5;SQL 中 `x NOT IN (…, NULL)` 永不为真。`$in` 侧(`id IN (SELECT …)`)恰好与 SQL 一致,说明差异纯粹来自 NULL 语义缺失。
|
||
|
||
**P1-7 HAVING 不能引用"未出现在 SELECT 列表里的聚合"** **[已验证]**
|
||
- 行号:`executor.ts:628-651`(`executeGroupBy` 只为 `stmt.columns` 里的聚合表达式算值并写入组行)、`365-378`(HAVING 用 `matchWhere` 对组行求值)
|
||
- 现象:`SELECT dept FROM e GROUP BY dept HAVING COUNT(*) > 1` → `[]`(应为 Eng、Sales);`HAVING SUM(salary) > 2000` → `[]`(应为 Eng)。而 `SELECT dept, COUNT(*) AS c … HAVING COUNT(*) > 1` 正常。
|
||
- 根因:HAVING 的键 `'COUNT(*)'`(parser.ts:1058-1067 生成)在组行里不存在 → `matchField(undefined, {$gt:1})` → false;`_aggAliasMap`(626-651)只做"表达式键→别名键"的重命名,不能补算缺失聚合。
|
||
- 复现:employees(id,dept,salary) 5 行,执行上述两条。
|
||
|
||
**P1-8 聚合的 NULL/类型语义:空集/全 NULL 返回 0 而非 NULL;MIN/MAX 走 `Number()` 数值化** **[已验证]**
|
||
- 行号:`executor.ts:660-680`(`argCol` 原样取列;`663` 过滤 null;`672` `rawValues.map(Number)`;`675-678` SUM 初值 0、AVG/MIN/MAX 空集返回 0、MIN/MAX 用 `Math.min/max`)
|
||
- 现象:
|
||
- 空集或全 NULL:`SUM/AVG/MIN/MAX` → `0`(SQL 为 NULL)
|
||
- 文本列:`MIN(s)/MAX(s)` over ('9','10') → `9 / 10`(文本序应为 '10' / '9');列表含 'abc' 时整列变 `NaN`(JSON 序列化为 `null`)
|
||
- `MIN(id)/MAX(id)` 对字符串主键返回数字
|
||
- `AVG(文本列)` → NaN
|
||
- 合计:COUNT(col) 跳过 NULL(正确)、COUNT(*) = 行数(正确)
|
||
- 根因:聚合只实现了数值语义;`computeAggregate` 返回类型硬编码 `number`(655),没有 NULL 传播、没有"文本聚合 vs 数值聚合"分支。
|
||
- 复现:`SELECT MIN(s), MAX(s) FROM t`(s = '9','10','abc')。
|
||
|
||
**P1-9 非 JOIN 路径下"带表前缀的聚合参数"恒为 0** **[已验证]**
|
||
- 行号:`executor.ts:328-336`(`/^(COUNT|SUM|AVG|MIN|MAX)\(/` 的列原样保留,不剥前缀)→ `662` `r[argCol]`,此时行键是裸列名
|
||
- 现象:`SELECT COUNT(o.id) FROM orders o` → 0(应为 3);`SELECT SUM(o.amount) FROM orders o` → 0(应为 300);`COUNT(*)` 正常。
|
||
- 反证:JOIN 路径行键带前缀(447-451),同样的 `COUNT(o.id)` 在 JOIN 查询里是对的 → 路径分叉。
|
||
- 复现:orders(o1,100),(o2,200),(o3,NULL)。
|
||
|
||
**P1-10 JOIN 的 NULL 键:嵌套循环认为 NULL=NULL 成立,哈希连接认为不成立 → 结果取决于右表是否有索引** **[已验证]**
|
||
- 行号:`where-matcher.ts:139-150`(`options.$col` 分支 `value === row[col]`,NULL===NULL 为真)用在 `executor.ts:586/602`;哈希路径 `executor.ts:531`(左值滤掉 null/undefined)、`543/553`(`String(v ?? '\0')`)
|
||
- 现象(a(a1,NULL),(a2,'x');b/c 同为 (·,NULL),(·,'x'),区别是 c.k 有索引):
|
||
- `INNER JOIN` on 未索引列 → a1–b1 + a2–b2(SQL 只应 a2–b2)
|
||
- `INNER JOIN` on 已索引列 → 仅 a2–c2(正确)
|
||
- `LEFT JOIN` on 未索引列 → a1–b1、a2–b2(SQL 应为 a1–NULL、a2–b2)
|
||
- `LEFT JOIN` on 已索引列 → a1–NULL、a2–c2(正确)
|
||
- 根因:两套连接实现各自解释 NULL;`$col` 比较缺三值逻辑,哈希键用 `'\0'` 哨兵代替 NULL(还可能把 NULL 与字符串 "\0" 混同)。
|
||
- 复现:同一份数据建 3 张表(右表索引与否不同)跑同一条 JOIN。
|
||
|
||
**P1-11 派生表(FROM 子查询)的别名限定:WHERE 恒空、投影出空对象** **[已验证]**
|
||
- 行号:`executor.ts:297-307`(非 JOIN 派生路径不加前缀、不剥别名,只在 303-307 对 where 做 `resolveSubqueries` 后 `matchWhere`);`where-matcher.ts:214-228`(`projectColumns` 的 `key.endsWith('.'+col)` 对 `col='d.age'` 永不成立)
|
||
- 现象:`SELECT * FROM (SELECT id, age FROM users) d WHERE d.age > 25` → `[]`(应为 2 行);`SELECT d.age FROM (SELECT id, age FROM users) d` → `[{},{},{}]`;去掉前缀(`WHERE age > 25`)才正确。
|
||
- 根因:外层 WHERE/投影里的 `d.age` 从未被映射到派生行的裸键;JOIN 分支因为先 `prefixRow(row, stmt.alias ?? '')`(301)才"看起来正常",但别名缺省时会生成 `.col` 这种坏键。
|
||
- 复现:见上。
|
||
|
||
**P1-12 JOIN 查询里的"未限定列名"静默失效(WHERE 空集 / GROUP BY 并成一组 / ORDER BY 不排序)** **[已验证]**
|
||
- 行号:`executor.ts:317-336`(别名剥离只发生在非 JOIN 分支)、`447-451`(JOIN 行键全部带前缀)、`379`(排序按裸键取不到值)
|
||
- 现象:
|
||
- `SELECT u.id FROM u INNER JOIN o ON u.id=o.user_id WHERE dept = 'Eng'` → `[]`(应为 3 行)
|
||
- `SELECT dept, COUNT(*) AS c FROM u INNER JOIN o ON u.id=o.user_id GROUP BY dept` → `[{c:4}]`(应为 Eng=3、Sales=1;所有行落进 `undefined` 组)
|
||
- `SELECT users.name FROM users INNER JOIN departments … ORDER BY name DESC` → 顺序未变(静默不排序)
|
||
- `SELECT name FROM users JOIN departments …` → 静默取 `users.name`(SQL 应报歧义;`projectColumns` 取第一个 `endsWith('.name')` 的键)
|
||
- 根因:列标识在不同路径下分别是"裸名/前缀名",匹配全靠字符串;JOIN 路径没有做"未限定名→唯一候选列"的解析。
|
||
- 复现:见上。
|
||
|
||
**P1-13 DISTINCT 作用在"原始行"而不是"投影后的行"** **[已验证]**
|
||
- 行号:`executor.ts:290-295 + 352`(有 `AS` 别名或 CASE → `plan.columns=['*']`)→ `364`(DISTINCT 在 `383-386` 投影之前)
|
||
- 现象:employees(1,Eng),(2,Eng),(3,Sales):`SELECT DISTINCT dept AS d FROM e` → 3 行(应为 2);`SELECT DISTINCT g AS k FROM t ORDER BY k DESC` → `[b,a,a]`(应为 b,a)。
|
||
- 根因:去重键用整行 `Object.values(row)`(689),把未被投影的 id 也算进去。
|
||
- 复现:见上(注意 `SELECT DISTINCT dept FROM e`(无别名)因为引擎已投影所以是对的 → 又是路径分叉)。
|
||
|
||
**P1-14 UNION:语句级 ORDER BY/LIMIT 挂到最后一个 SELECT 上;列数不校验;空左操作数改变结果列名** **[已验证]**
|
||
- 行号:`sql/parser.ts:366-388 + 394-409`(`parseSelect` 先吃掉 ORDER BY/LIMIT,再在 386 判 UNION,于是尾部的 ORDER BY/LIMIT 属于**右侧** SELECT);`executor.ts:153-200`(`executeSelectUnion` 全程不看 orderBy/limit/offset,也不做 maxRows 保护);`158`(`leftCols` 取自左结果第一行)、`192-200`(`projectUnionRow` 按位置映射、多余列丢弃)
|
||
- 现象:
|
||
- AST 实证:`SELECT v FROM t1 UNION SELECT v FROM t2 ORDER BY v LIMIT 2` → `right = {orderBy:[v], limit:2}`、`left` 无排序无限制
|
||
- `SELECT v FROM t1 UNION SELECT v FROM t2 ORDER BY v` → `[c,a,b,d]`(乱序);`… LIMIT 2` → 4 行;`UNION ALL … LIMIT 2` → 4 行
|
||
- `SELECT v FROM t1 UNION SELECT v, id FROM t2` → 不报错(SQL 应报列数不匹配)
|
||
- 左侧为空时 `leftCols=[]` → 右侧行保持自己的列名:`SELECT v FROM t1 WHERE id<'0' UNION SELECT amount FROM t2` → `[{amount:42}]`(左侧非空时为 `[{v:'x'},{v:42}]`)
|
||
- 根因:UNION 没有自己的 ORDER BY/LIMIT 承载结构(ast.ts:153-161 的 `SelectUnionStatement` 无 orderBy/limit 字段),执行器也不对合并集合做收尾。
|
||
- 复现:见上。
|
||
|
||
**P1-15 `SELECT <数字常量>` 投影出空对象** **[已验证]**
|
||
- 行号:`sql/parser.ts:1044-1049`(数字常量作为列文本 '1')→ `executor.ts:1017-1042`(`projectRow` 只处理 `*` / CASE / `AS` / 字符串常量,没有数字分支)→ `1046` `projectColumns` 找不到 '1' 键 → `{}`
|
||
- 现象:`SELECT 1 FROM t` → `[{},{}]`;`SELECT 1 AS one FROM t` → `[{}]`(别名分支取 `row['1']` → undefined);`SELECT id, 1 AS one FROM t` → `[{id:'1'},{id:'2'}]`(常量列消失)。
|
||
- 备注:EXISTS 子查询只关心行数所以掩盖了这个问题;注释(parser.ts:1044)明确把它当特性。
|
||
- 复现:见上。
|
||
|
||
**P1-16 标量子查询:多行不报错(静默取第一行)、空结果被当 NULL 值匹配** **[已验证]**
|
||
- 行号:`executor.ts:1425-1431`
|
||
- 现象:`SELECT id FROM u WHERE age = (SELECT age FROM u)`(3 行年龄)→ 返回 id=1(SQL 应报 "subquery returned more than one row");`age > (SELECT age FROM u)` → 取第一行年龄比较;`age = (SELECT age FROM u WHERE id='nope')`(空)→ `$eq: null` → 返回 age IS NULL 的行(SQL 0 行)。
|
||
- 根因:标量语义被实现为"第一行第一列/空则 null",没有基数检查,也没有 UNKNOWN 语义。
|
||
- 复现:见上。
|
||
|
||
**P1-17 AND/OR 无优先级(左结合)** **[已验证]**
|
||
- 行号:`sql/parser.ts:780-797`(`while (AND|OR)` 逐个子条件左折,不区分优先级)
|
||
- 现象:AST 实证 `a = 1 OR b = 1 AND id = 'r2'` → `{$and:[{$or:[a,b]}, {id:'r2'}]}`(SQL 应为 `a OR (b AND id)`);数据 r1(a=1,b=0)、r2(a=0,b=1)、r3(a=0,b=0) 时返回 `[r2]`(应为 r1、r2)。
|
||
- 根因:`parseCondition` 缺少 `parseOr → parseAnd → parseSimple` 分层。
|
||
- 复现:见上。影响所有引擎与所有语句(WHERE/ON/HAVING 共用 `parseCondition`)。
|
||
|
||
**P1-18 GROUP BY 下"带别名的非聚合列"被静默改名或整个丢弃** **[已验证]**
|
||
- 行号:`executor.ts:631-648`(`colExpr` 与 `stmt.groupBy` 做**字符串全等**比较;不匹配就走 646 的 `aggregated[colExpr] = groupRows[0][colExpr]`)
|
||
- 现象:
|
||
- `SELECT dept AS d, COUNT(*) AS c FROM e GROUP BY dept` → `[{dept:'Eng',c:2}]`(别名 d 丢失,键变成 dept)
|
||
- `SELECT salary AS s, COUNT(*) AS c FROM e GROUP BY dept` → s 列**整列消失**(值为 undefined,JSON 序列化时被抹掉,无报错)
|
||
- `ORDER BY d`(分组列的别名)静默不排序
|
||
- 根因:组行的键空间是"SELECT 列表字符串 + groupBy 字符串"的拼接,别名/前缀/空白只要不一致就落到 first-row 分支;`ORDER BY` 在投影前按裸键取值(379)。
|
||
- 复现:见上。
|
||
|
||
**P1-19 `maxRowsPerQuery` 会静默截断 `INSERT … SELECT` 的源数据(静默丢数据)** **[已验证]**
|
||
- 行号:`executor.ts:707`(`executeInsert` 用 `executeSelectPart` 取源行)→ `396-398`(`maxRowsPerQuery` 截断在 `executeSelect` 内)
|
||
- 现象:`maxRowsPerQuery: 2` 时,`INSERT INTO b SELECT id FROM a`(a 有 4 行)**只插入 2 行且不报错**。
|
||
- 根因:行数上限保护放在"查询结果"层,而读路径同时被写语句复用,没有区分"面向用户的结果集"与"内部行源"。
|
||
- 复现:配置 maxRowsPerQuery=2,见上。
|
||
|
||
---
|
||
|
||
### P2 — 边界与健壮性
|
||
|
||
| # | 文件:行号 | 现象 / 根因 | 状态 |
|
||
|---|---|---|---|
|
||
| P2-1 | `executor.ts:645-647` | `SELECT dept, salary FROM e GROUP BY dept` 返回每组**第一行**的 salary(MySQL 式 ANY_VALUE),标准 SQL 应报错;结果依赖扫描顺序 → 引擎相关 | [已验证] |
|
||
| P2-2 | `executor.ts:383-386 + 447-451` | JOIN 查询 `SELECT *` 返回 `users.id`/`departments.name` 这类**带前缀列名**,与单表 `SELECT *` 的裸列名不一致 | [已验证] |
|
||
| P2-3 | `where-matcher.ts:220-225` | 未限定列名在 JOIN 中静默取"第一个后缀匹配"的列(歧义应报错) | [已验证] |
|
||
| P2-4 | `executor.ts:591-595, 602-609` | LEFT/RIGHT 的 NULL 补行用**第一行的键集**(`rightRows[0]`/`leftRows[0]`)构造,键集异质(ALTER ADD/DROP 后、空表)时补行缺列;RIGHT 未匹配行被追加在结果末尾(顺序非 SQL 语义) | [读码确认] |
|
||
| P2-5 | `executor.ts:1169-1174` + `766-777` | `hasCorrelatedRefs` 把**任何** `$exists` 都判为关联 → `DELETE FROM t WHERE EXISTS (SELECT 1 FROM u)`(完全不相关)抛 `NOT_SUPPORTED`;同时非关联 EXISTS 被逐行重复执行(见 P3-2) | [已验证] |
|
||
| P2-6 | `builder.ts:105-112` + `memory.ts:192-210` | QueryBuilder 非 JOIN 路径直通 `engine.find`,**find 没有 `containsUnresolvedSubqueries` 防护**(只有 update/delete 有)→ `.where({id:{$eq:{$col:'v'}}})` 静默返回 `[]` | [已验证] |
|
||
| P2-7 | `executor.ts:305,317,322-336,367,375,440,651` | 就地改写调用方的 AST(where/columns/orderBy/groupBy),并在 `stmt` 上挂 `_aggAliasMap` 侧信道 → 同一 AST 二次执行(`toAST()` 复用、EXPLAIN + 执行)语义漂移 | [读码确认] |
|
||
| P2-8 | `engine/aria/index.ts:1223,2028` vs `1998,2005` | Aria 主键类型随访问路径翻转:全表/范围扫描 `key.slice()` → 字符串;`$eq` 快速路径用查询字面量原类型 → 同一列在 `SELECT *` 与 `WHERE id = 3` 下分别是 `'3'` 和 `3`;memory/disk/hybrid 恒为原类型 | [已验证] |
|
||
| P2-9 | 无 SQL 层保证 | 无 ORDER BY 时行序引擎相关(memory/disk/hybrid 插入序 vs aria 主键序)→ `LIMIT/OFFSET` 选出的行引擎间不一致;README"已知限制"未提 | [已验证] |
|
||
| P2-10 | `where-matcher.ts:18-28` | `LIKE` 编译为正则带 `i` 标志 → **大小写不敏感**('C%' 命中 'c');与 PostgreSQL 不同、与 SQLite ASCII 行为相近,但没有任何文档说明 | [已验证] |
|
||
| P2-11 | `executor.ts:665-670` | `COUNT(DISTINCT *)` 在第 666 行就 `return rows.length`,DISTINCT 被忽略(SQL 应语法报错) | [已验证] |
|
||
| P2-12 | `executor.ts:668` | `COUNT(DISTINCT col)` 用 `String(v)` 去重(无类型前缀)→ `1` 与 `'1'`、`true` 与 `'true'` 合并;与 v0.7.4 专门引入 `encodeGroupKey` 的理由自相矛盾 | [读码确认] |
|
||
| P2-13 | `core.ts:218-241` | 分号多语句**逐条执行、无事务**,失败时前面的语句已提交(实测 `INSERT; INSERT` 第二条 DUPLICATE_KEY,第一条留存);返回值只有最后一条结果 | [已验证] |
|
||
| P2-14 | `executor.ts:829-841` | ALTER DROP COLUMN 的通用路径靠"`engine.find` 返回行引用,直接 `delete row[col]`"清理数据;注释自认依赖 Memory 的引用语义 → 换引擎即静默失效(Aria 走引擎 alterTable 才没事) | [读码确认] |
|
||
| P2-15 | `sql/parser.ts:828-833` | `(CASE … END) = 'x'` PARSE_ERROR(括号分支返回内层条件后不再继续解析运算符);`ORDER BY COUNT(*)`、`(SELECT …) > 0`、任何算术表达式(`a+1`、`n/0`)都 PARSE_ERROR → executor 里为 CASE/表达式准备的分支(1010-1063、1240-1266)只能被很窄的语法触达 | [已验证] |
|
||
| P2-16 | `executor.ts:1070-1072` | `_hasAggregateColumn` 要求列文本**以聚合名开头** → `SELECT CASE WHEN COUNT(*) > 1 THEN …` 不触发聚合路径;组内 `evaluateCase` 把 `COUNT(*)` 当列引用(`resolveCaseValue` 95-97)→ null | [读码确认] |
|
||
| P2-17 | `executor.ts:95-97` | CASE 的 THEN/条件里的列引用按**精确键**取(`row[v]`)→ JOIN 场景下未限定列取不到(`THEN id` 在带前缀行里是 undefined → null) | [读码确认] |
|
||
| P2-18 | `executor.ts:1269-1288` | `caseConditionMatches` 对未知操作符 `default: break`(静默视为满足),与 v0.7.2 让 `matchOperator` 抛 `QUERY_ERROR` 的硬化方向不一致 | [读码确认] |
|
||
|
||
---
|
||
|
||
### P3 — 性能
|
||
|
||
| # | 文件:行号 | 问题 |
|
||
|---|---|---|
|
||
| P3-1 | `executor.ts:429`、`569-612` | JOIN 非哈希时**每张右表整表物化**再嵌套循环,每对候选都 `{...l,...r}` 造对象(585);RIGHT JOIN 再全扫一遍右表(598-610)→ O(N·M) 两遍 |
|
||
| P3-2 | `executor.ts:1226-1238` | `filterCorrelated` 外层**每行执行一次子查询**(含完全不相关的 EXISTS,因 1169-1174 的过宽判定),无缓存/去相关 |
|
||
| P3-3 | `executor.ts:379` + `388-390` | ORDER BY 别名场景排序两次,第一次作用在投影前的行上(键全 undefined)纯属浪费 |
|
||
| P3-4 | `memory.ts:195-208`、`aria/index.ts:686-704` | 同样的 order/limit/project 在引擎里做一遍、executor 再做一遍(复制 + 排序 + 切片 ×2) |
|
||
| P3-5 | `where-matcher.ts:16-28` | `likeCache` 是**模块级无上界 Map**,动态(用户输入)LIKE 模式持续增长 → 内存泄漏 |
|
||
| P3-6 | `executor.ts:211-215` | `EXPLAIN SELECT` **真执行查询**;`estimatedRows` 就是真实行数;`EXPLAIN` 还会跑 `resolveWriteWhere`(218-223)触发子查询执行 |
|
||
| P3-7 | `executor.ts:153-200, 297-307`;`core.ts:307-324` | UNION / 派生表 / 任何带 JOIN、ORDER BY、DISTINCT、聚合的查询都全量物化;`queryStream` 只对最简 SELECT 走真流式 |
|
||
| P3-8 | `executor.ts:677-678` | `Math.min(...arr)` 展开实参:O(n) 栈 + 崩溃(P0-1) |
|
||
| P3-9 | `executor.ts:1010-1063`、`59-83` | 投影表达式**逐行重新正则解析**(`parseCaseExpression` 每行每列一次)、`projectColumns` 未命中时对每行键做 O(cols×keys) 扫描;无预编译投影/表达式 |
|
||
| P3-10 | `executor.ts:31-39, 37` | `encodeGroupKey` 对 object 值 `JSON.stringify`(每行每值),分组/去重键无长度前缀 |
|
||
| P3-11 | `executor.ts:531-538` | 哈希连接把所有左表探测值(可能百万级)一次性放进单个 `$in` 查询,无分块 |
|
||
| P3-12 | `executor.ts:457-471` | WHERE 下推只覆盖"主表别名前缀 + 普通等值",其余(未限定列、$and 内部非等值、JOIN 表条件)全部在内存里逐行过滤 |
|
||
|
||
---
|
||
|
||
### 疑似(未实跑,读码推断)
|
||
|
||
- **S-1 分组/去重键可被伪造**:`encodeGroupKey` 各列值用 `'\x1f'` 连接且无长度前缀/转义(`executor.ts:621/689/170/178`),字符串值自身含 `'\x1f'` 时不同列组合可撞键(如 `('a\x1fss','b')` 与 `('a','ss\x1fb')`)→ DISTINCT/GROUP BY/UNION 误合并。哈希连接键(543/553)与 `$in` 探测用 `'\0'` 哨兵,同类问题且会把 NULL 与真实 `"\0"` 混同。
|
||
- **S-2 json 列分组**:`JSON.stringify` 的对象键顺序不同 → 逻辑相等的对象落入不同组(37)。
|
||
- **S-3 行键顺序敏感**:DISTINCT/UNION 用 `Object.values(row)`(689/170),同值但键序不同的行(ALTER ADD/DROP 后、异质行)不会去重。
|
||
- **S-4 Aria 索引键 `String()` 归一**:`1`/`'1'`、`true`/`'true'` 在二级索引里同一键(aria/index.ts:2045/2062);当前只造成多余候选(后续 `matchWhere` 会过滤),但语义上是类型混同。
|
||
- **S-5 `hasSelectAlias` 正则误判**:`/\s+AS\s+\w+$/i`(executor.ts:295)对含 " AS " 的列文本可能误判(已实测字符串常量 `'a AS b'` 不会误判,因为结尾是引号;但其它未加引号文本未穷举)。
|
||
- **S-6 混合类型排序**:`compare` 回退 `localeCompare(String(a),String(b))`(where-matcher.ts:207)→ 数字/字符串混合列按字典序、且依赖 locale。
|
||
- **S-7 哈希连接列对方向**:`keyIsLeft` 只看 `mainAlias` 前缀(executor.ts:508-512),第二个 JOIN 的 ON 若引用前一个 JOIN 表,`pairs` 会左右颠倒;目前通常因"取值列表为空"回退嵌套循环(531-532),但这是巧合而非保证。
|
||
- **S-8 `$like` 未处理 `\` 转义与 `%`/`_` 字面量**(where-matcher.ts:21-24 先转义正则字符再替换通配符;无 ESCAPE 支持),且正则无回溯保护 → 恶意模式(如 `%a%a%a%…`)可能触发灾难性回溯。
|
||
|
||
---
|
||
|
||
## 3. 性能议题(汇总)
|
||
|
||
1. **JOIN 是"整表物化 + 嵌套循环"**(P3-1):右表无条件全量读入(executor.ts:429),只有"ON 为纯等值 + 右表有索引"时才退化为一次 `$in` 探测的哈希连接(480-566)。没有块嵌套、没有排序合并、没有把 WHERE 下推到连接前(除了主表别名等值那一种)。
|
||
2. **完全不做投影下推**:`needsRawRows || hasSelectAlias` 时 `plan.columns=['*']`(352),GROUP BY/聚合路径也强制 `['*']`(340/351)→ 引擎把整行读出来再由 JS 投影/分组。列存式裁剪无从谈起。
|
||
3. **LIMIT 下推错位**(P1-2):本该减少工作量的下推反而造成错误结果;正确做法是只在"无 DISTINCT/GROUP BY/ORDER BY 别名"时下推。
|
||
4. **重复劳动 ×2**:引擎与 executor 各排一次序、各切一次片、各投影一次(P3-4、P3-3)。
|
||
5. **关联子查询逐行执行**(P3-2):N 行 = N 次子查询(每次都是一次完整 SELECT),且**不相关的 EXISTS 也被当成相关**。
|
||
6. **全量物化点**:UNION、派生表、ORDER BY 别名、DISTINCT、GROUP BY、JOIN 全部先物化再处理(P3-7);`queryStream` 只在最简 SELECT 上真流式。
|
||
7. **无上界增长**:`likeCache`(P3-5)、哈希连接 `$in` 值数组(P3-11)、分组 Map(每行都进 `groups`,输出组数无上限)、`maxRowsPerQuery` 是唯一的结果集上限(且会误伤 INSERT…SELECT,P1-19)。
|
||
8. **逐行重解析**:表达式/别名/CASE 的文本解析在行循环里(P3-9);`executor.ts:59` 的 CASE 正则每个 CASE 列每行跑一次,且用 `[\s\S]*?` 惰性匹配 + `exec` 循环。
|
||
9. **EXPLAIN 变成真执行**(P3-6):成本翻倍,且给不出真实估算(`estimatedRows` = 实际行数)。
|
||
10. **错误路径上的性能悬崖**:P0-1 的 `Math.min(...)` 同时也是 O(n) 实参构造;索引命中后仍要全条件 `matchWhere`(memory.ts:197-199 / aria:687-689),等值索引查出的行本可跳过该列比较。
|
||
|
||
---
|
||
|
||
## 4. 系统性观察:为什么这一层反复出 bug,哪里一刀能砍掉一整类
|
||
|
||
1. **同一语义有两份实现,且按"查询长什么样"分流**
|
||
- 有 JOIN → executor;无 JOIN → 引擎(builder.ts:99-113 与 executor.ts:311-355)。
|
||
- 有 `AS`/CASE → 引擎不投影、executor 投影;否则引擎投影(executor.ts:290-295/352)。
|
||
- ORDER BY 用别名 → 清空 plan 的 order/limit;否则把 limit 下推(353)。
|
||
- 这些分流处**每一个**都对应上面的一个 P1(P1-1/2/9/12/13 全是"同一个 SQL 换条路径就对")。LIMIT 下推是典型:JOIN 路径对、单表路径错。
|
||
- **一刀**:把"扫描→过滤→连接→分组→去重→投影→排序→截断"写成一条固定管线,且 **limit/offset/projection 只在一个地方执行**;引擎退化为"只提供带索引的行迭代器 + WHERE 匹配"。
|
||
2. **没有 NULL / 三值逻辑模型**
|
||
- `where-matcher` 用 JS `===`,哈希连接用 `String(v ?? '\0')`,`$in/$nin` 用 `includes`,分组键另起一套 `encodeGroupKey`,`COUNT(DISTINCT)` 又用 `String(v)`。P1-5/6/10 以及 S-1/S-4 都是同一根因的不同外显。
|
||
- **一刀**:引入唯一的 `sqlEquals/sqlCompare`(三态:TRUE/FALSE/UNKNOWN)+ 唯一的值编码(含 NULL 与类型)。所有比较、IN/NOT IN、JOIN 键、分组键、DISTINCT 键都调它 → NULL 类 bug 整类消失。
|
||
3. **列/表达式身份是"字符串",没有绑定阶段**
|
||
- 列是文本:`'dept AS d'`、`'COUNT(o.id)'`、`'CASE … END AS k'`;于是别名要在 6 处正则里再解析(295/633/977/1017/1027/1079),GROUP BY 靠字符串全等比较(645),HAVING 靠字符串映射(369-376),表前缀靠 `startsWith` 剥(1149-1156)。P1-9/18、P2-3/16/17 都源于此。
|
||
- **一刀**:解析期把列解析成 `{table?, column|expr, alias}` 并在编译期绑定到输出序号;GROUP BY/ORDER BY/HAVING 全部按"列序号"而非字符串对齐。
|
||
4. **行编码是"临时约定的键字符串"**
|
||
- JOIN 前缀键(447-451)、裸键、`undefined` vs `null`、`Object.values` 顺序、引擎投不投影 —— 全都会改变分组/去重/排序结果(P1-12/13、P2-4、S-3)。
|
||
- **一刀**:行 = 定长元组 + 列清单(schema of the projection),投影/去重/排序按**位置**进行;"前缀"只存在于连接阶段的符号表里,不进入结果行。
|
||
5. **关联子查询靠"语法嗅探 + 特殊通道"**
|
||
- `hasCorrelatedRefs`(1159-1181)是纯文本检测,命中就把整条 WHERE 丢进逐行路径;而 `$exists` 与 `$in`/标量走了两条不同的执行分支(1351-1356 vs 1417),其中一条忘了传外层行 → P1-4;过宽判定 → P2-5/P3-2;`stripCorrelatedExists`(1205-1223)想"先粗筛再精筛"却漏了 `$col` → P1-3。
|
||
- **一刀**:统一的表达式求值器携带 `outer row` 参数(子查询执行时传入),WHERE 的求值=对每行调用同一函数;不需要"是否相关"的预判,也就没有漏传上下文/粗筛过滤光的问题。
|
||
6. **SQL 子句顺序没有被编码**
|
||
- 代码里的顺序是"聚合→分组→DISTINCT→HAVING→排序→投影→再排序→截断",而 SQL 是 FROM→WHERE→GROUP→HAVING→SELECT→DISTINCT→ORDER→LIMIT。HAVING 在投影后求值导致 P1-7/18,DISTINCT 在投影前导致 P1-13,LIMIT 下推到扫描导致 P1-2。
|
||
- **一刀**:把上面那条标准顺序写成显式的 stage 列表(每个 stage 有明确的输入/输出列集),任何新特性只需插到正确位置。
|
||
7. **测试固化的是"长度/不抛错"而不是值**
|
||
- `tests/join.test.ts:57-99` 只断言行数(注释里甚至写 "column projection may vary");`tests/v073-fixes.test.ts:267-278` 比较两条同样错的路径;`groupby.test.ts` 只测 HAVING 引用了 SELECT 里已有的聚合。
|
||
- 结果:P1-3/7/12/13/14 这类"结果错但不崩溃"的语义长期存活,且每次 CHANGELOG 的"P1 修复"都只在某一条路径上打补丁(v0.7.4 修了 queryStream/写路径的子查询,却留下 P1-4 的关联 IN)。
|
||
- **建议**:补"值级 golden 测试",重点覆盖 NULL/三值逻辑、别名与未限定列、UNION + ORDER/LIMIT、LIMIT/OFFSET(含 OFFSET>0 与左侧为空的 UNION)、空集聚合、以及**同一 SQL 在 join/非 join、memory/aria 两种路径下的结果一致性**。
|
||
|
||
### 优先级建议(若要排修复顺序)
|
||
|
||
1. P0-1(崩)→ P1-19(静默丢数据)→ P1-1(OFFSET 结果错)→ P1-5/6(NULL 三值逻辑)→ P1-4/3(关联子查询静默空)→ P1-17(AND/OR 优先级,影响面最大且最易修)→ P1-7/8/13/14/18(聚合与集合语义)→ P1-9/11/12(路径分叉)→ P1-15/16。
|
||
2. 结构性投入(收益最大):统一值比较/编码(第 2 条)+ 列绑定(第 3 条)+ 单条执行管线(第 1、6 条)。这三件事落地后,本报告 P1 中的绝大多数会作为"整类"消失,而不是逐条打补丁。
|