Files
MetonaSqlark/AUDIT-query-layer-v0.7.4.md
T
thzxx c8b59bd16f docs: v0.7.4 全量深度审计 + v0.8.0 根治性迭代方案
- 全源码逐行通读(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 证据
- 全部关键结论经可执行探针实测复现(探针已删除,仓库无残留)
2026-09-14 20:37:44 +08:00

347 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 1300 行。
方法:先逐行读码定位可疑路径,再写临时 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/offset353**,留到投影后重排。
后处理顺序(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` 执行子查询置换为 boolean1345-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 / RIGHT486),以及 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)` 索引键 + 前缀 rangeScanaria/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_ERRORparser 只在 `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、3SQL 只应 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)` → 返回 2SQL 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-5SQL 中 `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 而非 NULLMIN/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 未索引列 → a1b1 + a2b2SQL 只应 a2b2
- `INNER JOIN` on 已索引列 → 仅 a2–c2(正确)
- `LEFT JOIN` on 未索引列 → a1b1、a2b2SQL 应为 a1NULL、a2b2
- `LEFT JOIN` on 已索引列 → a1NULL、a2c2(正确)
- 根因:两套连接实现各自解释 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=1SQL 应报 "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` 返回每组**第一行**的 salaryMySQL 式 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` | 就地改写调用方的 ASTwhere/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…SELECTP1-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)。
- 这些分流处**每一个**都对应上面的一个 P1P1-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/18DISTINCT 在投影前导致 P1-13LIMIT 下推到扫描导致 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-1OFFSET 结果错)→ P1-5/6NULL 三值逻辑)→ 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 中的绝大多数会作为"整类"消失,而不是逐条打补丁。