Files
MetonaSqlark/tests/v080-column-resolution.test.ts
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

170 lines
8.2 KiB
TypeScript
Raw Permalink 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 回归套件 —— A37 列引用:未限定操作数 + 存在性校验
* ============================================================================
* 两个此前互相纠缠的缺陷:
*
* ① **未限定列不能作比较操作数**(语法层)
* 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`)。
*
* ② **`$col` 引用到不存在的列 → 静默空集**(语义层)
* 投影侧早有"列必须存在"的校验,WHERE 侧一直**没有**`$col` 取不到值时
* 三值求值器只能返回 UNKNOWN,而 UNKNOWN 在 WHERE 里表现为"不保留该行"。
* 实测修复前:
* SELECT id FROM t WHERE id = oops → []oops 是列引用)
* SELECT id FROM t WHERE x = nope → []
* SELECT ... FROM l JOIN r ON l.k = r.nope → [](连接不上任何行)
* 用户看到"没有数据",与"列名拼错"完全无法区分 —— 这正是 PLAN §3 根因 7
* (未解析引用静默变 false)的最后一处。
*
* 契约变更:拼错的列名现在报 `COLUMN_NOT_FOUND`(而不是 PARSE_ERROR)——
* 问题不是语法错,而是"没有这一列"。
*/
import { MetonaSqlark } from '../src/core';
import { rows as rowsOf } from './helpers/assertions';
import type { DatabaseConfig } from '../src/constants';
const ENGINES: Array<[string, DatabaseConfig['mode'], Partial<DatabaseConfig>]> = [
['memory', 'memory', {}],
['disk', 'disk', {}],
['hybrid', 'hybrid', {}],
['aria', 'aria', { diskEngine: 'memory' }],
];
describe('[v0.8.0] A37 未限定列作操作数', () => {
describe.each(ENGINES)('%s 引擎', (label, mode, extra) => {
let db: MetonaSqlark;
beforeEach(async () => {
db = await MetonaSqlark.create({
name: `a37-${label}-${Math.random().toString(36).slice(2)}`,
mode,
...extra,
});
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),('4',NULL,9)");
});
afterEach(async () => {
await db.close();
});
it('裸列比较与限定形态完全等价', async () => {
// 数据:id=1 (x=1,y=5) / id=2 (2,2) / id=3 (7,7) / id=4 (x=NULL,y=9)
// 期望值**逐个实测得出**(不是推导出来的):`x > y` 为空集,
// 因为唯一满足的 id=1 是 1<5id=4 的 x 是 NULL → 所有比较都是 UNKNOWN。
for (const [op, expected] of [
['=', ['2', '3']],
['!=', ['1']],
['<', ['1']],
['>', []],
['>=', ['2', '3']],
['<=', ['1', '2', '3']],
] as Array<[string, string[]]>) {
const bare = rowsOf<{ id: string }>(await db.query(`SELECT id FROM t WHERE x ${op} y ORDER BY id`));
const qualified = rowsOf<{ id: string }>(
await db.query(`SELECT id FROM t WHERE t.x ${op} t.y ORDER BY id`),
);
expect(bare.map((r) => r.id)).toEqual(expected);
expect(bare).toEqual(qualified);
}
});
it('NULL 参与列比较时按三值逻辑处理(UNKNOWN 不保留)', async () => {
// id=4 的 x 是 NULL → `x = y` 为 UNKNOWN → 不保留(上面 '=' 的期望里没有 4
const rows = rowsOf<{ id: string }>(await db.query('SELECT id FROM t WHERE x = y ORDER BY id'));
expect(rows.map((r) => r.id)).not.toContain('4');
// 反向:IS NULL 能命中
const nullRows = rowsOf<{ id: string }>(await db.query('SELECT id FROM t WHERE x IS NULL'));
expect(nullRows.map((r) => r.id)).toEqual(['4']);
});
it('字面量操作数不受影响(NUMBER / STRING / TRUE / NULL 各有 token 类型)', async () => {
expect(rowsOf(await db.query('SELECT id FROM t WHERE x = 2'))).toEqual([{ id: '2' }]);
// x > 1 → id=2(2) 与 id=3(7)id=1 是 1,不满足
expect(rowsOf<{ id: string }>(await db.query('SELECT id FROM t WHERE x > 1 ORDER BY id')).map((r) => r.id))
.toEqual(['2', '3']);
expect(rowsOf(await db.query('SELECT id FROM t WHERE y = 5'))).toEqual([{ id: '1' }]);
// 字符串字面量
expect(rowsOf(await db.query("SELECT id FROM t WHERE id = '2'"))).toEqual([{ id: '2' }]);
});
it('裸布尔列条件仍然工作(WHERE x 不因取消语法限制而改变)', async () => {
await db.defineTable('b', {
id: { type: 'string', primaryKey: true },
flag: { type: 'boolean' },
});
await db.query("INSERT INTO b VALUES ('1', TRUE), ('2', FALSE), ('3', NULL)");
const rows = rowsOf<{ id: string }>(await db.query('SELECT id FROM b WHERE flag'));
expect(rows.map((r) => r.id)).toEqual(['1']);
});
// -------------------------------------------------------------------
// ② 存在性校验
// -------------------------------------------------------------------
it('WHERE 中拼错的列名 → COLUMN_NOT_FOUND(此前静默空集)', async () => {
await expect(db.query('SELECT id FROM t WHERE id = oops')).rejects.toMatchObject({
code: 'COLUMN_NOT_FOUND',
});
await expect(db.query('SELECT id FROM t WHERE id = t.oops')).rejects.toMatchObject({
code: 'COLUMN_NOT_FOUND',
});
await expect(db.query('SELECT id FROM t WHERE x = nope')).rejects.toMatchObject({
code: 'COLUMN_NOT_FOUND',
});
// 字段键侧(原有行为,回归护栏)
await expect(db.query('SELECT id FROM t WHERE nope = 1')).rejects.toMatchObject({
code: 'COLUMN_NOT_FOUND',
});
});
it('JOIN ON 中拼错的列名 → COLUMN_NOT_FOUND', async () => {
await db.defineTable('l', { id: { type: 'string', primaryKey: true }, k: { type: 'string' } });
await db.defineTable('r', { id: { type: 'string', primaryKey: true }, k: { type: 'string' } });
await db.query("INSERT INTO l VALUES ('l1','x')");
await db.query("INSERT INTO r VALUES ('r1','x')");
await expect(
db.query('SELECT l.id AS lid, r.id AS rid FROM l JOIN r ON l.k = r.nope'),
).rejects.toMatchObject({ code: 'COLUMN_NOT_FOUND' });
});
it('JOIN 里未限定且两表共有的列名 → 歧义报错(要求限定)', async () => {
await db.defineTable('l', { id: { type: 'string', primaryKey: true }, k: { type: 'string' } });
await db.defineTable('r', { id: { type: 'string', primaryKey: true }, k: { type: 'string' } });
await db.query("INSERT INTO l VALUES ('l1','x')");
await db.query("INSERT INTO r VALUES ('r1','x')");
// WHERE 里裸写共有列 → 歧义(SQL 标准要求限定)
await expect(
db.query("SELECT l.id FROM l JOIN r ON l.k = r.k WHERE k = 'x'"),
).rejects.toMatchObject({ code: 'COLUMN_NOT_FOUND' });
// 限定后正常。注意 JOIN 路径的行键带表别名前缀(既有行为,见
// executeJoinSelect 的行合并约定),因此输出键是 `l.id` 而非 `id`。
const rows = rowsOf<Record<string, unknown>>(
await db.query("SELECT l.id FROM l JOIN r ON l.k = r.k WHERE l.k = 'x'"),
);
expect(rows).toEqual([{ 'l.id': 'l1' }]);
});
it('JOIN ON 用未限定列仍按主表列解释(不因两表同名而拒绝)', async () => {
await db.defineTable('l', { id: { type: 'string', primaryKey: true }, k: { type: 'string' } });
await db.defineTable('r', { id: { type: 'string', primaryKey: true }, k: { type: 'string' } });
await db.query("INSERT INTO l VALUES ('l1','x'),('l2','y')");
await db.query("INSERT INTO r VALUES ('r1','x'),('r2','z')");
// `ON k = k` 是常见的简写写法:两侧都取主表列 → 恒真(仅受两表都有该列的约束),
// 关键是**不报错**(列确实存在),结果由既有 joinRows 语义决定
const rows = rowsOf<Record<string, unknown>>(
await db.query('SELECT l.id AS lid, r.id AS rid FROM l JOIN r ON k = k'),
);
expect(rows.length).toBeGreaterThanOrEqual(0);
});
});
});