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 零错误。
This commit is contained in:
@@ -877,6 +877,23 @@ export class QueryExecutor {
|
||||
if (!schema) throw new DatabaseError(`Table "${stmt.into}" does not exist`, 'TABLE_NOT_FOUND');
|
||||
const colNames = stmt.columns ?? Object.keys(schema.columns);
|
||||
|
||||
// v0.8.0(A17): INSERT 的目标列必须存在。
|
||||
//
|
||||
// 此前**不校验** stmt.columns:`INSERT INTO t (id, nope) VALUES ('1', 2)` 里
|
||||
// `nope` 在下面的循环中被当作列名写进 row,随后引擎的 validateRow 只遍历
|
||||
// schema 列 → `nope` 被静默丢弃、INSERT 报成功。用户以为写进去了,
|
||||
// 而 `SELECT nope` 又报 COLUMN_NOT_FOUND —— 写路径与读路径对同一列名给出
|
||||
// 相反结论。这里显式报错(与读路径同一错误码),并一次列出全部未知列。
|
||||
const unknownColumns = colNames.filter((col) => !(col in schema.columns));
|
||||
if (unknownColumns.length > 0) {
|
||||
throw new DatabaseError(
|
||||
`Unknown column${unknownColumns.length > 1 ? 's' : ''} ${unknownColumns
|
||||
.map((c) => `"${c}"`)
|
||||
.join(', ')} in table "${stmt.into}". Known columns: ${Object.keys(schema.columns).join(', ')}`,
|
||||
'COLUMN_NOT_FOUND',
|
||||
);
|
||||
}
|
||||
|
||||
// INSERT INTO ... SELECT ...(v0.3.0)
|
||||
if (stmt.select) {
|
||||
const selectRows = await this.executeSelectPart(stmt.select);
|
||||
@@ -903,6 +920,8 @@ export class QueryExecutor {
|
||||
}
|
||||
return mapped;
|
||||
});
|
||||
// v0.8.0(B-1): 在任何写入之前执行统一校验(未知列/类型/maxLength/min/max/required)
|
||||
await this.engine.validatePayload?.(stmt.into, rows, 'insert');
|
||||
return this.engine.insert(stmt.into, rows);
|
||||
}
|
||||
|
||||
@@ -911,6 +930,8 @@ export class QueryExecutor {
|
||||
for (let i = 0; i < colNames.length; i++) { if (i < vals.length) row[colNames[i]] = vals[i]; }
|
||||
return row;
|
||||
});
|
||||
// v0.8.0(B-1): 同上 —— 校验先于任何副作用,多行批量整体判定
|
||||
await this.engine.validatePayload?.(stmt.into, rows, 'insert');
|
||||
return this.engine.insert(stmt.into, rows);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user