Files
MetonaSqlark/tests/v080-correlated.test.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

100 lines
4.9 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.
/**
* v0.8.0 回归 —— A10 列对列比较与关联子查询
*
* 两个此前**静默返回空结果**的缺陷(行数正确、内容全空、无任何报错):
*
* 1. `WHERE t.x = t.y`(唯一可解析的列对列写法,解析为 `{ 't.x': { $eq: { $col: 't.y' } } }`
* 执行路径是:引擎层先按原始 where 取候选行(引擎的 matchWhere **没有** $col 上下文)
* → executor.filterCorrelated 带 `{ $col: true }` 做最终判定。
* 结果两处都错了:
* - 引擎层把 `{ $col: ... }` 当普通对象比较 → 所有行都不匹配;
* - executor 侧调用 matchWhere 时又**没传** `{ $col: true }`。
* 实测返回 `[]`(应返回 x == y 的行)。
*
* 2. `WHERE id IN (SELECT ... WHERE o.user_id = u.id)`(关联 IN 子查询)
* 子查询执行时**不传外层行上下文**,`u.id` 绑定为 null,子查询返回空集 →
* `$in: []` → 静默空结果。而结构相同的 EXISTS 走另一条分支、结果是正确的
* —— 又一处"同一语义两条路径"。
*
* 修复后两者与等价写法结果一致(见下方断言)。
*/
import { MetonaSqlark } from '../src/core';
const MODES: Array<[string, Record<string, unknown>]> = [
['memory', {}],
['disk', {}],
['hybrid', {}],
['aria', { diskEngine: 'memory' }],
];
describe('[v0.8.0] A10 列对列比较', () => {
test('WHERE t.x = t.y 返回相等行(四引擎)', async () => {
for (const [mode, extra] of MODES) {
const db = await MetonaSqlark.create({ name: `v080-a10-${mode}`, mode, ...extra } as never);
await db.defineTable('t', {
id: { type: 'string', primaryKey: true },
x: { type: 'number' },
y: { type: 'number' },
});
await db.query("INSERT INTO t VALUES ('1',1,5),('2',2,3),('3',7,7),('4',7,7)");
const rows = await db.query('SELECT id FROM t WHERE t.x = t.y') as Array<{ id: string }>;
expect(rows.map((r) => r.id).sort()).toEqual(['3', '4']);
await db.close();
}
});
test('裸 x = y 与限定 t.x = t.y 结果一致(v0.8.0 A37 解除语法限制)', async () => {
const db = await MetonaSqlark.create({ name: 'v080-a10-bare', mode: 'memory' });
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, x: { type: 'number' }, y: { type: 'number' } });
await db.query("INSERT INTO t VALUES ('1',1,5),('2',2,2),('3',7,7)");
// 此前 `x = y` 抛 PARSE_ERRORparser 只把 `a.b` 形态当列引用),
// 于是列对列比较必须写成限定形态。A37 之后两种写法等价 —— 本断言锁定这一点。
const bare = await db.query('SELECT id FROM t WHERE x = y') as Array<{ id: string }>;
const qualified = await db.query('SELECT id FROM t WHERE t.x = t.y') as Array<{ id: string }>;
expect(bare.map((r) => r.id).sort()).toEqual(['2', '3']);
expect(bare).toEqual(qualified);
// 拼错的列名现在是 COLUMN_NOT_FOUND(而不是静默空集)
await expect(db.query('SELECT id FROM t WHERE x = nope')).rejects.toMatchObject({
code: 'COLUMN_NOT_FOUND',
});
await db.close();
});
});
describe('[v0.8.0] A10 关联 IN 子查询', () => {
test('关联 IN 与等价 EXISTS 结果一致(四引擎)', async () => {
for (const [mode, extra] of MODES) {
const db = await MetonaSqlark.create({ name: `v080-a10b-${mode}`, mode, ...extra } as never);
await db.defineTable('u', { id: { type: 'string', primaryKey: true } });
await db.defineTable('o', { id: { type: 'string', primaryKey: true }, user_id: { type: 'string' } });
await db.query("INSERT INTO u VALUES ('1'),('2'),('3')");
await db.query("INSERT INTO o VALUES ('o1','1'),('o2','2')");
const viaIn = await db.query(
'SELECT id FROM u WHERE id IN (SELECT user_id FROM o WHERE o.user_id = u.id)',
) as Array<{ id: string }>;
const viaExists = await db.query(
'SELECT id FROM u WHERE EXISTS (SELECT 1 FROM o WHERE o.user_id = u.id)',
) as Array<{ id: string }>;
expect(viaIn.map((r) => r.id).sort()).toEqual(['1', '2']);
// 关键护栏:两条语义等价的路径必须给出一致结果
expect(viaIn.map((r) => r.id).sort()).toEqual(viaExists.map((r) => r.id).sort());
await db.close();
}
});
test('非关联 IN 子查询不受影响(回归护栏)', async () => {
const db = await MetonaSqlark.create({ name: 'v080-a10c', mode: 'memory' });
await db.defineTable('u', { id: { type: 'string', primaryKey: true } });
await db.defineTable('o', { id: { type: 'string', primaryKey: true }, user_id: { type: 'string' } });
await db.query("INSERT INTO u VALUES ('1'),('2'),('3')");
await db.query("INSERT INTO o VALUES ('o1','1'),('o2','2')");
const rows = await db.query('SELECT id FROM u WHERE id IN (SELECT user_id FROM o)') as Array<{ id: string }>;
expect(rows.map((r) => r.id).sort()).toEqual(['1', '2']);
await db.close();
});
});