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 已重建。
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 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<5;id=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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -44,10 +44,20 @@ describe('[v0.8.0] A10 列对列比较', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('裸 x = y 仍为显式 PARSE_ERROR(语法限制,不静默)', async () => {
|
||||
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 expect(db.query('SELECT id FROM t WHERE x = y')).rejects.toMatchObject({ code: 'PARSE_ERROR' });
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user