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

38 KiB
Raw Blame History

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.tstests/{join,subquery,groupby}.test.tstests/v07{0,1,2,3,4}-*.test.tstests/{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 bindParameterssql/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.findbuilder.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 如何逐子句处理(executeSelectexecutor.ts:282-400

决策树(282-356):

  1. fromSubquery(派生表)→ 先跑子查询取行;有 JOIN 则给行加 <别名>. 前缀后进入 JOIN 路径(297-307)。
  2. 无 FROM 且无 JOINSELECT 1)→ 单行空上下文(308-310)。
  3. 有 JOIN → executeJoinSelect311-313)。
  4. 普通单表(314-355):先把 WHERE / ORDER BY / GROUP BY / SELECT 列里的主表别名前缀剥掉(316-336);若 WHERE 命中 $col,走 filterCorrelated 逐行求值(339-345),否则 resolveSubqueries 后交给 engine.find346-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 子查询 / 关联引用

  • resolveSubqueries1336-1385)递归遍历 WHERE$exists 执行子查询置换为 boolean1345-1356);$and/$or/$not 递归;字段级交给 resolveOperatorSubqueries1390-1439)。
  • resolveOperatorSubqueries$in/$nin 取子查询第一列的值列表(1419-1423),其它运算符取第一行第一列(1425-1431);空结果标量 → null
  • $col 绑定:bindColumnRefs1291-1309)从 contextRow 取列值。
  • 关联上下文只有一条通路:hasCorrelatedRefs1159-1181,纯语法嗅探)→ filterCorrelated1226-1238)逐行 bindWhereRefs + 执行 $existsresolveOperatorSubqueriesexecuteSelect(subStmt) 时(1417)不传外层行,所以 IN (SELECT … WHERE 内层列 = 外层列) / 标量关联子查询拿不到外层上下文(见缺陷 P1-4)。
  • 代价:关联路径 = 外层每行一次子查询执行,无缓存(1228-1236)。

1.4 JOIN 实现

  • 主表:整表(或仅主表别名等值条件下推)物化并加前缀(408-418);非 JOIN 分支不做前缀。
  • 每个 JOINtryHashJoin480-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.col447-451),普通行为裸列名。
  • 投影 projectRow1010-1063):逐行用正则重新解析 *col AS alias、字符串常量、CASE…ENDprojectColumnswhere-matcher.ts:214-228)先精确匹配键,否则 key.endsWith('.'+col) 兜底。
  • 分组/去重键 encodeGroupKey31-39):类型前缀(n/u/s/d/b/o/x),以 \x1f 连接(621 / 689 / 170 / 178)。
  • 排序 applyOrderBywhere-matcher.ts:183-208):逐键比较;显式 NULLS FIRST/LAST 时 NULL 位置固定,未指定时 NULL 视为最大值(升序在末尾、降序在开头 = PostgreSQL 语义);类型不同回退 localeCompare(String(a), String(b))
  • 哈希连接、$in 探测、COUNT(DISTINCT) 不复用 encodeGroupKey543/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 引擎内 slicememory.ts:203-205 引擎内 slicearia/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 big20 万行同组)抛 RangeError: Maximum call stack size exceededSUM/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-393executor 再切一次);compiler.ts:40-41limit/offset 进 plan);engine/memory.ts:203-205engine/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.find354),引擎 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:352plan.columns=['*'] 但保留 limit+ 364DISTINCT 在切片之前)+ 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(关联路径)→ 344stripCorrelatedExists(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-651executeGroupBy 只为 stmt.columns 里的聚合表达式算值并写入组行)、365-378HAVING 用 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_aggAliasMap626-651)只做"表达式键→别名键"的重命名,不能补算缺失聚合。
  • 复现:employees(id,dept,salary) 5 行,执行上述两条。

