Files
MetonaSqlark/src/table/schema.ts
T
thzxx 567d150257 feat(A37): 未限定列可作比较操作数 + WHERE/JOIN ON 列引用存在性校验
两个此前互相纠缠的缺陷(PLAN §5 #35/#37、§3 根因 7 的最后一块)。

① 未限定列不能作比较操作数(语法层)
   parser 只把 `a.b` 形态当列引用,裸标识符一律走字面量解析,于是:
     WHERE x = y  → PARSE_ERROR: Expected value, got "y"
     ON k = k     → 同上
   列对列比较被迫写成 `WHERE t.x = t.y` —— 而多表 JOIN 里未限定列恰恰是最
   自然的写法(`ON user_id = id`)。
   修法:操作数位置上的 IDENTIFIER **必然是列引用**(字面量各有自己的 token
   类型:NUMBER/STRING/TRUE/FALSE/NULL),这一条不需要猜测。

   * 排查记录:这里试错了两次。起初用 peekToken 判"下一个是否运算符",
     实测仍报错 —— 因为进入该分支时运算符**已被 parseComparisonOp 消费**,
     `peek` 是 EOF/AND 而非运算符。最终按"位置"判定,不再依赖 lookahead。

② `$col` 引用到不存在的列 → 静默空集(语义层)
   投影侧早有列存在性校验,WHERE 侧一直没有:`$col` 取不到值 → UNRESOLVED →
   比较判 UNKNOWN → **所有行被过滤且不报错**。实测修复前:
     SELECT id FROM t WHERE id = oops          → []
     SELECT id FROM t WHERE id = t.oops        → []
     SELECT id FROM t WHERE x = nope           → []
     SELECT ... FROM l JOIN r ON l.k = r.nope  → [](连接不上任何行)
   用户看到的是"没有数据",与"列名拼错"完全无法区分。
   修法:新增 `validateWhereColumns`(WHERE 的键位 + `$col` 值位,含
   `$and`/`$or`/`$not` 内部)与 `validateJoinOnColumns`(ON 两侧归属判定),
   两者共用同一遍历实现。
   契约变更:
     - 拼错的列名 → `COLUMN_NOT_FOUND`(此前 PARSE_ERROR 或静默空集);
     - JOIN 的 WHERE 里裸写两表同名列 → 歧义报错(SQL 标准要求限定);
     - `ON k = k` 这类裸写法仍按"取主表列"解释(不因两表同名而拒绝,
       否则会把常见等值连接写法判为错误)。

附带修正:`schema.ts ↔ validation.ts` 的**循环依赖**(rollup 构建告警
"Circular dependency")。`checkFieldType` 的实现搬到 validation.ts(唯一校验
定义),schema.ts 重新导出以保持公开 API —— 依赖方向改为单向
(validation ← schema)。循环依赖在 ESM 下求值顺序不稳定,是难查的运行时陷阱。

验证:新增 tests/v080-column-resolution.test.ts(4 引擎 × 8 项,共 32 断言,
期望值全部逐个实测得出);tests/v080-correlated.test.ts 的"裸 x = y 为
PARSE_ERROR"用例改为断言两种写法等价。
全量 86 套件 / 1697 测试通过;e2e 14 项通过(真实 Chromium + OPFS);
typecheck(src+tests)、lint、build 零错误/零告警;dist 已重建。
2026-09-15 01:21:58 +08:00

144 lines
5.2 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 Schema — 表结构定义与校验
* @module table/schema
*/
import type { TableSchema, ColumnDef, FieldType } from '../constants';
import { FIELD_TYPES, DatabaseError } from '../constants';
import { compileValidator } from './validation';
// v0.8.0B-1):`checkFieldType` 的实现已迁到 `validation.ts`(唯一校验定义),
// 这里重新导出以保持公开 API 不变。
//
// 为什么必须搬走:'schema.ts ↔ validation.ts' 之前是**循环依赖**
//schema 需要 compileValidatorvalidation 需要 checkFieldType),
// rollup 构建时明确告警 'Circular dependency'。循环依赖在 ESM 下的
// 求值顺序不稳定(谁先被 import 谁就先初始化),是难查的运行时陷阱;
// 依赖方向必须单向:validation(约束实现)← schemaDDL 工具)。
export { checkFieldType } from './validation';
// ---------------------------------------------------------------------------
// Schema 工具
// ---------------------------------------------------------------------------
/** 从列定义创建 TableSchema */
export function createSchema(name: string, columns: Record<string, ColumnDef>): TableSchema {
validateColumns(columns);
return { name, columns };
}
/** 校验列定义 */
export function validateColumns(columns: Record<string, ColumnDef>): void {
const colNames = Object.keys(columns);
if (colNames.length === 0) {
throw new DatabaseError('Table must have at least one column', 'SCHEMA_ERROR');
}
let primaryKeyCount = 0;
for (const [colName, colDef] of Object.entries(columns)) {
// v0.7.1: '__proto__' 作为列名会触发对象原型 setter(列静默丢失);
// 显式拒绝避免原型污染类攻击面
if (colName === '__proto__') {
throw new DatabaseError('Column name "__proto__" is not allowed', 'SCHEMA_ERROR');
}
// 类型校验
if (!FIELD_TYPES.includes(colDef.type)) {
throw new DatabaseError(
`Invalid type "${colDef.type}" for column "${colName}". Valid types: ${FIELD_TYPES.join(', ')}`,
'SCHEMA_ERROR',
);
}
// 主键计数
if (colDef.primaryKey) {
primaryKeyCount++;
}
}
// 至少需要一个主键
if (primaryKeyCount === 0) {
throw new DatabaseError('Table must have at least one primary key column', 'SCHEMA_ERROR');
}
// v0.7.0: 复合主键(多列 primaryKey)当前不支持 —— 所有引擎的存储布局与
// 外键引用均为单主键假设(此前静默取第一个主键,其余标记被忽略 → 语义陷阱)。
// 显式拒绝避免用户误用;复合主键列入 v0.8 路线图。
if (primaryKeyCount > 1) {
throw new DatabaseError(
`Composite primary keys are not supported yet: table has ${primaryKeyCount} primary key columns. ` +
'Use a single primary key column (or a unique column combination) instead.',
'SCHEMA_ERROR',
);
}
}
/** 获取主键列名 */
export function getPrimaryKey(schema: TableSchema): string {
for (const [name, col] of Object.entries(schema.columns)) {
if (col.primaryKey) return name;
}
return Object.keys(schema.columns)[0];
}
/**
* v0.7.2: 更新载荷清洗 —— undefined 值视为"不更新该列"(保留旧值)。
* 此前 `update({ col: undefined })` 会把 undefined 写入行(覆盖旧值、列键丢失)。
* null 保留(显式置空语义)。
*/
export function stripUndefinedUpdates(updates: Record<string, unknown>): Record<string, unknown> {
const clean: Record<string, unknown> = {};
for (const [key, value] of Object.entries(updates)) {
if (value !== undefined) clean[key] = value;
}
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> {
return compileValidator(schema).validateRow(row);
}
/** 将 AST 列定义转换为 ColumnDef */
export function astColumnToColumnDef(astCol: {
name: string;
type: string;
primaryKey?: boolean;
unique?: boolean;
required?: boolean;
default?: unknown;
index?: boolean;
maxLength?: number;
min?: number;
max?: number;
references?: string;
onDelete?: 'CASCADE' | 'SET NULL' | 'RESTRICT';
onUpdate?: 'CASCADE' | 'SET NULL' | 'RESTRICT';
}): ColumnDef {
return {
type: astCol.type as FieldType,
primaryKey: astCol.primaryKey,
unique: astCol.unique,
required: astCol.required,
default: astCol.default,
index: astCol.index,
maxLength: astCol.maxLength,
min: astCol.min,
max: astCol.max,
references: astCol.references,
onDelete: astCol.onDelete,
onUpdate: astCol.onUpdate,
};
}