两个此前互相纠缠的缺陷(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 已重建。
100 lines
4.9 KiB
TypeScript
100 lines
4.9 KiB
TypeScript
/**
|
||
* 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_ERROR(parser 只把 `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();
|
||
});
|
||
});
|