两个此前互相纠缠的缺陷(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 已重建。
144 lines
5.2 KiB
TypeScript
144 lines
5.2 KiB
TypeScript
/**
|
||
* 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.0(B-1):`checkFieldType` 的实现已迁到 `validation.ts`(唯一校验定义),
|
||
// 这里重新导出以保持公开 API 不变。
|
||
//
|
||
// 为什么必须搬走:'schema.ts ↔ validation.ts' 之前是**循环依赖**
|
||
//(schema 需要 compileValidator;validation 需要 checkFieldType),
|
||
// rollup 构建时明确告警 'Circular dependency'。循环依赖在 ESM 下的
|
||
// 求值顺序不稳定(谁先被 import 谁就先初始化),是难查的运行时陷阱;
|
||
// 依赖方向必须单向:validation(约束实现)← schema(DDL 工具)。
|
||
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.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<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,
|
||
};
|
||
}
|