Files
MetonaSqlark/tests/v080-unified-validation.test.ts
T
thzxx 2a109ef933 feat(B-1): 统一行校验 choke point —— 消除三份分叉的校验实现(A12/A17)
背景(PLAN-v0.7.5.md 根因 1):
修复前有**三份**行校验实现,覆盖面各不相同:

  位置                                        类型 required PK非空 maxLength min/max 未知列
  engine/memory.ts(disk/hybrid 共用)          ✓     ✓       ✓      ✗        ✗     静默丢弃
  engine/aria/index.ts → checkFieldType         ✓     ✓       ✓      ✓        ✓     静默丢弃
  table/schema.ts                               ✓     ✓       ✓      ✓        ✓     静默丢弃

后果一(A12):同一份 schema、同一条 INSERT 是否报约束错误取决于引擎选择 ——
`CREATE TABLE t (name STRING(3))` + 插入 'abcdef' 在 Aria 抛错,在
memory/disk/hybrid 静默写入超长值。

后果二(A17):四个引擎对未知列一律静默丢弃。`INSERT INTO t (id, nope) VALUES
('1',2)` 报成功,随后 `SELECT nope` 报 COLUMN_NOT_FOUND —— 同一列名在写路径与
读路径得到**相反结论**。TABLE API 直通路径尤其明显(executor 按 schema 列序
构造行,nope 那个位置根本没有值,所以连"校验 stmt.columns"都拦不住)。

根治方式:
1. 新增 src/table/validation.ts —— 唯一校验定义 `compileValidator(schema)`,
   约束覆盖面取三者并集,并把**规范化**(default 填充、undefined 跳过、
   __proto__ 防污染)与校验放在同一处。
   三种载荷形态刻意分成三个显式入口,不合成带 options 的函数:
     - validateRow(row, knownColumns?)  INSERT 语义(default 生效、缺列合法)
     - validatePartial(row)             UPDATE 语义(只校验出现的列)
     - assertNoUnknownColumns           独立可复用的列名存在性检查
   混成一个函数会让"required 是否生效"取决于调用方参数,重新引入跨路径差异。
2. MemoryEngine / AriaEngine 的私有 validateRow 改为委托;schema.ts 的公开
   validateRow 同样委托(API 不变,实现只剩一份)。
3. 四个引擎新增 validatePayload(table, rows, mode)(IStorageEngine 契约),
   Executor 在**任何副作用之前**调用:多行批量整体判定,错误消息一次列出全部
   未知列与已知列清单。
4. executeInsert 显式校验 stmt.columns 全部存在(A17)。
5. UPDATE 的外键级联写入(applyUpdateCascade)从"直接赋值"改为过
   validatePartial —— 此前 CASCADE 把新主键写进引用列时绕过 maxLength/min/max,
   与 A12 属同一类"校验只在部分写入路径生效"。

连带修正(测试夹具本身不忠实,B-1 使其暴露):
  - tests/engine/aria-cache.test.ts 的 makeRows 无条件返回 {id,name,age},
    部分用例的表只有 {id,name} —— 多余列被静默丢弃所以"通过"。新增 rowsFor()
    按 schema 裁剪,让夹具忠实反映表结构(而不是放宽校验)。
  - tests/v073-fixes.test.ts "schema 外列不持久化" 改为断言写路径即拒绝,
    并保留"合法行落盘后不含额外列"的检查。

验证:
  - 新增 tests/v080-unified-validation.test.ts:8 项 × 4 引擎 + 9 项校验器
    单元契约,共 41 断言;
  - 全量 84 套件 / 1499 测试通过;typecheck(src+tests) 与 lint 零错误。
2026-09-14 23:38:58 +08:00

230 lines
9.3 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、跳过 undefined、只保留 schema 列', () => {
const validator = compileValidator(schema);
const row = validator.validateRow({ id: '1', tag: 'a' });
expect(row).toEqual({ id: '1', tag: 'a', flag: false });
expect('name' in row).toBe(false);
});
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,
});
});
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');
}
});
});