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:
thzxx
2026-09-14 23:38:58 +08:00
parent 67e72d897f
commit 2a109ef933
11 changed files with 678 additions and 108 deletions
+23 -24
View File
@@ -10,7 +10,8 @@ import type { IStorageEngine } from '../interface';
import type { QueryPlan, TableSchema, ColumnDef, WhereCondition } from '../../constants';
import { DatabaseError } from '../../constants';
import { matchWhere, applyOrderBy, projectColumns, containsUnresolvedSubqueries } from '../../query/where-matcher';
import { checkFieldType, stripUndefinedUpdates } from '../../table/schema';
import { stripUndefinedUpdates } from '../../table/schema';
import { compileValidator } from '../../table/validation';
import type { AriaEngineConfig, SSTableMeta } from './types';
import { DEFAULT_ARIA_CONFIG } from './types';
@@ -1788,31 +1789,29 @@ export class AriaEngine implements IStorageEngine {
}
private validateRow(schema: TableSchema, row: Record<string, unknown>): Record<string, unknown> {
const validated: Record<string, unknown> = {};
for (const [colName, colDef] of Object.entries(schema.columns)) {
let value = row[colName];
if (value === undefined && colDef.default !== undefined) value = colDef.default;
if (colDef.required && (value === undefined || value === null)) {
throw new DatabaseError(`Column "${colName}" is required in table "${schema.name}"`, 'VALIDATION_ERROR');
}
// v0.7.4: 主键列强制非空(SQL 语义 PK 隐含 NOT NULL)——
// 此前 null/undefined 主键被 String() 化为 "null"/"undefined" 静默入库
if (colDef.primaryKey && (value === undefined || value === null)) {
throw new DatabaseError(
`Primary key column "${colName}" in table "${schema.name}" cannot be null or undefined`,
'VALIDATION_ERROR',
);
}
if (value !== undefined && value !== null) {
this.checkType(colName, colDef.type, value, colDef);
}
if (value !== undefined) validated[colName] = value;
}
return validated;
// v0.8.0B-1):委托给**唯一**的行校验实现(table/validation.ts)。
//
// 此前这里是第二份独立实现(经由 checkFieldType 恰好覆盖了 maxLength/min/max
// 而 Memory 那份没有)—— 于是同一份 schema、同一条 INSERT 是否报约束错误
// 取决于选了哪个引擎(缺陷 A12),且两者对未知列都静默丢弃(A17)。
return compileValidator(schema).validateRow(row);
}
private checkType(colName: string, type: string, value: unknown, colDef?: ColumnDef): void {
checkFieldType('', colName, type as any, value, colDef);
/**
* v0.8.0(B-1):写入前置校验 —— 见 `IStorageEngine.validatePayload` 契约。
*/
async validatePayload(
tableName: string,
rows: Record<string, unknown>[],
mode: 'insert' | 'update' = 'insert',
): Promise<void> {
this.ensureTable(tableName);
const schema = this.schemas.get(tableName)!;
const validator = compileValidator(schema);
for (const row of rows) {
if (mode === 'update') validator.validatePartial(stripUndefinedUpdates(row));
else validator.validateRow(row);
}
}
// =======================================================================
+26
View File
@@ -93,6 +93,32 @@ export interface IStorageEngine {
/** 插入行,返回主键值列表 */
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
/**
* v0.8.0(B-1):写入前置校验 —— 未知列、类型、`maxLength` / `min` / `max`、
* `required` / 主键非空、`__proto__` 防污染,全部由 `table/validation.ts` 的
* **唯一**实现判定。
*
* 为什么把它放进引擎接口而不是留在 Executor:
* - **未知列**必须在引擎边界拦下。`INSERT INTO t (id, nope) VALUES ('1', 2)`
* 若在 Executor 拦,QueryBuilder / `db.table().insert()` 等直通路径仍然静默
* 丢列(缺陷 A17 的真实形态:executor 按 schema 列序构造行,那个位置没有值,
* 于是 `nope` 既不进 schema 也无从校验)。
* - **规范化必须在同一处**:`default` 填充与类型检查一旦分家,就会出现
* "executor 校验通过、引擎写入时又被改写"这类双份语义。
*
* 引擎**必须**使用 `compileValidator` 而不是自己实现 —— 此前 Memory 与 Aria
* 各写一份,`maxLength` 只在 Aria 生效(A12)。
*
* @param mode `'insert'`default 生效、缺列合法)或 `'update'`(仅校验出现的列)。
* 默认 `'insert'`,与历史行为一致。
* @throws DatabaseError COLUMN_NOT_FOUND | VALIDATION_ERROR | TYPE_ERROR
*/
validatePayload?(
tableName: string,
rows: Record<string, unknown>[],
mode?: 'insert' | 'update',
): Promise<void>;
/** 查询行 */
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
+16
View File
@@ -255,6 +255,22 @@ export class KVStoreEngine implements IStorageEngine {
// ---- CRUD ----
/**
* v0.8.0(B-1):写入前置校验 —— 委托给内存引擎(两者共享同一 schema 表)。
*
* KVStore/Hybrid 的行校验一直"继承"自 MemoryEngine,这正是 A12 的成因:
* 三者共用一份**缺 maxLength/min/max** 的实现。现在共享的是
* `table/validation.ts` 的规范实现,继承关系不再影响约束覆盖面。
*/
async validatePayload(
tableName: string,
rows: Record<string, unknown>[],
mode: 'insert' | 'update' = 'insert',
): Promise<void> {
this.ensureOpen();
return this.memory.validatePayload(tableName, rows, mode);
}
async insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]> {
this.ensureOpen();
const pks = await this.memory.insert(tableName, rows);
+48 -28
View File
@@ -9,6 +9,7 @@ import { DatabaseError } from '../constants';
import { cloneRow } from './interface';
import { matchWhere, applyOrderBy, projectColumns, containsUnresolvedSubqueries } from '../query/where-matcher';
import { stripUndefinedUpdates } from '../table/schema';
import { compileValidator, type RowValidator } from '../table/validation';
export class MemoryEngine implements IStorageEngine {
readonly name = 'memory';
@@ -498,10 +499,20 @@ export class MemoryEngine implements IStorageEngine {
const refTableData = this.tables.get(refTableName);
if (!refTableData) continue;
if (colDef.onUpdate !== 'CASCADE' && colDef.onUpdate !== 'SET NULL') continue;
// v0.8.0(B-1):级联写入也必须过统一校验。
//
// 此前这里**直接赋值**绕过校验:`onUpdate: 'CASCADE'` 把新主键写入引用列时,
// 若该列有 maxLength / min / max 约束(新主键更长或超出范围),
// 约束被静默绕过 —— 与 A12 是同一类"校验只在部分写入路径生效"的问题。
// SET NULL 到 required/非空列的检查由 checkUpdateRestrict 在任何修改前完成,
// 此处再校验可同时覆盖 maxLength/min/max 这类"具体值相关"的约束。
const validator = this.rowValidator(refSchema);
for (const [refPk, refRow] of refTableData) {
if (String(refRow[colName]) !== oldPk) continue;
const nextValue = colDef.onUpdate === 'CASCADE' ? newPk : null;
const { values } = validator.validatePartial({ [colName]: nextValue });
this.removeIndexEntries(refTableName, refRow, refPk);
refRow[colName] = colDef.onUpdate === 'CASCADE' ? newPk : null;
refRow[colName] = values[colName];
this.updateIndexes(refTableName, refRow, refPk);
}
}
@@ -750,35 +761,44 @@ export class MemoryEngine implements IStorageEngine {
}
private validateRow(schema: TableSchema, row: Record<string, unknown>): Record<string, unknown> {
const validated: Record<string, unknown> = {};
for (const [colName, colDef] of Object.entries(schema.columns)) {
let value = row[colName];
if (value === undefined && colDef.default !== undefined) value = colDef.default;
if (colDef.required && (value === undefined || value === null)) {
throw new DatabaseError(`Column "${colName}" is required in table "${schema.name}"`, 'VALIDATION_ERROR');
}
// v0.7.4: 主键列强制非空(SQL 语义 PK 隐含 NOT NULL)——
// 此前 null/undefined 主键被 String() 化为 "null"/"undefined" 静默入库
if (colDef.primaryKey && (value === undefined || value === null)) {
throw new DatabaseError(
`Primary key column "${colName}" in table "${schema.name}" cannot be null or undefined`,
'VALIDATION_ERROR',
);
}
if (value !== undefined && value !== null) this.checkType(colName, colDef.type, value);
if (value !== undefined) validated[colName] = value;
}
return validated;
// v0.8.0B-1):委托给**唯一**的行校验实现(table/validation.ts)。
//
// 此前这里是第三份独立实现:只做类型检查,**没有** maxLength / min / max
// 约束(Aria 有)—— 于是同一份 schema、同一条 INSERT 是否报错取决于引擎
//(缺陷 A12)。同时它对未知列静默丢弃(A17)。
return this.rowValidator(schema).validateRow(row);
}
private checkType(colName: string, type: string, value: unknown): void {
const jsType = typeof value;
switch (type) {
case 'string': if (jsType !== 'string') throw new DatabaseError(`Column "${colName}" expects string, got ${jsType}`, 'TYPE_ERROR'); break;
case 'number': if (jsType !== 'number') throw new DatabaseError(`Column "${colName}" expects number, got ${jsType}`, 'TYPE_ERROR'); break;
case 'boolean': if (jsType !== 'boolean') throw new DatabaseError(`Column "${colName}" expects boolean, got ${jsType}`, 'TYPE_ERROR'); break;
case 'date': if (jsType !== 'string' || isNaN(Date.parse(value as string))) throw new DatabaseError(`Column "${colName}" expects valid date`, 'TYPE_ERROR'); break;
case 'json': if (jsType !== 'object') throw new DatabaseError(`Column "${colName}" expects object/array, got ${jsType}`, 'TYPE_ERROR'); break;
/**
* 取该 schema 的行校验器(每次调用重新编译)。
*
* 不缓存在引擎字段上:`alterTable` 会原地修改 schema 对象,
* 长期缓存会继续用过期列定义("加了列却仍被当未知列"这类难查问题)。
* 编译本身只是 `Object.entries` + Set 构造,相对一次 INSERT 的索引维护可忽略。
*/
private rowValidator(schema: TableSchema): RowValidator {
return compileValidator(schema);
}
/**
* v0.8.0B-1):写入前置校验(见 `IStorageEngine.validatePayload` 契约)。
*
* 引擎在 `insert` / `update` 内部**同样**会校验 —— 本方法只是让 Executor 与
* QueryBuilder 能在"开始写入之前"拿到同一套判定结果,从而:
* - 多行 INSERT 的预检发生在任何副作用之前(错误信息带列名清单);
* - 直通路径与 SQL 路径不可能给出不同结论(同一个 `compileValidator`)。
*/
async validatePayload(
tableName: string,
rows: Record<string, unknown>[],
mode: 'insert' | 'update' = 'insert',
): Promise<void> {
this.ensureTable(tableName);
const schema = this.schemas.get(tableName)!;
const validator = this.rowValidator(schema);
for (const row of rows) {
if (mode === 'update') validator.validatePartial(stripUndefinedUpdates(row));
else validator.validateRow(row);
}
}
+15
View File
@@ -208,6 +208,21 @@ export class HybridEngine implements IStorageEngine {
throw error;
}
/**
* v0.8.0(B-1):写入前置校验 —— 委托给内存引擎(与磁盘引擎同 schema)。
*
* 关键点:**只判定一次**。Hybrid 的 write-through 会把同一批行先写内存再写磁盘,
* 两个引擎各自校验会给出同一结论(现在共享同一个 `compileValidator`),
* 但由本方法统一前置,可保证多行批量在任何副作用之前整体失败。
*/
async validatePayload(
tableName: string,
rows: Record<string, unknown>[],
mode: 'insert' | 'update' = 'insert',
): Promise<void> {
return this.memoryEngine.validatePayload(tableName, rows, mode);
}
async insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]> {
const pks = await this.memoryEngine.insert(tableName, rows);
// write-through: 同步写入磁盘
+21
View File
@@ -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.0A17: 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);
}
+14 -39
View File
@@ -5,6 +5,7 @@
import type { TableSchema, ColumnDef, FieldType } from '../constants';
import { FIELD_TYPES, DatabaseError } from '../constants';
import { compileValidator } from './validation';
// ---------------------------------------------------------------------------
// Schema 工具
@@ -83,46 +84,20 @@ export function stripUndefinedUpdates(updates: Record<string, unknown>): Record<
return clean;
}
/** 校验行数据 */
/**
* 校验行数据(INSERT 语义:`default` 生效、缺列合法)。
*
* v0.8.0B-1):实现已迁移到 `table/validation.ts#compileValidator`。
* 这里保留同名导出是因为它是**公开 API**`table/index.ts` 与 `src/index.ts` 转发),
* 且历史调用方(含 tests)依赖它 —— 但实现只有一份:委托过去。
*
* 为什么必须收敛:修复前本项目有**三份**行校验(memory / aria / 本文件),
* 覆盖面不同(memory 那份缺 maxLength / min / max)。于是"约束是否生效"取决于
* 引擎选择(缺陷 A12),而"未知列"三份都静默丢弃(A17)。
* 现在三处都是同一个 `compileValidator`。
*/
export function validateRow(schema: TableSchema, row: Record<string, unknown>): Record<string, unknown> {
const validated: Record<string, unknown> = {};
for (const [colName, colDef] of Object.entries(schema.columns)) {
let value = row[colName];
// 默认值
if (value === undefined && colDef.default !== undefined) {
value = colDef.default;
}
// 必填检查
if (colDef.required && (value === undefined || value === null)) {
throw new DatabaseError(
`Column "${colName}" is required in table "${schema.name}"`,
'VALIDATION_ERROR',
);
}
// v0.7.4: 主键列强制非空(SQL 语义 PK 隐含 NOT NULL)——
// 此前 null/undefined 主键被 String() 化为 "null"/"undefined" 静默入库
if (colDef.primaryKey && (value === undefined || value === null)) {
throw new DatabaseError(
`Primary key column "${colName}" in table "${schema.name}" cannot be null or undefined`,
'VALIDATION_ERROR',
);
}
// 类型检查
if (value !== undefined && value !== null) {
checkFieldType(schema.name, colName, colDef.type, value, colDef);
}
if (value !== undefined) {
validated[colName] = value;
}
}
return validated;
return compileValidator(schema).validateRow(row);
}
/** 检查字段类型(含约束校验) */
+229
View File
@@ -0,0 +1,229 @@
/**
* metona-sqlark 统一行校验 —— 存储写入的**唯一**验证与规范化入口(v0.8.0)
* @module table/validation
*
* ============================================================================
* 为什么必须合并(PLAN-v0.7.5.md 根因 1:关系语义在引擎间重复实现)
* ============================================================================
* 修复前项目里存在**三份**行校验实现,覆盖范围各不相同:
*
* | 位置 | 类型检查 | required | PK 非空 | maxLength | min/max | 未知列 |
* |---|---|---|---|---|---|---|
* | `engine/memory.ts#validateRow`disk/hybrid 继承) | ✓ | ✓ | ✓ | ✗ | ✗ | 静默丢弃 |
* | `engine/aria/index.ts#validateRow` → `checkFieldType` | ✓ | ✓ | ✓ | ✓ | ✓ | 静默丢弃 |
* | `table/schema.ts#validateRow` | ✓ | ✓ | ✓ | ✓ | ✓ | 静默丢弃 |
*
* 于是**同一份 schema、同一条 INSERT** 在 Aria 上抛 `maxLength` 错误,在
* memory/disk/hybrid 上静默写入超长值(缺陷 A12)—— 用户的约束是否生效
* 取决于他选了哪个引擎,且没有任何提示。
*
* 更糟的是"未知列"`INSERT INTO t (id, nope) VALUES ('1', 2)` 在四个引擎上
* 都**静默丢弃 `nope`**、插入成功。用户以为写进去了,`SELECT nope` 又报
* COLUMN_NOT_FOUND —— 写路径与读路径对同一列名给出相反结论(缺陷 A17)。
*
* 本模块把校验收敛成**一个定义**`compileValidator`),所有引擎与 QueryBuilder
* 都从它取校验器:
* - 约束覆盖面是"并集",不可能再出现"Aria 报错、memory 不报"
* - 未知列变成**显式错误**`COLUMN_NOT_FOUND`,与读路径同一错误码);
* - 校验与**规范化**在同一处完成(`__proto__` 防污染、`undefined` 跳过、
* `default` 填充),引擎只负责存储,不再各自解释 schema。
*
* ============================================================================
* 三种载荷形态,为什么不能合成一个函数
* ============================================================================
* - `validateRow` INSERT 语义:`default` 生效、缺列合法、`required` 按最终值判;
* - `validatePartial` UPDATE 语义:**只校验出现的列**`{ a: undefined }` 表示
* "不更新 a",不能被 required/min/max 判失败);
* - `assertNoUnknownColumns` 独立可复用的"列名存在性"检查(写路径预检)。
*
* 把它们混成一个带 options 的函数会让"required 是否生效"取决于调用方参数,
* 从而重新引入跨路径差异 —— 本模块刻意保持三个显式入口。
*/
import type { TableSchema, ColumnDef, FieldType } from '../constants';
import { DatabaseError } from '../constants';
import { checkFieldType } from './schema';
// ---------------------------------------------------------------------------
// 结果类型
// ---------------------------------------------------------------------------
/** 行校验器编译结果(单次编译,多次复用) */
export interface RowValidator {
/** 表名(用于错误消息) */
readonly table: string;
/** schema 里定义的列名集合 */
readonly columns: ReadonlySet<string>;
/**
* INSERT 语义校验 + 规范化。
*
* @param row 待写入的行(不会被修改)
* @param knownColumns 调用方已知/允许的列名(如 INSERT 显式列清单)。
* 省略时以 schema 列为准。**超出该集合的键即未知列 → 抛错。**
* @returns 规范化后的新行(仅含 schema 列,`undefined` 不落键,`default` 已填充)
* @throws DatabaseError COLUMN_NOT_FOUND | VALIDATION_ERROR | TYPE_ERROR
*/
validateRow(row: Record<string, unknown>, knownColumns?: readonly string[]): Record<string, unknown>;
/**
* UPDATE 语义校验 + 规范化:仅校验出现的列。
*
* @returns `{ values, unknown }` —— `values` 是规范化后的更新载荷,
* `unknown` 是 schema 里不存在的键(由调用方决定是否报错,
* 因为引擎内部级联等路径可能携带合并行的派生列)。
*/
validatePartial(row: Record<string, unknown>): {
values: Record<string, unknown>;
unknown: string[];
};
}
// ---------------------------------------------------------------------------
// 编译
// ---------------------------------------------------------------------------
/**
* 把 schema 编译成可复用的行校验器。
*
* 为什么"编译"而不是每次都遍历 schema:校验处于每次 INSERT/UPDATE 的热路径上,
* 引擎在一次批量写入里会对成百上千行调用它。预先把列定义拆成列表 + 集合,
* 既避免重复的 `Object.entries`,也让"哪些键允许出现"成为可哈希的集合判断。
* **不缓存**编译结果:schema 可被 `alterTable` 原地修改,长期缓存会用到过期列定义。
*/
export function compileValidator(schema: TableSchema): RowValidator {
const table = schema.name;
const entries: Array<[string, ColumnDef]> = Object.entries(schema.columns);
const columnNames = new Set(entries.map(([name]) => name));
function assertNoUnknownColumns(
row: Record<string, unknown>,
knownColumns?: readonly string[],
): void {
const allowed = knownColumns ? new Set(knownColumns) : columnNames;
// 先收集未知列再报错:一次列出全部,避免用户"改一个报一个"
const unknown: string[] = [];
for (const key of Object.keys(row)) {
if (key === '__proto__') continue; // 由 sanitize 阶段统一拒绝(消息不同)
if (!allowed.has(key)) unknown.push(key);
}
if (unknown.length > 0) {
throw new DatabaseError(
`Unknown column${unknown.length > 1 ? 's' : ''} ${unknown
.map((c) => `"${c}"`)
.join(', ')} in table "${table}". Known columns: ${[...allowed].join(', ')}`,
'COLUMN_NOT_FOUND',
);
}
}
function validateRow(
row: Record<string, unknown>,
knownColumns?: readonly string[],
): Record<string, unknown> {
assertNoUnknownColumns(row, knownColumns);
const validated: Record<string, unknown> = {};
for (const [colName, colDef] of entries) {
// INSERT 语义:缺列时 default 生效
let value = row[colName];
if (value === undefined && colDef.default !== undefined) value = colDef.default;
assertNotNullConstraints(table, colName, colDef, value);
if (value !== undefined && value !== null) {
checkFieldType(table, colName, colDef.type, value, colDef);
}
if (value !== undefined) validated[colName] = value;
}
return validated;
}
function validatePartial(row: Record<string, unknown>): {
values: Record<string, unknown>;
unknown: string[];
} {
const values: Record<string, unknown> = {};
const unknown: string[] = [];
for (const [colName, value] of Object.entries(row)) {
if (colName === '__proto__') {
throw new DatabaseError('Column name "__proto__" is not allowed', 'VALIDATION_ERROR');
}
const colDef = schema.columns[colName];
if (!colDef) {
unknown.push(colName);
continue;
}
// UPDATE 语义:`undefined` 已由 stripUndefinedUpdates 过滤;
// 这里再挡一次,保证"未提供的列"绝不会被判 required 失败。
if (value === undefined) continue;
assertNotNullConstraints(table, colName, colDef, value);
if (value !== null) {
checkFieldType(table, colName, colDef.type, value, colDef);
}
values[colName] = value;
}
return { values, unknown };
}
return { table, columns: columnNames, validateRow, validatePartial };
}
// ---------------------------------------------------------------------------
// 共享约束
// ---------------------------------------------------------------------------
/**
* NOT NULL 类约束。
*
* 两条规则合并在一个函数里按顺序判断,是为了让错误消息稳定:
* - `required` → "is required"(用户声明的业务约束);
* - `primaryKey` → 隐含 NOT NULLSQL 语义)。
* 在此之前两者分散在两个引擎的 `validateRow` 里各写一遍,消息略有差异
* (一个有表名后缀一个没有),依赖消息文本的测试只能各测各的引擎。
*/
function assertNotNullConstraints(
table: string,
colName: string,
colDef: ColumnDef,
value: unknown,
): void {
if (colDef.required && (value === undefined || value === null)) {
throw new DatabaseError(
`Column "${colName}" is required in table "${table}"`,
'VALIDATION_ERROR',
);
}
// v0.7.4:主键列强制非空 —— 此前 null/undefined 主键被 String() 化为
// "null"/"undefined" 静默入库,行再也无法按主键取回。
if (colDef.primaryKey && (value === undefined || value === null)) {
throw new DatabaseError(
`Primary key column "${colName}" in table "${table}" cannot be null or undefined`,
'VALIDATION_ERROR',
);
}
}
// ---------------------------------------------------------------------------
// 引擎接口适配
// ---------------------------------------------------------------------------
/**
* 收集行里所有未在 schema 中定义的键(不抛错版本)。
*
* 供引擎内部路径使用:级联更新、合并行等场景会带着派生列过来,
* 这里只返回名单,由调用方决定丢弃还是报错。
*/
export function collectUnknownColumns(
schema: TableSchema,
row: Record<string, unknown>,
): string[] {
const unknown: string[] = [];
for (const key of Object.keys(row)) {
if (key === '__proto__') continue;
if (!(key in schema.columns)) unknown.push(key);
}
return unknown;
}
/** 类型再导出,避免调用方从 constants 与 schema 两处 import */
export type { ColumnDef, FieldType };
+46 -15
View File
@@ -33,6 +33,27 @@ function makeRows(count: number): Record<string, unknown>[] {
return rows;
}
/**
* 按 schema 列裁剪行(v0.8.0 B-1 连带修正)。
*
* 此前 `makeRows` 无条件返回 `{id, name, age}`,而部分用例的表只有 `{id, name}` ——
* 多余列被引擎**静默丢弃**,测试因此"通过"。B-1 把未知列变成
* COLUMN_NOT_FOUND 后这些看起来无关的用例暴露出来。
*
* 这里的修法是让夹具**忠实反映表结构**(而不是放宽校验):测试本就不该依赖
* "写了不存在的列也不报错"这一行为。
*/
function rowsFor(schema: ReturnType<typeof createSchema>, count: number): Record<string, unknown>[] {
const allowed = Object.keys(schema.columns);
return makeRows(count).map((row) => {
const picked: Record<string, unknown> = {};
for (const key of allowed) {
if (key in row) picked[key] = row[key];
}
return picked;
});
}
describe('AriaEngine SSTable 缓存内存上限', () => {
test('缓存大小受 cacheLimitBytes 约束', async () => {
const engine = createSmallCacheEngine(2); // 2 * 4096 = 8KB 上限
@@ -82,11 +103,13 @@ describe('AriaEngine SSTable 缓存内存上限', () => {
test('超大 SSTable 常驻缓存(驱逐会导致读取静默跳过整个文件)', async () => {
const engine = createSmallCacheEngine(1); // 4KB 上限,单个 SSTable 必然超过
await engine.open('cache-oversized-pin', 1);
await engine.createTable(createSchema('users', {
const schema = createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
}));
await engine.insert('users', makeRows(300));
});
await engine.createTable(schema);
// v0.8.0B-1):夹具按 schema 裁剪(此前 makeRows 多带的 age 列被静默丢弃)
await engine.insert('users', rowsFor(schema, 300));
const lsm = (engine as any).lsm as {
flush(): Promise<void>;
getCacheSize(): number;
@@ -109,12 +132,14 @@ describe('AriaEngine SSTable 缓存内存上限', () => {
test('缓存驱逐后全表扫描仍返回完整数据(prefetch 兜底)', async () => {
const engine = createSmallCacheEngine(1); // 4KB 上限,必然触发驱逐
await engine.open('cache-evict-fullscan', 1);
await engine.createTable(createSchema('users', {
const schema = createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
}));
});
await engine.createTable(schema);
const rows = makeRows(300);
// v0.8.0B-1):夹具按 schema 裁剪(此前 makeRows 多带的 age 列被静默丢弃)
const rows = rowsFor(schema, 300);
await engine.insert('users', rows);
// v0.8.0: 这是最关键的数据完整性断言 —— 缓存上限极小(4KB)而 SSTable 更大时,
@@ -128,12 +153,14 @@ describe('AriaEngine SSTable 缓存内存上限', () => {
test('缓存驱逐后 PK 等值查询仍正确(prefetchKeys 兜底)', async () => {
const engine = createSmallCacheEngine(1);
await engine.open('cache-evict-pk', 1);
await engine.createTable(createSchema('users', {
const schema = createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
}));
});
await engine.createTable(schema);
const rows = makeRows(300);
// v0.8.0B-1):夹具按 schema 裁剪(此前 makeRows 多带的 age 列被静默丢弃)
const rows = rowsFor(schema, 300);
await engine.insert('users', rows);
// 分散查询多个 PK,每次都会经历 驱逐+重新加载
@@ -203,14 +230,16 @@ describe('AriaEngine SSTable 缓存内存上限', () => {
test('写入路径不突破缓存上限(flush 后立即裁剪)', async () => {
const engine = createSmallCacheEngine(2);
await engine.open('cache-write-bound', 1);
await engine.createTable(createSchema('users', {
const schema = createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
}));
});
await engine.createTable(schema);
// 分批写入,每批都触发多次 flush
for (let batch = 0; batch < 10; batch++) {
await engine.insert('users', makeRows(30).map((r, i) => ({ ...r, id: `b${batch}_u${i}` })));
// v0.8.0B-1):夹具按 schema 裁剪(此前 makeRows 的 age 列被静默丢弃)
await engine.insert('users', rowsFor(schema, 30).map((r, i) => ({ ...r, id: `b${batch}_u${i}` })));
const lsm = (engine as any).lsm;
expect(lsm.getCacheSize()).toBeLessThanOrEqual(lsm.getCacheLimit());
}
@@ -278,12 +307,14 @@ describe('AriaEngine SSTable 缓存内存上限', () => {
test('回归:删除后 tombstone 跨 flush 仍生效(不残留旧数据)', async () => {
const engine = createSmallCacheEngine(4);
await engine.open('regression-tombstone', 1);
await engine.createTable(createSchema('users', {
const schema = createSchema('users', {
id: { type: 'string', primaryKey: true },
age: { type: 'number', index: true },
}));
});
await engine.createTable(schema);
await engine.insert('users', makeRows(120));
// v0.8.0B-1):夹具按 schema 裁剪(此前 makeRows 的 name 列被静默丢弃)
await engine.insert('users', rowsFor(schema, 120));
// 分批删除,触发多次 flush
for (let batch = 0; batch < 4; batch++) {
+11 -2
View File
@@ -447,10 +447,19 @@ describe('v0.7.3: KVStore insert 持久化 validated 行', () => {
await db2.close();
});
test('schema 外列不持久化', async () => {
test('schema 外列在写路径即被拒绝(v0.8.0 B-1 由静默丢弃改为报错)', async () => {
const db = await MetonaSqlark.create({ name: 'v073-kv-extra-col', mode: 'disk', diskEngine: 'memory' });
await db.defineTable('users', { id: { type: 'string', primaryKey: true } });
await db.table('users').insert({ id: '1', junk: 'x' } as never);
// v0.8.0B-1 / 缺陷 A17):此断言此前写作"junk 被静默丢弃、插入成功"。
// 静默丢弃是错的:INSERT 报成功、`SELECT junk` 又报 COLUMN_NOT_FOUND ——
// 同一列名在写/读路径得到相反结论,用户以为写进去了。
// 现在写路径直接抛 COLUMN_NOT_FOUND(与读路径同一错误码),
// 因此"不持久化"这一目的以更强的形式成立(连内存表都不会有脏列)。
await expect(db.table('users').insert({ id: '1', junk: 'x' } as never)).rejects.toMatchObject({
code: 'COLUMN_NOT_FOUND',
});
// 合法的行仍可写入,且落盘后不含任何额外列
await db.table('users').insert({ id: '1' } as never);
await db.close();
const db2 = await MetonaSqlark.create({ name: 'v073-kv-extra-col', mode: 'disk', diskEngine: 'memory' });
const rows = rowsOf<Record<string, unknown>>(await db2.query('SELECT * FROM users'));
+229
View File
@@ -0,0 +1,229 @@
/**
* v0.8.0 B-1 B 1
* ============================================================================
* PLAN-v0.7.5.md 1 + A12/A17
* **** Memory/KVStore/Hybrid
* "只查类型"**** maxLength/min/maxAria 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');
}
});
});