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:
+54
-18
@@ -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, "''")}'`;
|
||||
}
|
||||
|
||||
// 聚合函数?
|
||||
|
||||
Reference in New Issue
Block a user