Files
MetonaSqlark/src/engine/interface.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

192 lines
7.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.
/**
* metona-sqlark Engine Interface — 存储引擎抽象接口
* @module engine/interface
*/
import type { QueryPlan, TableSchema } from '../constants';
// ---------------------------------------------------------------------------
// v0.8.0: 行所有权(row ownership)约定
// ---------------------------------------------------------------------------
/**
* 深拷贝一行,使调用方**无法通过修改返回值改写存储**。
*
* 为什么必须做(审计实测):Memory/KVStore/Hybrid 三个引擎此前把内部行对象
* **直接**交给调用方:
* const rows = await db.query('SELECT * FROM t');
* rows[0].tag = 'HACKED'; // 存储被改写
* 再查 WHERE tag='HACKED' → 0 行;WHERE tag='x' → 0 行
* 即调用方一次无意的原地修改就能让索引与行失配、该行永久查不出来(Aria 因为是
* 反序列化路径反而幸免,于是又成了跨引擎行为差异)。
*
* 约定(写入 interface 文档,所有引擎必须遵守):
* **读出的行是副本,写入接收的行也是副本** —— 引擎不得把内部行对象暴露给外部,
* 也不得持有调用方传入的行对象引用。
*
* 实现说明:结构化克隆可用时优先使用(正确处理 Date/嵌套对象/循环引用);
* 存储层写入的行已经过 validateRow 的 JSON 安全性检查,因此退化路径也是安全的。
*/
export function cloneRow<T extends Record<string, unknown>>(row: T): T {
if (row === null || typeof row !== 'object') return row;
if (typeof structuredClone === 'function') {
try {
return structuredClone(row);
} catch {
// 含不可克隆值(函数/Proxy)时退化为逐层复制
}
}
return cloneRowFallback(row) as T;
}
/** 退化实现:递归复制普通对象与数组(保留 Date) */
function cloneRowFallback(value: unknown): unknown {
if (value === null || typeof value !== 'object') return value;
if (value instanceof Date) return new Date(value.getTime());
if (Array.isArray(value)) return value.map((v) => cloneRowFallback(v));
if (value instanceof Uint8Array) return new Uint8Array(value);
if (value instanceof ArrayBuffer) return value.slice(0);
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = cloneRowFallback(v);
}
return out;
}
/** 批量深拷贝 */
export function cloneRows<T extends Record<string, unknown>>(rows: T[]): T[] {
return rows.map((r) => cloneRow(r));
}
// ---------------------------------------------------------------------------
// IStorageEngine — 所有存储引擎必须实现的接口
// ---------------------------------------------------------------------------
export interface IStorageEngine {
/** 引擎名称 */
readonly name: string;
/** 打开数据库 */
open(dbName: string, version: number): Promise<void>;
/** 关闭数据库 */
close(): Promise<void>;
/** 检查数据库是否已打开 */
isOpen(): boolean;
/** 创建表 */
createTable(schema: TableSchema): Promise<void>;
/** 删除表 */
dropTable(tableName: string): Promise<void>;
/** 检查表是否存在 */
hasTable(tableName: string): Promise<boolean>;
/** 获取所有表名 */
getTableNames(): Promise<string[]>;
/** 获取表结构 */
getTableSchema(tableName: string): Promise<TableSchema | null>;
/** 插入行,返回主键值列表 */
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>[]>;
/** v0.4.0: 流式查询 — 逐行回调扫描(有 where/limit/projection,无 orderBy 语义;有 orderBy 时实现可回退物化) */
findStream?(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number>;
/** 更新行,返回影响行数 */
update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number>;
/** 删除行,返回影响行数 */
delete(tableName: string, query: QueryPlan): Promise<number>;
/** 计数 */
count(tableName: string, query?: QueryPlan): Promise<number>;
/** 清空表数据(保留结构) */
clear(tableName: string): Promise<void>;
/** v0.4.1: ALTER TABLE(可选)— 引擎级结构变更(Aria 需重写存储行,其余引擎走 Executor 通用路径) */
alterTable?(tableName: string, action: 'ADD' | 'DROP', column: import('../constants').ColumnDef & { name: string }): Promise<void>;
// ---- 动态索引(可选,v0.3.0 ----
/** 创建二级索引(CREATE INDEX */
createIndex?(tableName: string, column: string, unique?: boolean): Promise<void>;
/** 删除二级索引(DROP INDEX */
dropIndex?(tableName: string, column: string, indexName?: string): Promise<void>;
// ---- 事务 ----
/** 开始事务 */
beginTransaction(): Promise<void>;
/** 提交事务 */
commitTransaction(): Promise<void>;
/** 回滚事务 */
rollbackTransaction(): Promise<void>;
// ---- Savepoint (可选) ----
/** 创建 Savepoint */
savepoint?(name: string): Promise<void>;
/** 回滚到 Savepoint */
rollbackToSavepoint?(name: string): Promise<void>;
/** 释放 Savepoint */
releaseSavepoint?(name: string): Promise<void>;
// ---- 备份 (可选) ----
/** 在线备份:导出全库一致性快照 */
backup?(): Promise<Record<string, Record<string, unknown>[]>>;
// ---- 自愈/重置 (可选,v0.4.2-fix) ----
/** 崩溃恢复自愈:校验并清理损坏数据、恢复一致性(检测到异常后调用,无需删库重建) */
repair?(): Promise<void>;
/** 清空全部数据与表结构(保留库本身,供演示页刷新/重建用) */
clearAll?(): Promise<void>;
/** 读取库内元数据(迁移版本持久化用) */
getMeta?(key: string): Promise<string | null>;
/** 写入库内元数据(迁移版本持久化用) */
setMeta?(key: string, value: string): Promise<void>;
}