Files
MetonaSqlark/tests/v080-unified-validation.test.ts
T
thzxx 841db2e049 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 零错误。
2026-09-15 00:00:08 +08:00

236 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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_ERRORmemory/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_ERRORA12', 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_ERRORA12', 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_FOUNDA17', 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');
}
});
});