fix(A5/A6/A7/A16/A18/A24/A28): 查询层语义根治 —— LIMIT 双重应用、未知列静默、聚合崩溃与空集语义

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 语义以表达式原文为键。
This commit is contained in:
thzxx
2026-09-14 21:19:49 +08:00
parent 7526951804
commit 752bdea97d
3 changed files with 398 additions and 86 deletions
+78 -49
View File
@@ -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<T extends Record<string, unknown> = Record<string, unknown>>(
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<string, unknown>)) {
if (typeof operand === 'object' && operand !== null) {
const ops = operand as Record<string, unknown>;
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<string, unknown>) => 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<string, unknown>) => {
// maxRowsPerQuery 必须与物化路径一致地生效(此前流式路径完全不受约束)
if (maxRows > 0 && emitted >= maxRows) return;
emitted++;
const ret = (onRow as (r: Record<string, unknown>) => unknown)(row);
if (ret && typeof (ret as PromiseLike<unknown>).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);
+266 -19
View File
@@ -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<string, unknown>): 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<Record<string, unknown>[]> {
/**
* 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<string, unknown>[];
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<Record<string, unknown>[]> {
const shape = this.analyzeSelect(stmt);
const { hasGroupBy, hasAggregate, isJoinQuery, needsRawRows, orderByAlias, hasSelectAlias, limitPushdownSafe } = shape;
let rows: Record<string, unknown>[];
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<string, unknown>[], col: string): number {
/**
* 计算单个聚合值。
*
* v0.8.0: 返回类型放宽为 unknown —— SUM/AVG/MIN/MAX 对空集返回 nullSQL 标准),
* COUNT 仍返回 number。最小/最大改为单次遍历(不再展开实参,消除栈溢出)。
*/
private computeAggregate(func: string, rows: Record<string, unknown>[], 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` 作为被引用列;
* - 其余视为裸列引用。
* 存在性检查允许两种形态:精确匹配,或**唯一**以 `.<col>` 结尾(JOIN 行以 `alias.col` 为键)。
* 若同一个后缀出现在多个表别名下则视为歧义,同样报错(符合"未限定列名歧义应报错"的语义)。
*
* 结果集为空时无法判定,此时跳过(空表 + 未知列不会误报)。
*/
private assertProjectionColumnsExist(
rows: Record<string, unknown>[],
columns: string[],
stmt: SelectStatement,
): void {
if (rows.length === 0 || columns.length === 0) return;
const available = new Set<string>();
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):普通列走 projectColumnsCASE 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' 在、值为 undefinedJSON 序列化后键消失)。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) {
+54 -18
View File
@@ -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<T>(): Record<string, T> {
return Object.create(null) as Record<string, T>;
}
private parseUpdate(): UpdateStatement {
this.expect(TokenType.UPDATE);
const tableName = this.expectIdentifier('table name');
this.expect(TokenType.SET);
// SET col=val, ...
const sets: Record<string, unknown> = {};
// SET col=val, ...v0.8.0: 无原型对象,防 __proto__ 列名静默吞掉赋值)
const sets: Record<string, unknown> = this.newColumnMap<unknown>();
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<unknown>() 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<FieldCondition>() 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<FieldCondition>() 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<FieldCondition>() 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<FieldCondition>() 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<FieldCondition>() 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<FieldCondition>() 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<FieldCondition>() 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<FieldCondition>() 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<FieldCondition>() 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<FieldCondition>() 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<FieldCondition>() 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<FieldCondition>() 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<FieldCondition>() 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, "''")}'`;
}
// 聚合函数?