P1-8 聚合的 NULL/类型语义:空集/全 NULL 返回 0 而非 NULLMIN/MAX 走 Number() 数值化 [已验证]

  • 行号:executor.ts:660-680argCol 原样取列;663 过滤 null672 rawValues.map(Number)675-678 SUM 初值 0、AVG/MIN/MAX 空集返回 0、MIN/MAX 用 Math.min/max
  • 现象:
    • 空集或全 NULLSUM/AVG/MIN/MAX0SQL 为 NULL
    • 文本列:MIN(s)/MAX(s) over ('9','10') → 9 / 10(文本序应为 '10' / '9');列表含 'abc' 时整列变 NaNJSON 序列化为 null
    • MIN(id)/MAX(id) 对字符串主键返回数字
    • AVG(文本列) → NaN
    • 合计:COUNT(col) 跳过 NULL(正确)、COUNT(*) = 行数(正确)
  • 根因:聚合只实现了数值语义;computeAggregate 返回类型硬编码 number(655),没有 NULL 传播、没有"文本聚合 vs 数值聚合"分支。
  • 复现:SELECT MIN(s), MAX(s) FROM ts = '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-150options.$col 分支 value === row[col]NULL===NULL 为真)用在 executor.ts:586/602;哈希路径 executor.ts:531(左值滤掉 null/undefined)、543/553String(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 已索引列 → a1–NULL、a2–c2(正确)
  • 根因:两套连接实现各自解释 NULL$col 比较缺三值逻辑,哈希键用 '\0' 哨兵代替 NULL(还可能把 NULL 与字符串 "\0" 混同)。
  • 复现:同一份数据建 3 张表(右表索引与否不同)跑同一条 JOIN。

P1-11 派生表(FROM 子查询)的别名限定:WHERE 恒空、投影出空对象 [已验证]

  • 行号:executor.ts:297-307(非 JOIN 派生路径不加前缀、不剥别名,只在 303-307 对 where 做 resolveSubqueriesmatchWhere);where-matcher.ts:214-228projectColumnskey.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-451JOIN 行键全部带前缀)、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.nameSQL 应报歧义;projectColumns 取第一个 endsWith('.name') 的键)
  • 根因:列标识在不同路径下分别是"裸名/前缀名",匹配全靠字符串;JOIN 路径没有做"未限定名→唯一候选列"的解析。
  • 复现:见上。

P1-13 DISTINCT 作用在"原始行"而不是"投影后的行" [已验证]

  • 行号:executor.ts:290-295 + 352(有 AS 别名或 CASE → plan.columns=['*'])→ 364DISTINCT 在 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-409parseSelect 先吃掉 ORDER BY/LIMIT,再在 386 判 UNION,于是尾部的 ORDER BY/LIMIT 属于右侧 SELECT);executor.ts:153-200executeSelectUnion 全程不看 orderBy/limit/offset,也不做 maxRows 保护);158leftCols 取自左结果第一行)、192-200projectUnionRow 按位置映射、多余列丢弃)
  • 现象:
    • AST 实证:SELECT v FROM t1 UNION SELECT v FROM t2 ORDER BY v LIMIT 2right = {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-1042projectRow 只处理 * / 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-797while (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-648colExprstmt.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:707executeInsertexecuteSelectPart 取源行)→ 396-398maxRowsPerQuery 截断在 executeSelect 内)
  • 现象:maxRowsPerQuery: 2 时,INSERT INTO b SELECT id FROM aa 有 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.findfind 没有 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'3memory/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.lengthDISTINCT 被忽略(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+1n/0)都 PARSE_ERROR → executor 里为 CASE/表达式准备的分支(1010-1063、1240-1266)只能被很窄的语法触达 [已验证]
P2-16 executor.ts:1070-1072 _hasAggregateColumn 要求列文本以聚合名开头SELECT CASE WHEN COUNT(*) > 1 THEN … 不触发聚合路径;组内 evaluateCaseCOUNT(*) 当列引用(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 让 matchOperatorQUERY_ERROR 的硬化方向不一致 [读码确认]

P3 — 性能

# 文件:行号 问题
P3-1 executor.ts:429569-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-208aria/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 还会跑 resolveWriteWhere218-223)触发子查询执行
P3-7 executor.ts:153-200, 297-307core.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-106359-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+$/iexecutor.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 || hasSelectAliasplan.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. 无上界增长likeCacheP3-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) 实参构造;索引命中后仍要全条件 matchWherememory.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/$ninincludes,分组键另起一套 encodeGroupKeyCOUNT(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 nullObject.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-2stripCorrelatedExists1205-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 中的绝大多数会作为"整类"消失,而不是逐条打补丁。