diff --git a/src/engine/aria/index.ts b/src/engine/aria/index.ts index c1fae6c..dfd3977 100644 --- a/src/engine/aria/index.ts +++ b/src/engine/aria/index.ts @@ -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): Record { - const validated: Record = {}; - 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.0(B-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[], + mode: 'insert' | 'update' = 'insert', + ): Promise { + 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); + } } // ======================================================================= diff --git a/src/engine/interface.ts b/src/engine/interface.ts index fccd967..3d5175f 100644 --- a/src/engine/interface.ts +++ b/src/engine/interface.ts @@ -93,6 +93,32 @@ export interface IStorageEngine { /** 插入行,返回主键值列表 */ insert(tableName: string, rows: Record[]): Promise; + /** + * 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[], + mode?: 'insert' | 'update', + ): Promise; + /** 查询行 */ find(tableName: string, query: QueryPlan): Promise[]>; diff --git a/src/engine/kvstore_engine.ts b/src/engine/kvstore_engine.ts index c8090a1..169006d 100644 --- a/src/engine/kvstore_engine.ts +++ b/src/engine/kvstore_engine.ts @@ -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[], + mode: 'insert' | 'update' = 'insert', + ): Promise { + this.ensureOpen(); + return this.memory.validatePayload(tableName, rows, mode); + } + async insert(tableName: string, rows: Record[]): Promise { this.ensureOpen(); const pks = await this.memory.insert(tableName, rows); diff --git a/src/engine/memory.ts b/src/engine/memory.ts index 427c5b5..0c42aed 100644 --- a/src/engine/memory.ts +++ b/src/engine/memory.ts @@ -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): Record { - const validated: Record = {}; - 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.0(B-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.0(B-1):写入前置校验(见 `IStorageEngine.validatePayload` 契约)。 + * + * 引擎在 `insert` / `update` 内部**同样**会校验 —— 本方法只是让 Executor 与 + * QueryBuilder 能在"开始写入之前"拿到同一套判定结果,从而: + * - 多行 INSERT 的预检发生在任何副作用之前(错误信息带列名清单); + * - 直通路径与 SQL 路径不可能给出不同结论(同一个 `compileValidator`)。 + */ + async validatePayload( + tableName: string, + rows: Record[], + mode: 'insert' | 'update' = 'insert', + ): Promise { + 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); } } diff --git a/src/hybrid/index.ts b/src/hybrid/index.ts index b81907f..4603b04 100644 --- a/src/hybrid/index.ts +++ b/src/hybrid/index.ts @@ -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[], + mode: 'insert' | 'update' = 'insert', + ): Promise { + return this.memoryEngine.validatePayload(tableName, rows, mode); + } + async insert(tableName: string, rows: Record[]): Promise { const pks = await this.memoryEngine.insert(tableName, rows); // write-through: 同步写入磁盘 diff --git a/src/query/executor.ts b/src/query/executor.ts index 20fae56..34c9b49 100644 --- a/src/query/executor.ts +++ b/src/query/executor.ts @@ -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); } diff --git a/src/table/schema.ts b/src/table/schema.ts index 7cadae0..fcce805 100644 --- a/src/table/schema.ts +++ b/src/table/schema.ts @@ -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): Record< return clean; } -/** 校验行数据 */ +/** + * 校验行数据(INSERT 语义:`default` 生效、缺列合法)。 + * + * v0.8.0(B-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): Record { - const validated: Record = {}; - - 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); } /** 检查字段类型(含约束校验) */ diff --git a/src/table/validation.ts b/src/table/validation.ts new file mode 100644 index 0000000..f498f35 --- /dev/null +++ b/src/table/validation.ts @@ -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; + + /** + * INSERT 语义校验 + 规范化。 + * + * @param row 待写入的行(不会被修改) + * @param knownColumns 调用方已知/允许的列名(如 INSERT 显式列清单)。 + * 省略时以 schema 列为准。**超出该集合的键即未知列 → 抛错。** + * @returns 规范化后的新行(仅含 schema 列,`undefined` 不落键,`default` 已填充) + * @throws DatabaseError COLUMN_NOT_FOUND | VALIDATION_ERROR | TYPE_ERROR + */ + validateRow(row: Record, knownColumns?: readonly string[]): Record; + + /** + * UPDATE 语义校验 + 规范化:仅校验出现的列。 + * + * @returns `{ values, unknown }` —— `values` 是规范化后的更新载荷, + * `unknown` 是 schema 里不存在的键(由调用方决定是否报错, + * 因为引擎内部级联等路径可能携带合并行的派生列)。 + */ + validatePartial(row: Record): { + values: Record; + 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, + 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, + knownColumns?: readonly string[], + ): Record { + assertNoUnknownColumns(row, knownColumns); + + const validated: Record = {}; + 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): { + values: Record; + unknown: string[]; + } { + const values: Record = {}; + 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 NULL(SQL 语义)。 + * 在此之前两者分散在两个引擎的 `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[] { + 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 }; diff --git a/tests/engine/aria-cache.test.ts b/tests/engine/aria-cache.test.ts index 1e8b2c5..5191763 100644 --- a/tests/engine/aria-cache.test.ts +++ b/tests/engine/aria-cache.test.ts @@ -33,6 +33,27 @@ function makeRows(count: number): Record[] { return rows; } +/** + * 按 schema 列裁剪行(v0.8.0 B-1 连带修正)。 + * + * 此前 `makeRows` 无条件返回 `{id, name, age}`,而部分用例的表只有 `{id, name}` —— + * 多余列被引擎**静默丢弃**,测试因此"通过"。B-1 把未知列变成 + * COLUMN_NOT_FOUND 后这些看起来无关的用例暴露出来。 + * + * 这里的修法是让夹具**忠实反映表结构**(而不是放宽校验):测试本就不该依赖 + * "写了不存在的列也不报错"这一行为。 + */ +function rowsFor(schema: ReturnType, count: number): Record[] { + const allowed = Object.keys(schema.columns); + return makeRows(count).map((row) => { + const picked: Record = {}; + 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.0(B-1):夹具按 schema 裁剪(此前 makeRows 多带的 age 列被静默丢弃) + await engine.insert('users', rowsFor(schema, 300)); const lsm = (engine as any).lsm as { flush(): Promise; 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.0(B-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.0(B-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.0(B-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.0(B-1):夹具按 schema 裁剪(此前 makeRows 的 name 列被静默丢弃) + await engine.insert('users', rowsFor(schema, 120)); // 分批删除,触发多次 flush for (let batch = 0; batch < 4; batch++) { diff --git a/tests/v073-fixes.test.ts b/tests/v073-fixes.test.ts index 11ea312..eb3a01b 100644 --- a/tests/v073-fixes.test.ts +++ b/tests/v073-fixes.test.ts @@ -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.0(B-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>(await db2.query('SELECT * FROM users')); diff --git a/tests/v080-unified-validation.test.ts b/tests/v080-unified-validation.test.ts new file mode 100644 index 0000000..514def6 --- /dev/null +++ b/tests/v080-unified-validation.test.ts @@ -0,0 +1,229 @@ +/** + * 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]> = [ + ['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、跳过 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'); + } + }); +});