fix(A22/A23/A25/A26/A27/A29/A30/A36): 查询层 8 项缺陷根治 + 单一语义收敛
每项都先用可执行探针复现出**错误的实际输出**,再修根因、补永久回归套件
(tests/v080-query-layer.test.ts,8 项 × 4 引擎 + 跨引擎项,共 111 断言)。
A22 GROUP BY 引用 SELECT 别名
修复前:`SELECT g AS grp, COUNT(*) FROM t GROUP BY grp` 抛
`COLUMN_NOT_FOUND Unknown column "g" in SELECT list` —— 错误信息与真正原因
(GROUP BY 用了别名)无关,因为 grp 取到 undefined 使全表并成一组,
投影阶段又发现 g 不在输出行里。
修复:GROUP BY 项先解析回**基列**(别名 → 源表达式)再分组,输出键与
"按基列分组"完全一致。
A23 HAVING 引用未出现在 SELECT 里的聚合
修复前:`SELECT g FROM t GROUP BY g HAVING SUM(n) > 25` → `[]`
(SUM(n) 从未被求值 → HAVING 的键在分组行里不存在 → UNKNOWN)。
修复:需要计算的聚合 = SELECT 列 ∪ HAVING 中的聚合(并集),
并在 HAVING **之后**才把行收缩为 SELECT 输出键(否则又变回 [];
顺序错了会双向出错:先投影 → 空结果,不投影 → 泄漏内部聚合列)。
A25 带表前缀的聚合参数恒 0
修复前:`COUNT(t.n)` → 0、`SUM(t.n)` → null(行键是 n,直接取 row['t.n']
得 undefined 再被"过滤 NULL"剔除,**且不报错**)。
修复:新增唯一列引用解析 resolveColumnValue(前缀剥离 → 精确 → 唯一后缀),
聚合识别统一为 parseAggregateExpression —— 此前"是否聚合"与"如何求值"
用两条不同的正则。取不到列改为抛 COLUMN_NOT_FOUND,不再静默计 0。
A26 UNION 尾部 ORDER BY/LIMIT 归属错误
修复前:`A UNION B ORDER BY id DESC` 只排 B;`... LIMIT 3` 返回 4 行
(parser 把子句挂在右侧 SELECT 上,AST 没有复合查询级字段)。
修复:SelectUnionStatement 增加 orderBy/limit/offset,parser 把子句**上移**
(移动而非复制,否则 LIMIT 应用两次),executor 在合并+去重后统一排序/切片。
A27 DISTINCT 作用在投影前
修复前:`SELECT DISTINCT g AS d FROM t` 返回 4 行 a,a,b,b
(对 {id,g,n} 原始行去重),而 `SELECT DISTINCT g` 返回 2 行。
修复:DISTINCT 移到投影后(作用于输出列);ORDER BY 的应用时机随之拆成
"引用输出列 → 投影后" / "引用非输出列 → 投影前",两者互为因果必须一起改。
A29 maxRowsPerQuery 静默截断写入
修复前:maxRowsPerQuery=2 时 `INSERT INTO dst SELECT id FROM src`(4 行源)
只写 2 行并报成功 —— 不是"限制查询规模"而是**静默丢数据**。
修复:行源不截断(executeSelect 增加 purpose='source'),写路径显式报错。
A30 INSERT 值多于目标列静默丢弃
修复前:`INSERT INTO t (id,g) VALUES ('9','z','LOST')` 报成功、'LOST' 消失。
修复:显式列名时解析期拦截(PARSE_ERROR),未给列名时 executor 对照 schema
拦截(VALIDATION_ERROR)——两种情况都需要,因为前者无需 schema。
A36 派生表别名引用
修复前:`SELECT d.id FROM (SELECT id, g FROM t) AS d` 返回 `[]`,
而同义的 `SELECT id FROM (...) AS d` 正确。
修复:抽出 normalizeUnprefixedReferences(WHERE/ORDER BY/GROUP BY/SELECT
四类引用统一剥离别名前缀),非 JOIN 单表路径与派生表路径共用同一规则。
连带根治(修复过程中发现的两个更底层问题):
1. **同步抛错穿过 async 边界**:`executor.execute()` 里 `return this.executeXxx(stmt)`
的同步前导段若抛错(arity/校验),异常成为**同步抛出** ——
`await expect(db.query(...)).rejects...` 的断言不生效、`.catch()` 永不执行。
现统一包一层 try/catch,保证任何错误都是 rejected promise。
2. **缺列的行形状不一致**:validateRow 此前"值为 undefined 就不落键",
于是 `INSERT INTO t (id,g) VALUES ('9','z')` 的行里没有 n 键 →
`SELECT id,g,n FROM t` 抛 COLUMN_NOT_FOUND: n,而 `SELECT * FROM t` 正常。
现在缺列且无 default → 显式补 null(SQL 语义),行始终含全部 schema 列;
ALTER ADD 同步在已有行上物化 null,使"内存视图"与"重启后视图"一致。
验证:全量 83 套件 / 1589 测试通过(含 Aria 5 万行索引竞态、KVStore 持久化);
typecheck(src+tests) 与 lint 零错误。
This commit is contained in:
@@ -158,6 +158,17 @@ export class MemoryEngine implements IStorageEngine {
|
|||||||
pks.add(pk);
|
pks.add(pk);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// v0.8.0(B-1):ADD 列在**已有行**上物化为 NULL。
|
||||||
|
//
|
||||||
|
// 行校验契约(table/validation.ts)保证"行含全部 schema 列",若 ALTER ADD
|
||||||
|
// 不补齐,新列在旧行上就是**键不存在**:内存里 `{id,name}`、而同一行经
|
||||||
|
// 落盘再读回(KVStore/Aria 的恢复路径会走 validateRow)变成
|
||||||
|
// `{id,name,phone:null}` —— 同一行的形状取决于"是否重启过"。
|
||||||
|
// 显式物化后,内存视图与持久化视图一致。
|
||||||
|
const addTable = this.tables.get(tableName)!;
|
||||||
|
for (const row of addTable.values()) {
|
||||||
|
if (!(column.name in row)) row[column.name] = null;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!schema.columns[column.name]) {
|
if (!schema.columns[column.name]) {
|
||||||
|
|||||||
@@ -158,6 +158,17 @@ export interface SelectUnionStatement {
|
|||||||
right: SelectStatement | SelectUnionStatement;
|
right: SelectStatement | SelectUnionStatement;
|
||||||
/** UNION ALL 不去重 */
|
/** UNION ALL 不去重 */
|
||||||
all?: boolean;
|
all?: boolean;
|
||||||
|
/**
|
||||||
|
* v0.8.0(A26):复合查询**整体**的 ORDER BY / LIMIT / OFFSET。
|
||||||
|
*
|
||||||
|
* SQL 标准里这三者作用于整个 UNION 结果,而不是最后一个 SELECT。
|
||||||
|
* 此前 AST 没有这三个字段,parser 把它们挂在了 UNION 右侧的 SELECT 上 ——
|
||||||
|
* 于是 `A UNION B ORDER BY id DESC` 只对 B 排序、`... LIMIT 3` 只截断 B
|
||||||
|
* (实测 `SELECT id FROM t UNION SELECT id FROM t LIMIT 3` 返回 4 行)。
|
||||||
|
*/
|
||||||
|
orderBy?: OrderBy[];
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+744
-109
File diff suppressed because it is too large
Load Diff
@@ -401,6 +401,7 @@ export class Parser {
|
|||||||
const right = this.parseSelect();
|
const right = this.parseSelect();
|
||||||
|
|
||||||
const unionStmt: SelectUnionStatement = { type: 'SELECT_UNION', left, right, all: all || undefined };
|
const unionStmt: SelectUnionStatement = { type: 'SELECT_UNION', left, right, all: all || undefined };
|
||||||
|
this.adoptTrailingClauses(unionStmt, right);
|
||||||
// 链式 UNION
|
// 链式 UNION
|
||||||
if (this.curTokenIs(TokenType.UNION)) {
|
if (this.curTokenIs(TokenType.UNION)) {
|
||||||
return this.parseUnionChain(unionStmt);
|
return this.parseUnionChain(unionStmt);
|
||||||
@@ -418,12 +419,46 @@ export class Parser {
|
|||||||
}
|
}
|
||||||
const right = this.parseSelect();
|
const right = this.parseSelect();
|
||||||
const unionStmt: SelectUnionStatement = { type: 'SELECT_UNION', left, right, all: all || undefined };
|
const unionStmt: SelectUnionStatement = { type: 'SELECT_UNION', left, right, all: all || undefined };
|
||||||
|
this.adoptTrailingClauses(unionStmt, right);
|
||||||
if (this.curTokenIs(TokenType.UNION)) {
|
if (this.curTokenIs(TokenType.UNION)) {
|
||||||
return this.parseUnionChain(unionStmt);
|
return this.parseUnionChain(unionStmt);
|
||||||
}
|
}
|
||||||
return unionStmt;
|
return unionStmt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.8.0(A26):把"最后一个 SELECT 上的 ORDER BY / LIMIT / OFFSET"上移到
|
||||||
|
* 复合查询节点,并把这些子句从该 SELECT 上**移除**。
|
||||||
|
*
|
||||||
|
* 为什么必须"移动"而不是"复制":
|
||||||
|
* - 语法上它们写在最后一个 SELECT 之后,但 SQL 语义作用于整个 UNION
|
||||||
|
* (`A UNION B LIMIT 3` 是"合并去重后取前 3 行",不是"B 取前 3 行");
|
||||||
|
* - 若只复制不移除,LIMIT 会**应用两次** —— 正是 A5/A6 那类"两处都生效"
|
||||||
|
* 缺陷的同一个坑(B 先被截断,再对合并结果截断,结果可能少行)。
|
||||||
|
*
|
||||||
|
* 由于 `parseSelect` 无法预知后面有没有 UNION(它在返回后才知道),
|
||||||
|
* 只能先让它照常解析、发现 UNION 时再回收 —— 这比"预读 UNION"简单且无回溯。
|
||||||
|
*/
|
||||||
|
private adoptTrailingClauses(
|
||||||
|
unionStmt: SelectUnionStatement,
|
||||||
|
right: SelectStatement | SelectUnionStatement,
|
||||||
|
): void {
|
||||||
|
// 链式 UNION 时右侧可能已是 UNION 节点,其尾部子句在创建时已上移
|
||||||
|
if (right.type !== 'SELECT') return;
|
||||||
|
if (right.orderBy) {
|
||||||
|
unionStmt.orderBy = right.orderBy;
|
||||||
|
delete right.orderBy;
|
||||||
|
}
|
||||||
|
if (right.limit !== undefined) {
|
||||||
|
unionStmt.limit = right.limit;
|
||||||
|
delete right.limit;
|
||||||
|
}
|
||||||
|
if (right.offset !== undefined) {
|
||||||
|
unionStmt.offset = right.offset;
|
||||||
|
delete right.offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 解析 JOIN 子句列表 */
|
/** 解析 JOIN 子句列表 */
|
||||||
private parseJoinClauses(): import('../query/ast').JoinClause[] {
|
private parseJoinClauses(): import('../query/ast').JoinClause[] {
|
||||||
const joins: import('../query/ast').JoinClause[] = [];
|
const joins: import('../query/ast').JoinClause[] = [];
|
||||||
|
|||||||
+39
-1
@@ -131,9 +131,19 @@ export function compileValidator(schema: TableSchema): RowValidator {
|
|||||||
|
|
||||||
assertNotNullConstraints(table, colName, colDef, value);
|
assertNotNullConstraints(table, colName, colDef, value);
|
||||||
if (value !== undefined && value !== null) {
|
if (value !== undefined && value !== null) {
|
||||||
|
assertJsonSafeNumber(table, colName, value);
|
||||||
checkFieldType(table, colName, colDef.type, value, colDef);
|
checkFieldType(table, colName, colDef.type, value, colDef);
|
||||||
}
|
}
|
||||||
if (value !== undefined) validated[colName] = value;
|
// v0.8.0(B-1):缺列且无 default → 显式写入 NULL,**不能省略键**。
|
||||||
|
//
|
||||||
|
// 此前 `if (value !== undefined) validated[colName] = value;` 会把这个列整个
|
||||||
|
// 从行里删掉,于是存储行只含"有值的列",行形状取决于写入方式:
|
||||||
|
// INSERT INTO t (id, g) VALUES ('9','z') -- 行里没有 n 键
|
||||||
|
// SELECT id, g, n FROM t WHERE id = '9' -- 抛 COLUMN_NOT_FOUND: n
|
||||||
|
// 而 `SELECT * FROM t` 却能正常返回(少一列而已)—— 同一行"有没有 n 列"
|
||||||
|
// 在读路径上给出相反结论。SQL 语义中"未提供值"就是 NULL,故统一补 null:
|
||||||
|
// 行始终包含全部 schema 列,投影/排序/三值比较才有统一前提。
|
||||||
|
validated[colName] = value === undefined ? null : value;
|
||||||
}
|
}
|
||||||
return validated;
|
return validated;
|
||||||
}
|
}
|
||||||
@@ -158,6 +168,7 @@ export function compileValidator(schema: TableSchema): RowValidator {
|
|||||||
if (value === undefined) continue;
|
if (value === undefined) continue;
|
||||||
assertNotNullConstraints(table, colName, colDef, value);
|
assertNotNullConstraints(table, colName, colDef, value);
|
||||||
if (value !== null) {
|
if (value !== null) {
|
||||||
|
assertJsonSafeNumber(table, colName, value);
|
||||||
checkFieldType(table, colName, colDef.type, value, colDef);
|
checkFieldType(table, colName, colDef.type, value, colDef);
|
||||||
}
|
}
|
||||||
values[colName] = value;
|
values[colName] = value;
|
||||||
@@ -172,6 +183,33 @@ export function compileValidator(schema: TableSchema): RowValidator {
|
|||||||
// 共享约束
|
// 共享约束
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查字段类型(含约束校验)。
|
||||||
|
*
|
||||||
|
* v0.8.0(B-1):在 `checkFieldType`(table/schema.ts)之外**额外**拒绝
|
||||||
|
* `NaN` 与 `±Infinity`。为什么必须有这一层:
|
||||||
|
* - JSON 无法表示它们 —— `JSON.stringify({ v: NaN })` 得到 `{"v":null}`,
|
||||||
|
* 于是 `INSERT ... VALUES (NaN)` 在内存引擎里是 NaN,落盘再读回来变成 null;
|
||||||
|
* 同一个库在"写后立即查"与"重启后查"得到不同结果,且没有任何提示。
|
||||||
|
* - KVStore / Aria 的持久化路径都是 JSON,因此这是**所有**磁盘引擎的共性问题。
|
||||||
|
* - 用户能构造出 NaN:`Number('abc')`、`0/0`、`parseFloat('x')` 等,
|
||||||
|
* 经由参数绑定进入写入路径。
|
||||||
|
* 显式拒绝(`VALIDATION_ERROR`)让问题在写入时暴露,而不是变成读出来的 null。
|
||||||
|
*/
|
||||||
|
function assertJsonSafeNumber(
|
||||||
|
table: string,
|
||||||
|
colName: string,
|
||||||
|
value: unknown,
|
||||||
|
): void {
|
||||||
|
if (typeof value !== 'number') return;
|
||||||
|
if (Number.isFinite(value)) return;
|
||||||
|
throw new DatabaseError(
|
||||||
|
`Column "${colName}" in table "${table}" cannot store ${Number.isNaN(value) ? 'NaN' : String(value)}:`
|
||||||
|
+ ' it is not representable in JSON and would be silently read back as null',
|
||||||
|
'VALIDATION_ERROR',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NOT NULL 类约束。
|
* NOT NULL 类约束。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -447,8 +447,16 @@ describe('KVStoreEngine', () => {
|
|||||||
expect(schema!.columns.phone).toBeDefined();
|
expect(schema!.columns.phone).toBeDefined();
|
||||||
expect(schema!.columns.age).toBeUndefined();
|
expect(schema!.columns.age).toBeUndefined();
|
||||||
const rows = await engine2.find('users', { table: 'users' });
|
const rows = await engine2.find('users', { table: 'users' });
|
||||||
expect(rows[0].phone).toBeUndefined();
|
// v0.8.0(B-1):ADD 出来的列在已有行上物化为 NULL。
|
||||||
|
//
|
||||||
|
// 行校验契约保证"存储行含全部 schema 列",此前 ADD 不补齐,于是同一行
|
||||||
|
// 在内存里没有 phone 键、落盘再读回(恢复路径走 validateRow)却有
|
||||||
|
// `phone: null` —— 行的形状取决于是否重启过。此断言按新契约改为 null。
|
||||||
|
expect(rows[0].phone).toBeNull();
|
||||||
|
// DROP 掉的列必须真正消失(不是变 null):它是**不在 schema 里**的列,
|
||||||
|
// 若保留为 null 会让"已删列"看起来仍然存在。
|
||||||
expect(rows[0].age).toBeUndefined();
|
expect(rows[0].age).toBeUndefined();
|
||||||
|
expect('age' in rows[0]).toBe(false);
|
||||||
await engine2.close();
|
await engine2.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
/**
|
||||||
|
* v0.8.0 回归套件 —— 查询层缺陷根治(A22/A23/A25/A26/A27/A29/A30/A36)
|
||||||
|
* ============================================================================
|
||||||
|
* 本套件锁定 PLAN-v0.7.5.md §5 缺陷总账中查询层的 8 项修复。每一项都先给出
|
||||||
|
* **修复前的实测错误输出**,再断言正确结果 —— 这样即使将来重构执行器,
|
||||||
|
* 失败的断言能直接告诉后来者"当初错在哪里"。
|
||||||
|
*
|
||||||
|
* 参考数据(表 t):
|
||||||
|
* id=1 g='a' n=10 id=3 g='b' n=30
|
||||||
|
* id=2 g='a' n=20 id=4 g='b' n=40
|
||||||
|
*/
|
||||||
|
import { MetonaSqlark } from '../src/core';
|
||||||
|
import { rows as rowsOf } from './helpers/assertions';
|
||||||
|
import type { DatabaseConfig } from '../src/constants';
|
||||||
|
|
||||||
|
const ENGINES: Array<[string, DatabaseConfig['mode'], Partial<DatabaseConfig>]> = [
|
||||||
|
['memory', 'memory', {}],
|
||||||
|
['disk', 'disk', {}],
|
||||||
|
['hybrid', 'hybrid', {}],
|
||||||
|
['aria', 'aria', { diskEngine: 'memory' }],
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('[v0.8.0] 查询层缺陷根治', () => {
|
||||||
|
describe.each(ENGINES)('%s 引擎', (label, mode, extra) => {
|
||||||
|
let db: MetonaSqlark;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
db = await MetonaSqlark.create({
|
||||||
|
name: `ql-${label}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
mode,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
await db.defineTable('t', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
g: { type: 'string' },
|
||||||
|
n: { type: 'number' },
|
||||||
|
});
|
||||||
|
await db.query("INSERT INTO t VALUES ('1','a',10),('2','a',20),('3','b',30),('4','b',40)");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// A22: GROUP BY 可引用 SELECT 别名
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
it('A22 GROUP BY 引用 SELECT 别名', async () => {
|
||||||
|
// 修复前:抛 `COLUMN_NOT_FOUND Unknown column "g" in SELECT list`
|
||||||
|
// (GROUP BY grp 取 row['grp'] 得 undefined → 全部行并成一组 →
|
||||||
|
// 投影阶段又发现 g 不在输出行里,报出一个和真正原因无关的错误)
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query('SELECT g AS grp, COUNT(*) AS c FROM t GROUP BY grp ORDER BY grp'),
|
||||||
|
);
|
||||||
|
expect(rows).toEqual([{ g: 'a', c: 2 }, { g: 'b', c: 2 }]);
|
||||||
|
// 输出键与"按基列分组"完全一致(同一查询的两种写法必须等价)
|
||||||
|
const byBase = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query('SELECT g, COUNT(*) AS c FROM t GROUP BY g ORDER BY g'),
|
||||||
|
);
|
||||||
|
expect(rows).toEqual(byBase);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A22 GROUP BY 引用别名时不泄漏别名键', async () => {
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query('SELECT g AS grp FROM t GROUP BY grp ORDER BY grp'),
|
||||||
|
);
|
||||||
|
// 输出的键必须是基列名 g(SELECT 列表里 g AS grp 的投影结果),
|
||||||
|
// 而不是内部的分组键 grp —— 否则同一查询在"有别名/无别名"两种写法下
|
||||||
|
// 行形状不同。
|
||||||
|
expect(rows).toEqual([{ g: 'a' }, { g: 'b' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// A23: HAVING 可引用未出现在 SELECT 里的聚合
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
it('A23 HAVING 引用未选中的聚合(SUM)', async () => {
|
||||||
|
// 修复前:返回 [](SUM(n) 从未被求值 → HAVING 的键在行里不存在 → UNKNOWN)
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query('SELECT g FROM t GROUP BY g HAVING SUM(n) > 25 ORDER BY g'),
|
||||||
|
);
|
||||||
|
expect(rows).toEqual([{ g: 'a' }, { g: 'b' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A23 HAVING 引用未选中的聚合(MAX)并正确过滤', async () => {
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query('SELECT g FROM t GROUP BY g HAVING MAX(n) > 25 ORDER BY g'),
|
||||||
|
);
|
||||||
|
expect(rows).toEqual([{ g: 'b' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A23 HAVING 的辅助聚合不得出现在输出行里', async () => {
|
||||||
|
// 为 HAVING 计算的 SUM(n) 是内部键:输出必须只有 g 一列。
|
||||||
|
// (修复过程中曾泄漏成 {g, 'SUM(n)'} —— 多出用户没要求的输出列。)
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query('SELECT g FROM t GROUP BY g HAVING SUM(n) > 0 ORDER BY g'),
|
||||||
|
);
|
||||||
|
expect(rows).toEqual([{ g: 'a' }, { g: 'b' }]);
|
||||||
|
expect(Object.keys(rows[0])).toEqual(['g']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A23 HAVING 同时引用别名与未选中聚合', async () => {
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query('SELECT g AS grp, COUNT(*) AS c FROM t GROUP BY grp HAVING SUM(n) > 25 ORDER BY grp'),
|
||||||
|
);
|
||||||
|
expect(rows).toEqual([{ g: 'a', c: 2 }, { g: 'b', c: 2 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// A25: 带表前缀的聚合参数
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
it('A25 COUNT(t.n) / SUM(t.n) 按列取值而非恒 0', async () => {
|
||||||
|
// 修复前:COUNT(t.n) → 0,SUM(t.n) → null(行键是 n,直接取 row['t.n'] 得
|
||||||
|
// undefined,再被"过滤 NULL"剔除,且**不报错**)
|
||||||
|
expect(rowsOf(await db.query('SELECT COUNT(t.n) AS c FROM t'))).toEqual([{ c: 4 }]);
|
||||||
|
expect(rowsOf(await db.query('SELECT SUM(t.n) AS s FROM t'))).toEqual([{ s: 100 }]);
|
||||||
|
expect(rowsOf(await db.query('SELECT AVG(t.n) AS a FROM t'))).toEqual([{ a: 25 }]);
|
||||||
|
expect(rowsOf(await db.query('SELECT MIN(t.n) AS lo FROM t'))).toEqual([{ lo: 10 }]);
|
||||||
|
expect(rowsOf(await db.query('SELECT MAX(t.n) AS hi FROM t'))).toEqual([{ hi: 40 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A25 分组聚合的带前缀参数', async () => {
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query('SELECT g, SUM(t.n) AS s FROM t GROUP BY g ORDER BY g'),
|
||||||
|
);
|
||||||
|
expect(rows).toEqual([{ g: 'a', s: 30 }, { g: 'b', s: 70 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A25 COUNT(DISTINCT t.n) 带前缀且类型安全去重', async () => {
|
||||||
|
expect(rowsOf(await db.query('SELECT COUNT(DISTINCT t.n) AS c FROM t'))).toEqual([{ c: 4 }]);
|
||||||
|
// 数值去重不得被编码串扰(曾把 encodeValueKey 的结果 Number() 回读成 NaN)
|
||||||
|
expect(rowsOf(await db.query('SELECT SUM(DISTINCT t.n) AS s FROM t'))).toEqual([{ s: 100 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A25 聚合参数引用不存在的列 → COLUMN_NOT_FOUND(不静默计 0)', async () => {
|
||||||
|
await expect(db.query('SELECT COUNT(t.nope) AS c FROM t')).rejects.toMatchObject({
|
||||||
|
code: 'COLUMN_NOT_FOUND',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// A26: UNION 尾部 ORDER BY / LIMIT 作用于整个复合结果
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
it('A26 UNION 尾部 ORDER BY 作用于复合结果', async () => {
|
||||||
|
// 修复前:只对右侧 SELECT 排序 → [{1},{2},{4},{3}]
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query("SELECT id FROM t WHERE g = 'a' UNION SELECT id FROM t WHERE g = 'b' ORDER BY id DESC"),
|
||||||
|
);
|
||||||
|
expect(rows).toEqual([{ id: '4' }, { id: '3' }, { id: '2' }, { id: '1' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A26 UNION 尾部 LIMIT 作用于复合结果', async () => {
|
||||||
|
// 修复前:只截断右侧 → 返回 4 行
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query('SELECT id FROM t UNION SELECT id FROM t LIMIT 3'),
|
||||||
|
);
|
||||||
|
expect(rows).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A26 UNION LIMIT 只应用一次(不得双重截断)', async () => {
|
||||||
|
const limited = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query('SELECT id FROM t UNION ALL SELECT id FROM t LIMIT 5'),
|
||||||
|
);
|
||||||
|
expect(limited).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A26 UNION 去重 + OFFSET', async () => {
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query('SELECT id FROM t UNION SELECT id FROM t ORDER BY id LIMIT 2 OFFSET 1'),
|
||||||
|
);
|
||||||
|
expect(rows).toEqual([{ id: '2' }, { id: '3' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A26 UNION ORDER BY 引用不存在的列 → COLUMN_NOT_FOUND', async () => {
|
||||||
|
await expect(db.query('SELECT id FROM t UNION SELECT id FROM t ORDER BY nope')).rejects.toMatchObject({
|
||||||
|
code: 'COLUMN_NOT_FOUND',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// A27: DISTINCT 作用于输出列(投影之后)
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
it('A27 DISTINCT 列别名与裸列结果一致', async () => {
|
||||||
|
// 修复前:`SELECT DISTINCT g AS d` 返回 4 行 a,a,b,b
|
||||||
|
//(DISTINCT 作用在投影前的 {id,g,n} 原始行上,四行互不相同)
|
||||||
|
const aliased = rowsOf<Record<string, unknown>>(await db.query('SELECT DISTINCT g AS d FROM t ORDER BY d'));
|
||||||
|
expect(aliased).toEqual([{ d: 'a' }, { d: 'b' }]);
|
||||||
|
const bare = rowsOf<Record<string, unknown>>(await db.query('SELECT DISTINCT g FROM t ORDER BY g'));
|
||||||
|
expect(bare).toEqual([{ g: 'a' }, { g: 'b' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A27 DISTINCT 多列仍按输出列去重', async () => {
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(await db.query('SELECT DISTINCT g, n FROM t ORDER BY g, n'));
|
||||||
|
expect(rows).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A27 DISTINCT + ORDER BY 输出列(先去重再排序)', async () => {
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(await db.query('SELECT DISTINCT g FROM t ORDER BY g DESC'));
|
||||||
|
expect(rows).toEqual([{ g: 'b' }, { g: 'a' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// A30: INSERT 值个数与列个数不匹配 → 报错(此前静默丢弃多余值)
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
it('A30 显式列名时值多于列 → PARSE_ERROR(解析期即可判定)', async () => {
|
||||||
|
// 修复前:`INSERT INTO t (id, g) VALUES ('9','z','LOST')` 报成功、'LOST' 消失。
|
||||||
|
// 有了显式列名后 arity 无需 schema 即可判定,因此解析期就拦下
|
||||||
|
//(v0.8.0 A16 的解析期校验;executor 侧对"未显式给列名"的情形兜底)。
|
||||||
|
await expect(
|
||||||
|
db.query("INSERT INTO t (id, g) VALUES ('9', 'z', 'LOST')"),
|
||||||
|
).rejects.toMatchObject({ code: 'PARSE_ERROR' });
|
||||||
|
expect(rowsOf(await db.query("SELECT id FROM t WHERE id = '9'"))).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A30 显式列名时值少于列 → PARSE_ERROR(缺列必须显式写出)', async () => {
|
||||||
|
// `INSERT INTO t (id, g) VALUES ('9')` 是列/值个数不匹配的写法:
|
||||||
|
// 用户想写的是 `INSERT INTO t (id) VALUES ('9')`。静默补 NULL 会让
|
||||||
|
// 拼错列清单的语句"看起来成功",因此同样报错。
|
||||||
|
await expect(db.query("INSERT INTO t (id, g) VALUES ('9')")).rejects.toMatchObject({
|
||||||
|
code: 'PARSE_ERROR',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A30 未显式列名时值多于列 → VALIDATION_ERROR(需要 schema 才能判定)', async () => {
|
||||||
|
// 不带列清单时个数要对照 schema 才能判断,由 executor 在拿到 schema 后校验
|
||||||
|
await expect(db.query("INSERT INTO t VALUES ('9', 'z', 1, 'LOST')")).rejects.toMatchObject({
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
});
|
||||||
|
expect(rowsOf(await db.query("SELECT id FROM t WHERE id = '9'"))).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A30 值少于列仍合法(缺列走 default / NULL)', async () => {
|
||||||
|
await db.query("INSERT INTO t (id, g) VALUES ('9', 'z')");
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(await db.query("SELECT id, g, n FROM t WHERE id = '9'"));
|
||||||
|
expect(rows).toEqual([{ id: '9', g: 'z', n: null }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// A36: 派生表别名引用
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
it('A36 派生表别名引用与裸列引用结果一致', async () => {
|
||||||
|
// 修复前:`SELECT d.id FROM (SELECT ...) AS d` 返回 [](d.id 未剥离前缀),
|
||||||
|
// 而同义的 `SELECT id FROM (...) AS d` 正确
|
||||||
|
const aliased = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query("SELECT d.id FROM (SELECT id, g FROM t) AS d WHERE d.g = 'a' ORDER BY d.id"),
|
||||||
|
);
|
||||||
|
expect(aliased).toEqual([{ id: '1' }, { id: '2' }]);
|
||||||
|
const bare = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query("SELECT id FROM (SELECT id, g FROM t) AS d WHERE g = 'a' ORDER BY id"),
|
||||||
|
);
|
||||||
|
expect(aliased).toEqual(bare);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A36 派生表别名用于聚合与排序', async () => {
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query('SELECT COUNT(d.id) AS c FROM (SELECT id FROM t) AS d'),
|
||||||
|
);
|
||||||
|
expect(rows).toEqual([{ c: 4 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// A35: JOIN 的 NULL 键
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
it('A35 JOIN 的 NULL 键不成立,且与右表有无索引无关', async () => {
|
||||||
|
await db.defineTable('l', { id: { type: 'string', primaryKey: true }, k: { type: 'string' } });
|
||||||
|
await db.defineTable('ri', { id: { type: 'string', primaryKey: true }, k: { type: 'string', index: true } });
|
||||||
|
await db.defineTable('rn', { id: { type: 'string', primaryKey: true }, k: { type: 'string' } });
|
||||||
|
await db.query("INSERT INTO l VALUES ('l1','x'),('l2',NULL)");
|
||||||
|
await db.query("INSERT INTO ri VALUES ('r1','x'),('r2',NULL)");
|
||||||
|
await db.query("INSERT INTO rn VALUES ('n1','x'),('n2',NULL)");
|
||||||
|
|
||||||
|
const indexed = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query('SELECT l.id AS lid, ri.id AS rid FROM l JOIN ri ON l.k = ri.k'),
|
||||||
|
);
|
||||||
|
const unindexed = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query('SELECT l.id AS lid, rn.id AS rid FROM l JOIN rn ON l.k = rn.k'),
|
||||||
|
);
|
||||||
|
// NULL = NULL 是 UNKNOWN → 两个 NULL 行都不得匹配
|
||||||
|
expect(indexed).toEqual([{ lid: 'l1', rid: 'r1' }]);
|
||||||
|
expect(unindexed).toEqual([{ lid: 'l1', rid: 'n1' }]);
|
||||||
|
// 关键不变量:结果与"右表该列有没有索引"无关
|
||||||
|
expect(indexed.map((r) => r.lid)).toEqual(unindexed.map((r) => r.lid));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A35 LEFT JOIN 保留未匹配行(NULL 键行保留、右表列补 NULL)', async () => {
|
||||||
|
await db.defineTable('l', { id: { type: 'string', primaryKey: true }, k: { type: 'string' } });
|
||||||
|
await db.defineTable('r', { id: { type: 'string', primaryKey: true }, k: { type: 'string' } });
|
||||||
|
await db.query("INSERT INTO l VALUES ('l1','x'),('l2',NULL)");
|
||||||
|
await db.query("INSERT INTO r VALUES ('r1','x')");
|
||||||
|
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query('SELECT l.id AS lid, r.id AS rid FROM l LEFT JOIN r ON l.k = r.k ORDER BY lid'),
|
||||||
|
);
|
||||||
|
expect(rows).toEqual([{ lid: 'l1', rid: 'r1' }, { lid: 'l2', rid: null }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// maxRowsPerQuery 与 NaN 落盘(与引擎无关,用 memory 验证即可)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('[v0.8.0] A29 写路径的行数上限保护', () => {
|
||||||
|
it('INSERT ... SELECT 超过 maxRowsPerQuery → 显式报错(不静默截断)', async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: 'a29-limit', mode: 'memory', maxRowsPerQuery: 2 });
|
||||||
|
await db.defineTable('src', { id: { type: 'string', primaryKey: true } });
|
||||||
|
await db.defineTable('dst', { id: { type: 'string', primaryKey: true } });
|
||||||
|
// 注意:每条 INSERT 都受上限约束,因此分 4 条语句各写 1 行来堆积 4 行源数据
|
||||||
|
for (const id of ['1', '2', '3', '4']) {
|
||||||
|
await db.query(`INSERT INTO src VALUES ('${id}')`);
|
||||||
|
}
|
||||||
|
expect(rowsOf<{ c: number }>(await db.query('SELECT COUNT(*) AS c FROM src'))).toEqual([{ c: 4 }]);
|
||||||
|
|
||||||
|
// 修复前:行源被 maxRowsPerQuery 静默截断为 2 行 → 只写入 2 行并报成功。
|
||||||
|
// 现在:行源不截断,写路径显式报错,dst 保持空。
|
||||||
|
await expect(db.query('INSERT INTO dst SELECT id FROM src')).rejects.toMatchObject({
|
||||||
|
code: 'QUERY_ERROR',
|
||||||
|
});
|
||||||
|
expect(rowsOf(await db.query('SELECT id FROM dst'))).toHaveLength(0);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('INSERT ... SELECT 未超上限时正常写入全部行源', async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: 'a29-within', mode: 'memory', maxRowsPerQuery: 2 });
|
||||||
|
await db.defineTable('src', { id: { type: 'string', primaryKey: true } });
|
||||||
|
await db.defineTable('dst', { id: { type: 'string', primaryKey: true } });
|
||||||
|
await db.query("INSERT INTO src VALUES ('1'),('2')");
|
||||||
|
await db.query('INSERT INTO dst SELECT id FROM src');
|
||||||
|
expect(rowsOf(await db.query('SELECT id FROM dst ORDER BY id'))).toEqual([{ id: '1' }, { id: '2' }]);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('INSERT ... VALUES 同样受上限保护', async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: 'a29-limit-values', mode: 'memory', maxRowsPerQuery: 2 });
|
||||||
|
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
|
||||||
|
await expect(db.query("INSERT INTO t VALUES ('1'),('2'),('3')")).rejects.toMatchObject({
|
||||||
|
code: 'QUERY_ERROR',
|
||||||
|
});
|
||||||
|
expect(rowsOf(await db.query('SELECT id FROM t'))).toHaveLength(0);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('[v0.8.0] NaN / Infinity 拒绝落盘(B-1 规范化契约)', () => {
|
||||||
|
const ENGINES2 = ENGINES;
|
||||||
|
|
||||||
|
it.each(ENGINES2)('%s: NaN 写入被拒绝(否则重启后变 null)', async (label, mode, extra) => {
|
||||||
|
const db = await MetonaSqlark.create({ name: `nan-${label}`, mode, ...extra });
|
||||||
|
await db.defineTable('t', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
v: { type: 'number' },
|
||||||
|
});
|
||||||
|
await expect(db.table('t').insert({ id: '1', v: Number.NaN } as never)).rejects.toMatchObject({
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
});
|
||||||
|
await expect(db.table('t').insert({ id: '2', v: Number.POSITIVE_INFINITY } as never)).rejects.toMatchObject({
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
});
|
||||||
|
// 正常数值不受影响
|
||||||
|
await db.table('t').insert({ id: '3', v: 1.5 } as never);
|
||||||
|
expect(rowsOf<{ v: number }>(await db.query('SELECT v FROM t'))).toEqual([{ v: 1.5 }]);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -150,11 +150,15 @@ describe('[v0.8.0] B-1 校验器单元契约(compileValidator)', () => {
|
|||||||
flag: { type: 'boolean', default: false },
|
flag: { type: 'boolean', default: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
it('validateRow 填充 default、跳过 undefined、只保留 schema 列', () => {
|
it('validateRow 填充 default,未提供且无 default 的列补 NULL', () => {
|
||||||
const validator = compileValidator(schema);
|
const validator = compileValidator(schema);
|
||||||
const row = validator.validateRow({ id: '1', tag: 'a' });
|
const row = validator.validateRow({ id: '1', tag: 'a' });
|
||||||
expect(row).toEqual({ id: '1', tag: 'a', flag: false });
|
// v0.8.0:未提供且无 default 的列**显式补 null**。此前该键被整个删掉,
|
||||||
expect('name' in row).toBe(false);
|
// 于是 `SELECT id, tag, name FROM t` 对刚插入的行抛 COLUMN_NOT_FOUND: name,
|
||||||
|
// 而 `SELECT * FROM t` 又能返回 —— 同一行"有没有 name 列"两种结论。
|
||||||
|
expect(row).toEqual({ id: '1', tag: 'a', flag: false, name: null, n: null });
|
||||||
|
// 行必须包含**全部** schema 列(投影/排序/三值比较的统一前提)
|
||||||
|
expect(Object.keys(row).sort()).toEqual(['flag', 'id', 'n', 'name', 'tag']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('validateRow 拒绝未知列并一次列出全部', () => {
|
it('validateRow 拒绝未知列并一次列出全部', () => {
|
||||||
@@ -174,6 +178,8 @@ describe('[v0.8.0] B-1 校验器单元契约(compileValidator)', () => {
|
|||||||
id: '1',
|
id: '1',
|
||||||
tag: 'a',
|
tag: 'a',
|
||||||
flag: false,
|
flag: false,
|
||||||
|
name: null,
|
||||||
|
n: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user