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
+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, "''")}'`;
}
// 聚合函数?