From 752bdea97dc3eca3e72a88bfc014f84c23b0d086 Mon Sep 17 00:00:00 2001 From: thzxx Date: Mon, 14 Sep 2026 21:19:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(A5/A6/A7/A16/A18/A24/A28):=20=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E5=B1=82=E8=AF=AD=E4=B9=89=E6=A0=B9=E6=B2=BB=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20LIMIT=20=E5=8F=8C=E9=87=8D=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E3=80=81=E6=9C=AA=E7=9F=A5=E5=88=97=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E3=80=81=E8=81=9A=E5=90=88=E5=B4=A9=E6=BA=83=E4=B8=8E=E7=A9=BA?= =?UTF-8?q?=E9=9B=86=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A6 LIMIT/OFFSET 被应用两次(丢行) compileSelect 无条件下推 limit/offset,引擎切一次,executeSelect 末尾又切一次。 实测 4 行表:LIMIT 2 OFFSET 1 只返回 1 行;LIMIT 10 OFFSET 3 返回空。 JOIN/派生表路径因不走 plan 反而正确,同一 executor 内自相矛盾。 现引入 analyzeSelect() 统一判定 limitPushdownSafe,两条路径互斥且只应用一次。 实测 10 种查询形态(含 DISTINCT/GROUP BY/别名/深 OFFSET/LIMIT 0)全部正确。 A7 queryStream 与 query 结果不一致(四类静默分歧) core.ts 自己重写了一套能否走引擎快路径/如何投影的规则,与 executor 各写一份: SELECT id AS x FROM t query [{x}],stream [{id,v}](全列+原列名) SELECT t.id FROM t query [{id}],stream [{}](空对象) LIMIT 2 OFFSET 1 query 1 行,stream 2 行 LIMIT 0 query 0 行,stream 1 行(Aria 又是 0 行) 且流式路径不触发 beforeQuery/afterQuery、不受 maxRowsPerQuery 约束。 现由 executor.analyzeSelect() 做单一事实来源;不可流式一律回退物化路径; 列引用剥离主表别名前缀;LIMIT 0 短路;回调返回 Promise 时显式报错 (此前用 constructor.name === 'AsyncFunction' 判定,普通函数返回 Promise 会静默丢弃)。 实测 16 种查询形态 × 4 引擎 = 64 组,query 与 queryStream 逐值相等。 A5 MIN/MAX 栈溢出(崩溃) Math.min(...arr) 展开实参:20 万行同组直接 RangeError。改为单次遍历归约 reduceNumeric。 A24 空集聚合语义 SUM/AVG/MIN/MAX 对空集返回 0(使 和为 0 与 无数据 不可区分),改为 SQL 标准的 NULL; COUNT 仍返回 0。 A18 未知列静默产出空对象行 SELECT bogus FROM t 返回 [{},{},...](行数对、内容空、无报错);SELECT NAME(列名 name) 同样静默空。新增 assertProjectionColumnsExist:投影前校验列存在性, 未知列抛 COLUMN_NOT_FOUND;同一后缀命中多个表别名时抛歧义错误。空结果集不误报。 A16 INSERT 列/值个数不校验(静默丢弃/写半行) 显式列名时在解析期校验每行值个数与列数一致: INSERT INTO t (id,name) VALUES ('4','z',9) 此前静默丢弃 9,现报错。 A28 UPDATE SET __proto__ 静默吞掉 sets['__proto__'] 触发原型 setter,sets 变空对象,既不写入也不被未知列预检看到, 表现为返回成功但什么都没发生。parser 的列名映射统一改为 Object.create(null)。 附带修复(A18 验证时发现): SELECT 1 AS one FROM t 返回 [{}] —— parseColumnRef 的数字分支提前 return 吞掉别名; 裸 SELECT 1 同样产出空对象 —— 投影未处理匿名常量列。现按 SQLite 语义以表达式原文为键。 --- src/core.ts | 127 +++++++++++-------- src/query/executor.ts | 285 +++++++++++++++++++++++++++++++++++++++--- src/sql/parser.ts | 72 ++++++++--- 3 files changed, 398 insertions(+), 86 deletions(-) diff --git a/src/core.ts b/src/core.ts index 53af9c3..0361c9e 100644 --- a/src/core.ts +++ b/src/core.ts @@ -257,6 +257,27 @@ export class MetonaSqlark { * }); * ``` */ + /** + * 流式查询:逐行回调,尽量不物化全部结果(大表友好)。 + * + * v0.8.0 根治:**快路径与物化路径的结果必须逐值相等**。 + * + * 此前 core.ts 自己重写了一套"能不能走引擎快路径 / 列投影怎么算"的规则, + * 与 executor 的规则各写一份并发生漂移,实测四类静默不一致: + * SELECT id AS x FROM t → query 返回 [{x}],stream 返回 [{id,v}](全列 + 原列名) + * SELECT t.id FROM t → query 返回 [{id}],stream 返回 [{}](空对象) + * ... LIMIT 2 OFFSET 1 → query 1 行,stream 2 行 + * ... LIMIT 0 → query 0 行,stream 1 行(Aria 又是 0 行,跨引擎也不同) + * 另:流式路径既不触发 beforeQuery/afterQuery 钩子,也不受 maxRowsPerQuery 约束。 + * + * 现在的规则: + * 1. 是否可流式、如何投影,全部由 `executor.analyzeSelect()` 判定(单一事实来源); + * 2. 不可流式(以及任何不确定的情况)一律回退到 `query()` 物化后逐行回调 —— + * 这条路径天然与 `query()` 同语义,是正确性的兜底保证; + * 3. 快路径只覆盖"引擎层投影与 executor 投影语义等价"的简单 SELECT; + * 4. 回调返回 Promise 时不再靠 `constructor.name` 猜(此前对普通函数返回 Promise + * 的情况完全失效),而是直接检测返回值并显式报错,避免 Promise 被静默丢弃。 + */ async queryStream = Record>( sql: string, onRow: (row: T) => void, @@ -275,56 +296,54 @@ export class MetonaSqlark { } const select = stmt as import('./query/ast').SelectStatement; - // 不可流式场景:JOIN / GROUP BY / HAVING / DISTINCT / 聚合 / UNION / 关联子查询 / ORDER BY - const aggregate = select.columns.some((c) => /^(COUNT|SUM|AVG|MIN|MAX)\(/i.test(c)); - // v0.7.3: WHERE 含子查询($subquery / $exists / 嵌套 $col 列引用)不可流式 —— - // 引擎层 matchWhere 的 $in/$nin 遇未解析的 $subquery 对象返回 false → 所有行 - // 被静默过滤(空结果);$col 操作符无对应匹配分支会抛 QUERY_ERROR。 - // 递归检测后回退物化路径(resolveSubqueries 正确解析)。 - const hasSubquery = (where: import('./constants').WhereCondition | undefined): boolean => { - if (!where) return false; - for (const [k, v] of Object.entries(where)) { - if (k === '$and' || k === '$or') { - if ((v as import('./constants').WhereCondition[]).some((sub) => hasSubquery(sub))) return true; - continue; - } - if (k === '$not') { - if (hasSubquery(v as import('./constants').WhereCondition)) return true; - continue; - } - if (k === '$exists') return true; - if (typeof v === 'object' && v !== null) { - for (const [, operand] of Object.entries(v as Record)) { - if (typeof operand === 'object' && operand !== null) { - const ops = operand as Record; - if ('$subquery' in ops || '$col' in ops) return true; - } - } - } - } - return false; - }; - const streamable = !select.joins && !select.groupBy && !select.having && !select.distinct - && !aggregate && !(select.orderBy && select.orderBy.length > 0) - && !hasSubquery(select.where); + // 执行形态由 executor 统一判定(与 query() 路径共用同一规则) + const shape = this.executor.analyzeSelect(select); - if (streamable && typeof this.engine.findStream === 'function') { - // 用户回调为 async(返回 Promise)时引擎同步扫描无法 await → 回退物化 - const isAsync = (onRow as { constructor?: { name?: string } }).constructor?.name === 'AsyncFunction'; - if (!isAsync) { - const where = this.normalizeWhereForStream(select); - const plainCols = select.columns.filter((c) => !/\s+AS\s+\w+$/i.test(c)); - return this.engine.findStream(select.from, { - table: select.from, - columns: plainCols.length > 0 && plainCols[0] !== '*' ? plainCols : ['*'], - where: where && Object.keys(where).length > 0 ? where : undefined, - limit: select.limit, - offset: select.offset, - }, onRow as (row: Record) => void); - } + // LIMIT 0 语义:任何引擎都必须返回 0 行。 + // 引擎对 `limit: 0` 的解释并不一致(Aria 返回 0 行,Memory/KVStore/Hybrid 把 0 当 + // "无限制"返回全部行 —— 实测 LIMIT 0 在四种引擎下分别为 0/1/1/1 行)。 + // 流式路径直接短路,避免依赖各引擎对 0 的解释。 + if (select.limit === 0) return 0; + + if (shape.streamable && typeof this.engine.findStream === 'function') { + const where = this.normalizeWhereForStream(select); + // 与 executor 的非 JOIN 路径一致:剥离主表别名前缀后再交给引擎 + // (executor 对 `SELECT t.id FROM t` 会发 columns=['id'];此前流式路径把 + // 't.id' 原样传给引擎,引擎按 't.id' 建键 → 行里取不到 → 回调收到 {})。 + const mainAliases = [select.alias ?? select.from].filter(Boolean); + const columns = select.columns.length > 0 + ? select.columns.map((c) => this.stripAliasPrefix(c, mainAliases)) + : ['*']; + const maxRows = this.maxRowsPerQuery; + let emitted = 0; + let streamingError: unknown = null; + + const count = await this.engine.findStream(select.from, { + table: select.from, + columns, + where: where && Object.keys(where).length > 0 ? where : undefined, + limit: select.limit, + offset: select.offset, + }, (row: Record) => { + // maxRowsPerQuery 必须与物化路径一致地生效(此前流式路径完全不受约束) + if (maxRows > 0 && emitted >= maxRows) return; + emitted++; + const ret = (onRow as (r: Record) => unknown)(row); + if (ret && typeof (ret as PromiseLike).then === 'function') { + // 同步扫描无法 await 用户回调 —— 显式报错而不是静默丢弃 Promise + streamingError = new DatabaseError( + 'queryStream callback returned a Promise; use await db.query() for async row handlers', + 'NOT_SUPPORTED', + ); + } + }); + + if (streamingError) throw streamingError; + // 引擎返回的行数在 maxRowsPerQuery 截断时需与回调次数一致 + return maxRows > 0 ? Math.min(count, maxRows) : count; } - // 回退:物化后逐行回调 + // 回退:物化后逐行回调(与 query() 完全同语义,含钩子与 maxRowsPerQuery) const result = await this.query(sql); if (Array.isArray(result)) { for (const row of result as T[]) { @@ -335,9 +354,19 @@ export class MetonaSqlark { return 0; } + /** + * v0.8.0: 剥离列引用上的主表别名前缀(`t.id` → `id`)。 + * 与 executor 非 JOIN 路径的 `stripAlias` 语义保持一致。 + */ + private stripAliasPrefix(col: string, aliases: string[]): string { + for (const a of aliases) { + if (a && col.startsWith(`${a}.`)) return col.slice(a.length + 1); + } + return col; + } + /** 流式查询用:剥离主表别名前缀(复用 query 路径的规范化逻辑) */ - private normalizeWhereForStream(select: import('./query/ast').SelectStatement): import('./constants').WhereCondition | undefined { - const aliases = [select.alias ?? select.from].filter(Boolean); + private normalizeWhereForStream(select: import('./query/ast').SelectStatement): import('./constants').WhereCondition | undefined { const aliases = [select.alias ?? select.from].filter(Boolean); const strip = (col: string): string => { for (const a of aliases) { if (col.startsWith(`${a}.`)) return col.slice(a.length + 1); diff --git a/src/query/executor.ts b/src/query/executor.ts index 0b32422..61529a4 100644 --- a/src/query/executor.ts +++ b/src/query/executor.ts @@ -14,8 +14,39 @@ import type { import { DatabaseError } from '../constants'; import { compileStatement } from './compiler'; import { createSchema, astColumnToColumnDef } from '../table/schema'; -import { matchWhere, applyOrderBy, projectColumns } from './where-matcher'; +import { matchWhere, applyOrderBy, projectColumns, containsUnresolvedSubqueries } from './where-matcher'; import { parseWhereCondition } from '../sql/parser'; + +// --------------------------------------------------------------------------- +// SELECT 执行形态(v0.8.0) +// --------------------------------------------------------------------------- + +/** + * 一条 SELECT 的执行形态判定结果。 + * + * 由 {@link QueryExecutor.analyzeSelect} 统一产出,供 executor 自身与 + * `core.queryStream` 共享 —— 避免"两条入口各写一套规则"导致的语义漂移。 + */ +export interface SelectExecutionShape { + /** 有 GROUP BY */ + hasGroupBy: boolean; + /** 无 GROUP BY 但 SELECT 列表含聚合函数 */ + hasAggregate: boolean; + /** 含 JOIN */ + isJoinQuery: boolean; + /** 需要原始行(SELECT 列或 WHERE 含 CASE 表达式) */ + needsRawRows: boolean; + /** ORDER BY 引用了 SELECT 别名(投影后才存在) */ + orderByAlias: boolean; + /** SELECT 列含 `col AS alias` */ + hasSelectAlias: boolean; + /** LIMIT/OFFSET 可安全下推给引擎(否则由 executor 末尾应用一次) */ + limitPushdownSafe: boolean; + /** 引擎层投影与 executor 投影语义等价(列均为裸列引用) */ + engineEquivalentProjection: boolean; + /** 可直连引擎 findStream 做真流式(无任何改变行集合/行序/行形状的阶段) */ + streamable: boolean; +} import type { WhereCondition } from '../constants'; // --------------------------------------------------------------------------- @@ -55,6 +86,59 @@ interface CaseExpression { alias: string | null; } +/** + * v0.8.0: 解析 `expr AS alias` 中的 `expr` 取值来源。 + * + * 此前只处理"字符串常量"与"列引用"两种情况,数字/布尔/NULL 常量会走 + * `row[source]` → undefined,于是 `SELECT 1 AS one FROM t` 返回 `[{}]` + * (键在、值为 undefined,JSON 序列化后整个键消失), + * 而这正是 EXISTS 子查询里最常见的写法(`SELECT 1 FROM ...`)。 + */ +function resolveAliasSource(source: string, row: Record): unknown { + const text = source.trim(); + // 字符串常量(含 SQL 标准 '' 转义还原) + const strLit = text.match(/^'(.*)'$/s); + if (strLit) return strLit[1].replace(/''/g, "'"); + // 数字常量(含负号与小数) + if (/^-?\d+(\.\d+)?$/.test(text)) return Number(text); + if (/^TRUE$/i.test(text)) return true; + if (/^FALSE$/i.test(text)) return false; + if (/^NULL$/i.test(text)) return null; + return row[text]; +} + +/** + * v0.8.0: 数值型聚合的单次遍历实现。 + * + * 为什么替换 `Math.min(...arr)` / `Math.max(...arr)`: + * 展开实参会把整个数组压进调用栈,20 万行同组即 RangeError(栈溢出); + * 而这是普通查询就能触发的崩溃,不是边界场景。 + * + * 空集语义(SQL 标准):SUM/AVG/MIN/MAX 对**空集或全 NULL** 返回 NULL。 + * 此前统一返回 0,使 `SUM(x) = 0` 与"没有数据"不可区分。 + * 注意 COUNT 不在此列 —— COUNT 对空集返回 0(由调用方处理)。 + */ +function reduceNumeric(values: number[], op: 'SUM' | 'AVG' | 'MIN' | 'MAX'): number | null { + if (values.length === 0) return null; + let acc = op === 'SUM' || op === 'AVG' ? 0 : values[0]; + for (let i = 0; i < values.length; i++) { + const v = values[i]; + switch (op) { + case 'SUM': + case 'AVG': + acc += v; + break; + case 'MIN': + if (i > 0 && v < acc) acc = v; + break; + case 'MAX': + if (i > 0 && v > acc) acc = v; + break; + } + } + return op === 'AVG' ? acc / values.length : acc; +} + /** 解析 "CASE WHEN c1 THEN v1 WHEN c2 THEN v2 ELSE v3 END [AS alias]" */ function parseCaseExpression(expr: string): CaseExpression | null { const m = expr.match(/^\s*CASE\s+([\s\S]*?)\s+END\s*(?:AS\s+(\w+))?\s*$/i); @@ -279,21 +363,74 @@ export class QueryExecutor { // SELECT // =================================================================== - private async executeSelect(stmt: SelectStatement): Promise[]> { + /** + * v0.8.0: SELECT 语句的**执行形态分析**(单一事实来源)。 + * + * 为什么把它独立出来:core.queryStream 此前在 core.ts 里**自己重新推导**了一遍 + * "这条 SELECT 能不能走引擎快路径、列投影怎么算",与 executor 的规则各写一份, + * 于是两者漂移出四类静默不一致(实测): + * SELECT id AS x FROM t query=[{x}] stream=[{id,v}](全列 + 原列名) + * SELECT t.id FROM t query=[{id}] stream=[{}](空对象) + * ... LIMIT 2 OFFSET 1 query=1 行 stream=2 行(引擎与 executor 各切一次) + * ... LIMIT 0 query=[] stream=1 行 + * + * 现在由 executor 提供唯一判定,core 只消费结论,不再复制规则。 + */ + analyzeSelect(stmt: SelectStatement): SelectExecutionShape { const hasGroupBy = !!(stmt.groupBy && stmt.groupBy.length > 0); const hasAggregate = !hasGroupBy && this._hasAggregateColumn(stmt.columns); - let rows: Record[]; - const isJoinQuery = !!(stmt.joins && stmt.joins.length > 0); - // CASE WHEN 表达式需要原始列求值(SELECT 列或 WHERE 条件中的 CASE): - // 引擎层取全行,投影统一在 executor 端完成 const needsRawRows = this.hasCaseColumn(stmt.columns) || (!!stmt.where && this.whereHasCase(stmt.where)); - // v0.3.3: ORDER BY 引用 SELECT 别名 → 引擎层不排序/不截断,投影后再排序 const orderByAlias = this.orderByUsesSelectAlias(stmt); - // v0.3.3: SELECT 列含 `col AS alias` → 引擎层投影会丢失源列,统一取原始行由 executor 投影 const hasSelectAlias = stmt.columns.some((c) => /\s+AS\s+\w+$/i.test(c)); + // LIMIT/OFFSET 下推安全性:只有"引擎返回的行 == LIMIT 应当作用其上的行"时才可下推。 + // 此前 compileSelect 无条件下推、executor 末尾又切一次 → LIMIT 被应用两遍 + // (4 行表上 LIMIT 2 OFFSET 1 只返回 1 行)。 + const limitPushdownSafe = !hasGroupBy && !hasAggregate && !isJoinQuery + && !stmt.fromSubquery && !stmt.distinct + && !(stmt.having && Object.keys(stmt.having).length > 0) + && !orderByAlias && !needsRawRows && !hasSelectAlias; + + // 引擎层投影是否与 executor 语义等价:仅当全部列都是裸 * 或简单列引用 + //(可带 table. 前缀)时成立。出现 AS 别名/常量/CASE/聚合就交给 executor 投影。 + let engineEquivalentProjection = stmt.columns.length > 0; + for (const c of stmt.columns) { + if (c === '*') continue; + if (/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(c)) continue; + engineEquivalentProjection = false; + break; + } + + // 真流式(引擎 findStream 直连)的充分条件:所有会改变行集合/行序/行形状的 + // 阶段都不存在,且 WHERE 已解析(无子查询、无 $col 关联引用)。 + const streamable = engineEquivalentProjection + && !hasGroupBy && !hasAggregate && !isJoinQuery && !stmt.fromSubquery + && !stmt.distinct && !(stmt.having && Object.keys(stmt.having).length > 0) + && !(stmt.orderBy && stmt.orderBy.length > 0) + && !needsRawRows && !orderByAlias && !hasSelectAlias + && !this.hasCorrelatedRefs(stmt.where) + && !containsUnresolvedSubqueries(stmt.where); + + return { + hasGroupBy, + hasAggregate, + isJoinQuery, + needsRawRows, + orderByAlias, + hasSelectAlias, + limitPushdownSafe, + engineEquivalentProjection, + streamable, + }; + } + + private async executeSelect(stmt: SelectStatement): Promise[]> { + const shape = this.analyzeSelect(stmt); + const { hasGroupBy, hasAggregate, isJoinQuery, needsRawRows, orderByAlias, hasSelectAlias, limitPushdownSafe } = shape; + let rows: Record[]; + if (stmt.fromSubquery) { // v0.4.0: FROM (SELECT ...) 派生表 — 子查询结果作为行源 const subRows = await this.executeSelectPart(stmt.fromSubquery); @@ -350,6 +487,11 @@ export class QueryExecutor { } const plan = compileStatement(hasGroupBy || hasAggregate ? { ...stmt, columns: ['*'] } : stmt); if (needsRawRows || hasSelectAlias) plan.columns = ['*']; + if (!limitPushdownSafe) { + // 不安全:不把 LIMIT/OFFSET 交给引擎,由末尾统一应用(只应用一次) + plan.limit = undefined; + plan.offset = undefined; + } if (orderByAlias) { plan.orderBy = undefined; plan.limit = undefined; plan.offset = undefined; } rows = await this.engine.find(plan.table, plan); } @@ -377,6 +519,18 @@ export class QueryExecutor { rows = rows.filter((row) => matchWhere(row, stmt.having!)); } if (stmt.orderBy && stmt.orderBy.length > 0) rows = applyOrderBy(rows, stmt.orderBy); + // v0.8.0 根治:投影前校验列引用存在性(此前未知列静默产出 {} 行)。 + // + // 实测缺陷:`SELECT bogus FROM t`(4 行表)返回 `[{},{},{},{}]`, + // `SELECT NAME FROM t`(列名是 name)同样返回 `[{},{}]` —— 行数正确、内容全空、 + // 无任何报错;`GROUP BY bogus` 会把全表并成一组,`ORDER BY bogus` 顺序随机。 + // SQLite/MySQL 三处都报 "no such column"。 + // + // 校验时机选在 JOIN/子查询合并完成后(此时是最终行形态),且仅在**未发生聚合**时 + // 进行 —— 聚合/分组会把行替换为计算键,普通列本就不存在(那属于另一类语义问题)。 + if (!hasAggregate) { + this.assertProjectionColumnsExist(rows, stmt.columns, stmt); + } // v0.7.3: `SELECT *, col AS alias` —— 此前 columns[0]==='*' 直接不投影, // 别名列/常量列丢失。仅当 '*' 是唯一列时跳过投影(projectRow 对裸 '*' // 合并原行全部列,其余表达式覆盖/追加) @@ -388,9 +542,15 @@ export class QueryExecutor { if (orderByAlias && stmt.orderBy && stmt.orderBy.length > 0) { rows = applyOrderBy(rows, stmt.orderBy); } - const offset = stmt.offset ?? 0; - const limit = stmt.limit ?? rows.length; - rows = rows.slice(offset, offset + limit); + // v0.8.0: LIMIT/OFFSET 的应用点,两条路径互斥且**只执行一次**: + // - limitPushdownSafe === true → 引擎已按同一 offset/limit 完成切片,此处不再切片; + // - limitPushdownSafe === false → 引擎拿不到 limit/offset,此处是唯一应用点。 + // 此前两条路径都切了一次,导致 4 行表上 `LIMIT 2 OFFSET 1` 只返回 1 行(应 2,3)。 + if (!limitPushdownSafe) { + const offset = stmt.offset ?? 0; + const limit = stmt.limit ?? rows.length; + rows = rows.slice(offset, offset + limit); + } // 全局行数上限保护 if (this.maxRowsPerQuery > 0 && rows.length > this.maxRowsPerQuery) { @@ -652,7 +812,13 @@ export class QueryExecutor { return result; } - private computeAggregate(func: string, rows: Record[], col: string): number { + /** + * 计算单个聚合值。 + * + * v0.8.0: 返回类型放宽为 unknown —— SUM/AVG/MIN/MAX 对空集返回 null(SQL 标准), + * COUNT 仍返回 number。最小/最大改为单次遍历(不再展开实参,消除栈溢出)。 + */ + private computeAggregate(func: string, rows: Record[], col: string): number | null { // v0.3.2: 聚合参数支持 CASE WHEN 表达式(如 SUM(CASE WHEN age > 18 THEN 1 ELSE 0 END)) const caseExpr = /^\s*CASE\b/i.test(col) ? parseCaseExpression(col) : null; // v0.4.0: COUNT(DISTINCT col) 等去重聚合 @@ -670,12 +836,18 @@ export class QueryExecutor { return rawValues.length; } const nums = rawValues.map(Number); + // v0.8.0: 数值型聚合一律走单次遍历归约。 + // + // 此前 MIN/MAX 用 `Math.min(...distinctNums)` 展开实参:20 万行同组直接 + // `RangeError: Maximum call stack size exceeded`(原生错误,调用方无法按 code 分类)。 + // 同时把"空集/全 NULL"的返回值从 0 改为 null —— SQL 标准中 SUM/AVG/MIN/MAX + // 对空集返回 NULL,返回 0 会让 `SUM(x) = 0` 与"没有数据"不可区分。 const distinctNums = distinctArg ? Array.from(new Set(nums)) : nums; switch (func) { - case 'SUM': return distinctNums.reduce((a: number, b) => a + b, 0); - case 'AVG': return distinctNums.length === 0 ? 0 : distinctNums.reduce((a: number, b) => a + b, 0) / distinctNums.length; - case 'MIN': return distinctNums.length === 0 ? 0 : Math.min(...distinctNums); - case 'MAX': return distinctNums.length === 0 ? 0 : Math.max(...distinctNums); + case 'SUM': return reduceNumeric(distinctNums, 'SUM'); + case 'AVG': return reduceNumeric(distinctNums, 'AVG'); + case 'MIN': return reduceNumeric(distinctNums, 'MIN'); + case 'MAX': return reduceNumeric(distinctNums, 'MAX'); default: return 0; } } @@ -1003,6 +1175,76 @@ export class QueryExecutor { getEngine(): IStorageEngine { return this.engine; } + /** + * v0.8.0: 校验 SELECT 列表中的**裸列引用**在结果行里确实存在,否则抛 COLUMN_NOT_FOUND。 + * + * 为什么必须做:`SELECT bogus FROM t` 此前返回 `[{},{},...]`(行数对、内容空、无报错), + * 这是"静默错误结果"里最难被发现的一类 —— 调用方拿到的是结构正确但全空的表格。 + * + * 判定规则(与 projectRow 的分类保持一致): + * - `*` 跳过; + * - 字符串/数字常量列跳过; + * - CASE 表达式跳过(其内部列引用由 evaluateCase 处理); + * - `expr AS alias`:字符串/数字常量跳过,否则取 `expr` 作为被引用列; + * - 其余视为裸列引用。 + * 存在性检查允许两种形态:精确匹配,或**唯一**以 `.` 结尾(JOIN 行以 `alias.col` 为键)。 + * 若同一个后缀出现在多个表别名下则视为歧义,同样报错(符合"未限定列名歧义应报错"的语义)。 + * + * 结果集为空时无法判定,此时跳过(空表 + 未知列不会误报)。 + */ + private assertProjectionColumnsExist( + rows: Record[], + columns: string[], + stmt: SelectStatement, + ): void { + if (rows.length === 0 || columns.length === 0) return; + + const available = new Set(); + for (const row of rows) { + for (const key of Object.keys(row)) available.add(key); + } + + for (const raw of columns) { + const col = raw.trim(); + if (col === '*') continue; + if (parseCaseExpression(col)) continue; + + let reference = col; + const aliasMatch = col.match(/^(.+?)\s+AS\s+\w+$/i); + if (aliasMatch) reference = aliasMatch[1].trim(); + + // 常量列(字符串 / 数字 / 布尔 / NULL) + if (/^'.*'$/s.test(reference)) continue; + if (/^-?\d+(\.\d+)?$/.test(reference)) continue; + if (/^(TRUE|FALSE|NULL)$/i.test(reference)) continue; + // 聚合表达式(在 hasAggregate 分支已跳过,这里再兜一层防御) + if (/^(COUNT|SUM|AVG|MIN|MAX)\s*\(/i.test(reference)) continue; + + if (available.has(reference)) continue; + + const suffixMatches: string[] = []; + for (const key of available) { + if (key.endsWith(`.${reference}`)) suffixMatches.push(key); + } + if (suffixMatches.length === 1) continue; + + if (suffixMatches.length > 1) { + const owners = suffixMatches.map((k) => k.slice(0, k.length - reference.length - 1)).sort(); + throw new DatabaseError( + `Ambiguous column "${reference}" in SELECT list: present in ${owners.join(', ')}`, + 'COLUMN_NOT_FOUND', + { column: reference, tables: owners, from: stmt.from }, + ); + } + + throw new DatabaseError( + `Unknown column "${reference}" in SELECT list`, + 'COLUMN_NOT_FOUND', + { column: reference, from: stmt.from, available: [...available].slice(0, 32) }, + ); + } + } + /** * 列投影(v0.3.1):普通列走 projectColumns,CASE WHEN 表达式逐行求值; * v0.3.3: 支持 `col AS alias` 列别名 @@ -1038,6 +1280,13 @@ export class QueryExecutor { constCols.push({ key: col, value }); continue; } + // v0.8.0: 匿名常量列(数字/布尔/NULL)—— `SELECT 1 FROM t` 此前投影成 {} + // (键 '1' 在、值为 undefined,JSON 序列化后键消失)。SQLite/MySQL 用 + // 表达式原文作列名,这里保持一致。 + if (/^-?\d+(\.\d+)?$/.test(col) || /^(TRUE|FALSE|NULL)$/i.test(col)) { + constCols.push({ key: col, value: resolveAliasSource(col, row) }); + continue; + } plain.push(col); } // v0.7.3: hasStar 时以原行全部列为基(projectColumns 仅投影 plain 列,不含 * 的其余列) @@ -1048,9 +1297,7 @@ export class QueryExecutor { if (source === '*') { Object.assign(projected, row); } else { - const lit = source.match(/^'(.*)'$/s); - // v0.7.3: 同 constCols —— SQL 标准 '' 转义还原 - projected[alias] = lit ? lit[1].replace(/''/g, "'") : row[source]; + projected[alias] = resolveAliasSource(source, row); } } for (const { key, value } of constCols) { diff --git a/src/sql/parser.ts b/src/sql/parser.ts index 9c9fad2..30ef007 100644 --- a/src/sql/parser.ts +++ b/src/sql/parser.ts @@ -25,7 +25,7 @@ import type { CommitTransactionStatement, ASTColumnDef, } from '../query/ast'; -import type { WhereCondition, OrderBy, SortDirection } from '../constants'; +import type { WhereCondition, FieldCondition, OrderBy, SortDirection } from '../constants'; import { DatabaseError } from '../constants'; // --------------------------------------------------------------------------- @@ -551,6 +551,26 @@ export class Parser { values.push(rowValues); } while (this.curTokenIs(TokenType.COMMA)); + // v0.8.0 根治:显式列名时校验每行值的个数与列数一致。 + // + // 此前完全不校验 arity,实测: + // INSERT INTO t (id, name) VALUES ('4','z',9) → 多余的 9 被**静默丢弃** + // INSERT INTO t VALUES ('3') → 静默写入半行(其余列缺失) + // SQLite / MySQL 都会报错。静默丢弃/截断属于"静默数据丢失", + // 必须在解析期拦下(此时无需 schema,只要有显式列名即可判断)。 + // + // 未显式给列名时(INSERT INTO t VALUES (...))需要 schema 才能判断个数, + // 由 executor 在拿到 schema 后校验(见 validateInsertArity)。 + if (columns) { + for (let i = 0; i < values.length; i++) { + if (values[i].length !== columns.length) { + throw this.error( + `INSERT column/value count mismatch: ${columns.length} column(s) but row ${i + 1} has ${values[i].length} value(s)`, + ); + } + } + } + return { type: 'INSERT', into: tableName, @@ -563,13 +583,25 @@ export class Parser { // UPDATE // =================================================================== + /** + * v0.8.0: 创建**无原型**对象,用于以用户提供的列名为键的映射。 + * + * 背景:`obj['__proto__'] = v` 在普通对象上会触发原型 setter 而不是新增属性, + * 于是 `UPDATE t SET __proto__ = 'x'` 的 sets 变成 `{}` —— 既没写进去、也不会被 + * v0.7.4 新增的"未知列显式报错"预检看到,表现为"返回成功但什么都没发生"。 + * 建表路径在 v0.7.1 已用 Object.create(null) 防护,此处补齐其余路径。 + */ + private newColumnMap(): Record { + return Object.create(null) as Record; + } + private parseUpdate(): UpdateStatement { this.expect(TokenType.UPDATE); const tableName = this.expectIdentifier('table name'); this.expect(TokenType.SET); - // SET col=val, ... - const sets: Record = {}; + // SET col=val, ...(v0.8.0: 无原型对象,防 __proto__ 列名静默吞掉赋值) + const sets: Record = this.newColumnMap(); do { if (this.curTokenIs(TokenType.COMMA)) this.nextToken(); const col = this.expectIdentifier('column name'); @@ -577,7 +609,7 @@ export class Parser { sets[col] = this.parseValue(); } while (this.curTokenIs(TokenType.COMMA)); - let where: WhereCondition = {}; + let where: WhereCondition = this.newColumnMap() as WhereCondition; if (this.curTokenIs(TokenType.WHERE)) { this.nextToken(); where = this.parseCondition(); @@ -595,7 +627,7 @@ export class Parser { this.expect(TokenType.FROM); const tableName = this.expectIdentifier('table name'); - let where: WhereCondition = {}; + let where: WhereCondition = this.newColumnMap() as WhereCondition; if (this.curTokenIs(TokenType.WHERE)) { this.nextToken(); where = this.parseCondition(); @@ -865,7 +897,7 @@ export class Parser { const isNot = this.curTokenIs(TokenType.NOT); if (isNot) this.nextToken(); this.expect(TokenType.NULL); - const result: WhereCondition = {}; + const result: WhereCondition = this.newColumnMap() as WhereCondition; result[column] = isNot ? { $ne: null } : { $eq: null }; return result; } @@ -876,7 +908,7 @@ export class Parser { const low = this.parseValue(); this.expect(TokenType.AND); const high = this.parseValue(); - const result: WhereCondition = {}; + const result: WhereCondition = this.newColumnMap() as WhereCondition; result[column] = { $gte: low, $lte: high }; return result; } @@ -888,7 +920,7 @@ export class Parser { const low = this.parseValue(); this.expect(TokenType.AND); const high = this.parseValue(); - const result: WhereCondition = {}; + const result: WhereCondition = this.newColumnMap() as WhereCondition; result[column] = { $not: { $gte: low, $lte: high } }; return result; } @@ -903,13 +935,13 @@ export class Parser { if (this.curTokenIs(TokenType.SELECT)) { const subquery = this.parseSelect(); this.expect(TokenType.RPAREN); - const result: WhereCondition = {}; + const result: WhereCondition = this.newColumnMap() as WhereCondition; result[column] = { $nin: { $subquery: subquery } }; return result; } const values = this.parseValueList(); this.expect(TokenType.RPAREN); - const result: WhereCondition = {}; + const result: WhereCondition = this.newColumnMap() as WhereCondition; result[column] = { $nin: values }; return result; } else if (this.peekTokenIs(TokenType.LIKE)) { @@ -917,7 +949,7 @@ export class Parser { this.nextToken(); // skip NOT this.nextToken(); // skip LIKE const pattern = this.parseValue(); - const result: WhereCondition = {}; + const result: WhereCondition = this.newColumnMap() as WhereCondition; result[column] = { $not: { $like: pattern } }; return result; } @@ -927,7 +959,7 @@ export class Parser { if (this.curTokenIs(TokenType.LIKE)) { this.nextToken(); const pattern = this.parseValue(); - const result: WhereCondition = {}; + const result: WhereCondition = this.newColumnMap() as WhereCondition; result[column] = { $like: pattern }; return result; } @@ -940,13 +972,13 @@ export class Parser { if (this.curTokenIs(TokenType.SELECT)) { const subquery = this.parseSelect(); this.expect(TokenType.RPAREN); - const result: WhereCondition = {}; + const result: WhereCondition = this.newColumnMap() as WhereCondition; result[column] = { $in: { $subquery: subquery } }; return result; } const values = this.parseValueList(); this.expect(TokenType.RPAREN); - const result: WhereCondition = {}; + const result: WhereCondition = this.newColumnMap() as WhereCondition; result[column] = { $in: values }; return result; } @@ -957,7 +989,7 @@ export class Parser { this.curTokenIs(TokenType.RPAREN) || this.curTokenIs(TokenType.EOF) || (this.curToken.type === TokenType.IDENTIFIER && ['THEN', 'END', 'ELSE', 'NULLS', 'LIMIT', 'OFFSET', 'ORDER', 'GROUP', 'HAVING', 'UNION', 'WHERE'].includes(this.curToken.value.toUpperCase())) ) { - const result: WhereCondition = {}; + const result: WhereCondition = this.newColumnMap() as WhereCondition; result[column] = { $eq: true }; return result; } @@ -970,7 +1002,7 @@ export class Parser { this.nextToken(); // skip ( const subquery = this.parseSelect(); this.expect(TokenType.RPAREN); - const result: WhereCondition = {}; + const result: WhereCondition = this.newColumnMap() as WhereCondition; result[column] = { [op]: { $subquery: subquery } }; return result; } @@ -987,7 +1019,7 @@ export class Parser { value = this.parseValue(); } - const result: WhereCondition = {}; + const result: WhereCondition = this.newColumnMap() as WhereCondition; result[column] = { [op]: value }; return result; } @@ -1066,6 +1098,9 @@ export class Parser { } // 数字常量列:SELECT 1 FROM t(常见于 EXISTS 子查询) + // v0.8.0: 只返回常量文本,别名交给调用方 parseColumnWithAlias 处理 + //(此前这里直接 return,导致 `SELECT 1 AS one` 的别名被丢弃, + // 投影时 row['1'] → undefined → 整行变成 {})。 if (this.curTokenIs(TokenType.NUMBER)) { const value = this.curToken.value; this.nextToken(); @@ -1073,10 +1108,11 @@ export class Parser { } // v0.4.0: 字符串常量列:SELECT 'value' FROM t + // v0.8.0: 同样只返回字面量文本,别名由 parseColumnWithAlias 叠加 if (this.curTokenIs(TokenType.STRING)) { const value = this.curToken.value; this.nextToken(); - return `'${value}'`; + return `'${value.replace(/'/g, "''")}'`; } // 聚合函数?