每项都先用可执行探针复现出**错误的实际输出**,再修根因、补永久回归套件
(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 零错误。
236 lines
9.8 KiB
TypeScript
236 lines
9.8 KiB
TypeScript
/**
|
||
* v0.8.0 回归套件 —— B-1 统一行校验(工作流 B 第 1 项)
|
||
* ============================================================================
|
||
* 背景(PLAN-v0.7.5.md 根因 1 + 缺陷 A12/A17):
|
||
* 修复前存在**三份**行校验实现,覆盖面各不相同 —— Memory/KVStore/Hybrid 共用
|
||
* 一份"只查类型"的实现(**没有** maxLength/min/max),Aria 走 checkFieldType
|
||
* (有约束),schema.ts 是第三份。于是:
|
||
*
|
||
* A12: `CREATE TABLE t (name STRING(3))` + `INSERT ... 'abcdef'`
|
||
* → Aria 抛 VALIDATION_ERROR,memory/disk/hybrid **静默写入超长值**。
|
||
* 用户的约束是否生效取决于选了哪个引擎。
|
||
*
|
||
* A17: `INSERT INTO t (id, nope) VALUES ('1', 2)`
|
||
* → 四个引擎全部**静默丢弃 nope**、INSERT 报成功;随后 `SELECT nope`
|
||
* 又抛 COLUMN_NOT_FOUND —— 同一列名在写路径与读路径得到相反结论。
|
||
*
|
||
* 本套件对四引擎逐一断言相同结论,锁定"校验只有一份实现"。
|
||
*/
|
||
import { MetonaSqlark } from '../src/core';
|
||
import { compileValidator } from '../src/table/validation';
|
||
import { createSchema } from '../src/table/schema';
|
||
import { rows as rowsOf, expectCode } 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] B-1 统一行校验 —— 四引擎行为必须一致', () => {
|
||
describe.each(ENGINES)('%s 引擎', (label, mode, extra) => {
|
||
let db: MetonaSqlark;
|
||
|
||
beforeEach(async () => {
|
||
db = await MetonaSqlark.create({ name: `b1-${label}-${Math.random().toString(36).slice(2)}`, mode, ...extra });
|
||
});
|
||
|
||
afterEach(async () => {
|
||
await db.close();
|
||
});
|
||
|
||
// ---- A12: maxLength / min / max ----
|
||
|
||
it('maxLength 超长 → VALIDATION_ERROR(A12)', async () => {
|
||
await db.defineTable('t', {
|
||
id: { type: 'string', primaryKey: true },
|
||
name: { type: 'string', maxLength: 3 },
|
||
});
|
||
await expectCode(db.query("INSERT INTO t VALUES ('1', 'abcd')"), 'VALIDATION_ERROR');
|
||
// 边界值恰好等于上限必须通过
|
||
await db.query("INSERT INTO t VALUES ('2', 'abc')");
|
||
expect(rowsOf(await db.query('SELECT id FROM t'))).toHaveLength(1);
|
||
});
|
||
|
||
it('min / max 越界 → VALIDATION_ERROR(A12)', async () => {
|
||
await db.defineTable('t', {
|
||
id: { type: 'string', primaryKey: true },
|
||
n: { type: 'number', min: 1, max: 10 },
|
||
});
|
||
await expectCode(db.query("INSERT INTO t VALUES ('1', 0)"), 'VALIDATION_ERROR');
|
||
await expectCode(db.query("INSERT INTO t VALUES ('2', 11)"), 'VALIDATION_ERROR');
|
||
await db.query("INSERT INTO t VALUES ('3', 1), ('4', 10)");
|
||
expect(rowsOf(await db.query('SELECT id FROM t'))).toHaveLength(2);
|
||
});
|
||
|
||
it('UPDATE 同样受 maxLength / min / max 约束(A12)', async () => {
|
||
await db.defineTable('t', {
|
||
id: { type: 'string', primaryKey: true },
|
||
name: { type: 'string', maxLength: 3 },
|
||
n: { type: 'number', min: 0 },
|
||
});
|
||
await db.query("INSERT INTO t VALUES ('1', 'abc', 5)");
|
||
await expectCode(db.query("UPDATE t SET name = 'abcd' WHERE id = '1'"), 'VALIDATION_ERROR');
|
||
await expectCode(db.query('UPDATE t SET n = -1 WHERE id = \'1\''), 'VALIDATION_ERROR');
|
||
// 失败的更新不得留下任何部分修改
|
||
const after = rowsOf<{ name: string; n: number }>(await db.query('SELECT name, n FROM t'));
|
||
expect(after).toEqual([{ name: 'abc', n: 5 }]);
|
||
});
|
||
|
||
// ---- A17: 未知列 ----
|
||
|
||
it('INSERT 未知列 → COLUMN_NOT_FOUND(A17)', async () => {
|
||
await db.defineTable('t', {
|
||
id: { type: 'string', primaryKey: true },
|
||
n: { type: 'number' },
|
||
});
|
||
await expectCode(db.query("INSERT INTO t (id, nope) VALUES ('1', 2)"), 'COLUMN_NOT_FOUND');
|
||
// 关键:不能"部分插入"——报错后表必须仍是空的
|
||
expect(rowsOf(await db.query('SELECT id FROM t'))).toHaveLength(0);
|
||
});
|
||
|
||
it('INSERT 未知列的错误消息列出已知列(便于自查)', async () => {
|
||
await db.defineTable('t', {
|
||
id: { type: 'string', primaryKey: true },
|
||
n: { type: 'number' },
|
||
});
|
||
await expect(db.query("INSERT INTO t (id, nope) VALUES ('1', 2)")).rejects.toThrow(
|
||
/Unknown column "nope" in table "t"\. Known columns: id, n/,
|
||
);
|
||
});
|
||
|
||
it('TABLE API 直通路径同样拒绝未知列(A17 的真实形态)', async () => {
|
||
await db.defineTable('t', {
|
||
id: { type: 'string', primaryKey: true },
|
||
n: { type: 'number' },
|
||
});
|
||
// db.table().insert() 不经 Executor —— 校验必须在引擎边界生效,
|
||
// 否则这条路径仍然静默丢列
|
||
await expectCode(
|
||
db.table('t').insert({ id: '1', nope: 2 } as never),
|
||
'COLUMN_NOT_FOUND',
|
||
);
|
||
expect(rowsOf(await db.query('SELECT id FROM t'))).toHaveLength(0);
|
||
});
|
||
|
||
it('UPDATE 未知列 → COLUMN_NOT_FOUND(原有行为,回归护栏)', async () => {
|
||
await db.defineTable('t', {
|
||
id: { type: 'string', primaryKey: true },
|
||
n: { type: 'number' },
|
||
});
|
||
await db.query("INSERT INTO t VALUES ('1', 2)");
|
||
await expectCode(db.query('UPDATE t SET nope = 3 WHERE id = \'1\''), 'COLUMN_NOT_FOUND');
|
||
});
|
||
|
||
// ---- 批量原子性 ----
|
||
|
||
it('多行 INSERT 中任一行违反约束 → 整条语句不写入', async () => {
|
||
await db.defineTable('t', {
|
||
id: { type: 'string', primaryKey: true },
|
||
name: { type: 'string', maxLength: 3 },
|
||
});
|
||
await expectCode(
|
||
db.query("INSERT INTO t VALUES ('1', 'ok'), ('2', 'toolong')"),
|
||
'VALIDATION_ERROR',
|
||
);
|
||
// 第 1 行合法,但语句级原子性要求整批拒绝
|
||
expect(rowsOf(await db.query('SELECT id FROM t'))).toHaveLength(0);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('[v0.8.0] B-1 校验器单元契约(compileValidator)', () => {
|
||
const schema = createSchema('t', {
|
||
id: { type: 'string', primaryKey: true },
|
||
name: { type: 'string', maxLength: 3 },
|
||
n: { type: 'number', min: 0, max: 10 },
|
||
tag: { type: 'string', required: true },
|
||
flag: { type: 'boolean', default: false },
|
||
});
|
||
|
||
it('validateRow 填充 default,未提供且无 default 的列补 NULL', () => {
|
||
const validator = compileValidator(schema);
|
||
const row = validator.validateRow({ id: '1', tag: 'a' });
|
||
// v0.8.0:未提供且无 default 的列**显式补 null**。此前该键被整个删掉,
|
||
// 于是 `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 拒绝未知列并一次列出全部', () => {
|
||
const validator = compileValidator(schema);
|
||
expect(() => validator.validateRow({ id: '1', tag: 'a', x: 1, y: 2 })).toThrow(
|
||
/Unknown columns "x", "y"/,
|
||
);
|
||
});
|
||
|
||
it('validateRow 的 knownColumns 收窄允许集合(INSERT 显式列清单)', () => {
|
||
const validator = compileValidator(schema);
|
||
// 只允许 id/tag 出现 → name 虽在 schema 里也算未知列
|
||
expect(() => validator.validateRow({ id: '1', tag: 'a', name: 'x' }, ['id', 'tag'])).toThrow(
|
||
/Unknown column "name"/,
|
||
);
|
||
expect(validator.validateRow({ id: '1', tag: 'a' }, ['id', 'tag'])).toEqual({
|
||
id: '1',
|
||
tag: 'a',
|
||
flag: false,
|
||
name: null,
|
||
n: null,
|
||
});
|
||
});
|
||
|
||
it('validatePartial 只校验出现的列(undefined 不触发 required)', () => {
|
||
const validator = compileValidator(schema);
|
||
const { values, unknown } = validator.validatePartial({ n: 5, ghost: 1 });
|
||
expect(values).toEqual({ n: 5 });
|
||
expect(unknown).toEqual(['ghost']);
|
||
// required 列未出现 → 不报错(UPDATE 语义)
|
||
expect(validator.validatePartial({ name: 'ab' }).values).toEqual({ name: 'ab' });
|
||
// 但出现且越界 → 报错
|
||
expect(() => validator.validatePartial({ name: 'abcd' })).toThrow('exceeds max length');
|
||
});
|
||
|
||
it('validatePartial 拒绝 __proto__ 列名(原型污染防护)', () => {
|
||
const validator = compileValidator(schema);
|
||
expect(() => validator.validatePartial(JSON.parse('{"__proto__": {"polluted": 1}}'))).toThrow(
|
||
/__proto__/,
|
||
);
|
||
});
|
||
|
||
it('null 通过 required 之外的约束(NULL 不是类型错误)', () => {
|
||
const validator = compileValidator(schema);
|
||
expect(validator.validateRow({ id: '1', tag: 'a', name: null, n: null })).toEqual({
|
||
id: '1',
|
||
tag: 'a',
|
||
name: null,
|
||
n: null,
|
||
flag: false,
|
||
});
|
||
});
|
||
|
||
it('primaryKey 隐含 NOT NULL', () => {
|
||
const validator = compileValidator(schema);
|
||
expect(() => validator.validateRow({ id: null, tag: 'a' })).toThrow(/Primary key column "id"/);
|
||
expect(() => validator.validateRow({ tag: 'a' })).toThrow(/Primary key column "id"/);
|
||
});
|
||
|
||
it('required 与 primaryKey 各自给出稳定错误消息', () => {
|
||
const validator = compileValidator(schema);
|
||
expect(() => validator.validateRow({ id: '1', tag: null })).toThrow('Column "tag" is required in table "t"');
|
||
});
|
||
|
||
it('类型错误使用 TYPE_ERROR 码(可被调用方分类处理)', () => {
|
||
const validator = compileValidator(schema);
|
||
try {
|
||
validator.validateRow({ id: 1, tag: 'a' });
|
||
throw new Error('should have thrown');
|
||
} catch (error) {
|
||
expect((error as { code?: string }).code).toBe('TYPE_ERROR');
|
||
}
|
||
});
|
||
});
|