- 全源码逐行通读(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 证据 - 全部关键结论经可执行探针实测复现(探针已删除,仓库无残留)
38 KiB
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):
fromSubquery(派生表)→ 先跑子查询取行;有 JOIN 则给行加<别名>.前缀后进入 JOIN 路径(297-307)。- 无 FROM 且无 JOIN(
SELECT 1)→ 单行空上下文(308-310)。 - 有 JOIN →
executeJoinSelect(311-313)。 - 普通单表(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),insertMany20 万行,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;672rawValues.map(Number);675-678SUM 初值 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(*) = 行数(正确)
- 空集或全 NULL:
- 根因:聚合只实现了数值语义;
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)\(/的列原样保留,不剥前缀)→662r[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 JOINon 未索引列 → a1–b1 + a2–b2(SQL 只应 a2–b2)INNER JOINon 已索引列 → 仅 a2–c2(正确)LEFT JOINon 未索引列 → a1–b1、a2–b2(SQL 应为 a1–NULL、a2–b2)LEFT JOINon 已索引列 → 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}])
- AST 实证:
- 根因: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/ 字符串常量,没有数字分支)→1046projectColumns找不到 '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. 性能议题(汇总)
- JOIN 是"整表物化 + 嵌套循环"(P3-1):右表无条件全量读入(executor.ts:429),只有"ON 为纯等值 + 右表有索引"时才退化为一次
$in探测的哈希连接(480-566)。没有块嵌套、没有排序合并、没有把 WHERE 下推到连接前(除了主表别名等值那一种)。 - 完全不做投影下推:
needsRawRows || hasSelectAlias时plan.columns=['*'](352),GROUP BY/聚合路径也强制['*'](340/351)→ 引擎把整行读出来再由 JS 投影/分组。列存式裁剪无从谈起。 - LIMIT 下推错位(P1-2):本该减少工作量的下推反而造成错误结果;正确做法是只在"无 DISTINCT/GROUP BY/ORDER BY 别名"时下推。
- 重复劳动 ×2:引擎与 executor 各排一次序、各切一次片、各投影一次(P3-4、P3-3)。
- 关联子查询逐行执行(P3-2):N 行 = N 次子查询(每次都是一次完整 SELECT),且不相关的 EXISTS 也被当成相关。
- 全量物化点:UNION、派生表、ORDER BY 别名、DISTINCT、GROUP BY、JOIN 全部先物化再处理(P3-7);
queryStream只在最简 SELECT 上真流式。 - 无上界增长:
likeCache(P3-5)、哈希连接$in值数组(P3-11)、分组 Map(每行都进groups,输出组数无上限)、maxRowsPerQuery是唯一的结果集上限(且会误伤 INSERT…SELECT,P1-19)。 - 逐行重解析:表达式/别名/CASE 的文本解析在行循环里(P3-9);
executor.ts:59的 CASE 正则每个 CASE 列每行跑一次,且用[\s\S]*?惰性匹配 +exec循环。 - EXPLAIN 变成真执行(P3-6):成本翻倍,且给不出真实估算(
estimatedRows= 实际行数)。 - 错误路径上的性能悬崖:P0-1 的
Math.min(...)同时也是 O(n) 实参构造;索引命中后仍要全条件matchWhere(memory.ts:197-199 / aria:687-689),等值索引查出的行本可跳过该列比较。
4. 系统性观察:为什么这一层反复出 bug,哪里一刀能砍掉一整类
- 同一语义有两份实现,且按"查询长什么样"分流
- 有 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 匹配"。
- 没有 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 整类消失。
- 列/表达式身份是"字符串",没有绑定阶段
- 列是文本:
'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 全部按"列序号"而非字符串对齐。
- 列是文本:
- 行编码是"临时约定的键字符串"
- JOIN 前缀键(447-451)、裸键、
undefinedvsnull、Object.values顺序、引擎投不投影 —— 全都会改变分组/去重/排序结果(P1-12/13、P2-4、S-3)。 - 一刀:行 = 定长元组 + 列清单(schema of the projection),投影/去重/排序按位置进行;"前缀"只存在于连接阶段的符号表里,不进入结果行。
- JOIN 前缀键(447-451)、裸键、
- 关联子查询靠"语法嗅探 + 特殊通道"
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 的求值=对每行调用同一函数;不需要"是否相关"的预判,也就没有漏传上下文/粗筛过滤光的问题。
- SQL 子句顺序没有被编码
- 代码里的顺序是"聚合→分组→DISTINCT→HAVING→排序→投影→再排序→截断",而 SQL 是 FROM→WHERE→GROUP→HAVING→SELECT→DISTINCT→ORDER→LIMIT。HAVING 在投影后求值导致 P1-7/18,DISTINCT 在投影前导致 P1-13,LIMIT 下推到扫描导致 P1-2。
- 一刀:把上面那条标准顺序写成显式的 stage 列表(每个 stage 有明确的输入/输出列集),任何新特性只需插到正确位置。
- 测试固化的是"长度/不抛错"而不是值
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 两种路径下的结果一致性。
优先级建议(若要排修复顺序)
- 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 条)+ 列绑定(第 3 条)+ 单条执行管线(第 1、6 条)。这三件事落地后,本报告 P1 中的绝大多数会作为"整类"消失,而不是逐条打补丁。