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:
+214
-1
@@ -683,6 +683,18 @@ export class QueryExecutor {
|
||||
const { hasGroupBy, hasAggregate, isJoinQuery, needsRawRows, orderByAlias, hasSelectAlias, limitPushdownSafe } = shape;
|
||||
let rows: Record<string, unknown>[];
|
||||
|
||||
// v0.8.0(A37):WHERE 里的列引用也必须校验存在性。
|
||||
//
|
||||
// 投影侧的同类校验(assertProjectionColumnsExist)早已存在,但 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 → []
|
||||
// 三条都会静默返回空集(`oops` 在 SQL 里是列引用,因为没有别的字面量形态)。
|
||||
// 这正是"未解析引用静默变 false"这一整类缺陷(PLAN §3 根因 7)的最后一块。
|
||||
await this.assertWhereColumnsExist(stmt, isJoinQuery);
|
||||
|
||||
if (stmt.fromSubquery) {
|
||||
// v0.4.0: FROM (SELECT ...) 派生表 — 子查询结果作为行源
|
||||
const subRows = await this.executeSelectPart(stmt.fromSubquery);
|
||||
@@ -854,6 +866,16 @@ export class QueryExecutor {
|
||||
// ---- JOIN ----
|
||||
|
||||
private async executeJoinSelect(stmt: SelectStatement, preloadedMain?: Record<string, unknown>[]): Promise<Record<string, unknown>[]> {
|
||||
// v0.8.0(A37):JOIN ON 的列引用也要校验存在性。
|
||||
//
|
||||
// 修复前 `ON l.k = rn.nope` 这类拼错的列名会走到 matchWhere → $col 取不到值
|
||||
// → UNKNOWN → **没有任何行能连接上**,用户看到的是空结果而不是"列不存在"。
|
||||
// 这里与 SELECT 的 WHERE 校验共用同一实现(不同 context 文案与歧义策略)。
|
||||
for (const join of stmt.joins ?? []) {
|
||||
if (join.on && Object.keys(join.on).length > 0) {
|
||||
await this.validateJoinOnColumns(stmt, join);
|
||||
}
|
||||
}
|
||||
const mainAlias = stmt.alias ?? stmt.from;
|
||||
// v0.4.0: 派生表行源已预加载(行带别名前缀)
|
||||
let mainRows: Record<string, unknown>[];
|
||||
@@ -1745,7 +1767,198 @@ export class QueryExecutor {
|
||||
return !this.orderByReferencesOutputColumns(stmt);
|
||||
}
|
||||
|
||||
/** 列列表是否包含 CASE WHEN 表达式 */
|
||||
/**
|
||||
* v0.8.0(A37):校验 JOIN ON 里引用的列在**参与连接的两张表**之一存在。
|
||||
*
|
||||
* `ON a.x = b.y` 的 `a.x` 属于主表或已有 JOIN 表,`b.y` 属于当前 JOIN 表 ——
|
||||
* 两侧都要能找到归属;否则报 COLUMN_NOT_FOUND(而不是让连接静默产生空结果)。
|
||||
* 裸列名只要求"某一侧存在"(`ON k = k` 的既有语义是取主表列)。
|
||||
*/
|
||||
private async validateJoinOnColumns(
|
||||
stmt: SelectStatement,
|
||||
join: JoinClause,
|
||||
): Promise<void> {
|
||||
const available = new Set<string>();
|
||||
const addTable = async (table: string, alias: string): Promise<void> => {
|
||||
const schema = await this.engine.getTableSchema(table);
|
||||
if (!schema) return;
|
||||
for (const col of Object.keys(schema.columns)) {
|
||||
available.add(col);
|
||||
available.add(`${alias}.${col}`);
|
||||
}
|
||||
};
|
||||
await addTable(stmt.from, stmt.alias ?? stmt.from);
|
||||
for (const other of stmt.joins ?? []) {
|
||||
await addTable(other.table, other.alias ?? other.table);
|
||||
if (other === join) break;
|
||||
}
|
||||
await addTable(join.table, join.alias ?? join.table);
|
||||
|
||||
const missing: string[] = [];
|
||||
const check = (ref: string): void => {
|
||||
const text = ref.trim();
|
||||
if (!text) return;
|
||||
if (available.has(text)) return;
|
||||
const bare = text.includes('.') ? text.split('.').pop()! : text;
|
||||
if (available.has(bare)) return;
|
||||
missing.push(text);
|
||||
};
|
||||
const walkOperand = (value: unknown): void => {
|
||||
if (typeof value !== 'object' || value === null) return;
|
||||
for (const [op, operand] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (op === '$col') { check(String(operand)); continue; }
|
||||
if (typeof operand === 'object' && operand !== null && !Array.isArray(operand)) walkOperand(operand);
|
||||
}
|
||||
};
|
||||
for (const [key, value] of Object.entries(join.on)) {
|
||||
if (key === '$and' || key === '$or' || key === '$not') continue;
|
||||
check(key);
|
||||
walkOperand(value);
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
throw new DatabaseError(
|
||||
`Unknown column${missing.length > 1 ? 's' : ''} ${missing.map((c) => `"${c}"`).join(', ')}`
|
||||
+ ` in JOIN ON of table "${stmt.from}"`,
|
||||
'COLUMN_NOT_FOUND',
|
||||
{ columns: missing, from: stmt.from },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.8.0(A37):校验 WHERE 中出现的列名都存在于行源。
|
||||
*
|
||||
* 覆盖两类引用:
|
||||
* - **键位**:`WHERE x = 1` 的 `x`;
|
||||
* - **`$col` 值位**:`WHERE x = y` 的 `y`(parser 生成 `{ x: { $eq: { $col: 'y' } } }`),
|
||||
* 含 `$and`/`$or`/`$not` 内部的操作数槽。
|
||||
*
|
||||
* 派生表与子查询的行源:跳过(其列来自子查询投影,需要单独解析;
|
||||
* 由 `normalizeUnprefixedReferences` + 运行时行形状决定)。
|
||||
* JOIN:两侧的表别名都要参与判定 —— `t.x` 与另一表的 `u.x` 都属于合法引用。
|
||||
*
|
||||
* 之所以必须是**显式错误**而不是依赖运行时 UNKNOWN:`$col` 取不到值时
|
||||
* 三值求值器只能返回 UNKNOWN,而 UNKNOWN 在 WHERE 里表现为"不保留该行" ——
|
||||
* 用户看到的是"没有匹配数据",与"列名拼错"完全无法区分。
|
||||
*/
|
||||
private async assertWhereColumnsExist(stmt: SelectStatement, isJoinQuery: boolean): Promise<void> {
|
||||
if (!stmt.where || Object.keys(stmt.where).length === 0) return;
|
||||
// 派生表行源:列来自子查询投影,此处不做 schema 校验
|
||||
if (stmt.fromSubquery) return;
|
||||
if (!stmt.from) return;
|
||||
await this.validateWhereColumns(stmt, stmt.where, {
|
||||
context: 'WHERE',
|
||||
rejectAmbiguous: isJoinQuery,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用的 WHERE 侧列引用校验(SELECT 的 WHERE 与 JOIN 的 ON 共用)。
|
||||
*
|
||||
* @param context 错误消息中的位置描述('WHERE' / 'JOIN ON')
|
||||
* @param rejectAmbiguous JOIN 场景下裸列名被多表共有 → 报歧义(SQL 标准要求限定)。
|
||||
* **JOIN ON 传 false**:`ON a.x = b.x` 里两侧的限定名各自
|
||||
* 合法,而 `ON k = k` 这种裸写法在 JOIN 语境下按"取主表列"
|
||||
* 解释(与既有 joinRows 行为一致),不应因为"两表都有 k"
|
||||
* 就拒绝 —— 那会把常见的等值连接写法判为错误。
|
||||
*/
|
||||
private async validateWhereColumns(
|
||||
stmt: SelectStatement,
|
||||
where: WhereCondition,
|
||||
opts: { context: string; rejectAmbiguous: boolean },
|
||||
): Promise<void> {
|
||||
const isJoinQuery = opts.rejectAmbiguous;
|
||||
const available = new Set<string>();
|
||||
const owners = new Map<string, string>(); // 裸列名 → 表别名(用于歧义判定)
|
||||
const collectFrom = async (table: string, alias: string): Promise<void> => {
|
||||
const schema = await this.engine.getTableSchema(table);
|
||||
if (!schema) return;
|
||||
for (const col of Object.keys(schema.columns)) {
|
||||
available.add(col);
|
||||
available.add(`${alias}.${col}`);
|
||||
const prev = owners.get(col);
|
||||
if (prev === undefined) owners.set(col, alias);
|
||||
else if (prev !== alias) owners.set(col, '*'); // 多表共有 → 歧义
|
||||
}
|
||||
};
|
||||
|
||||
await collectFrom(stmt.from, stmt.alias ?? stmt.from);
|
||||
if (isJoinQuery) {
|
||||
for (const join of stmt.joins ?? []) {
|
||||
await collectFrom(join.table, join.alias ?? join.table);
|
||||
}
|
||||
}
|
||||
|
||||
const missing: string[] = [];
|
||||
const ambiguous: string[] = [];
|
||||
const check = (ref: string): void => {
|
||||
const text = ref.trim();
|
||||
if (!text) return;
|
||||
if (available.has(text)) {
|
||||
// 裸列名在 JOIN 中若被多张表共有 → 歧义(SQL 标准要求限定)
|
||||
if (!text.includes('.') && isJoinQuery && owners.get(text) === '*') ambiguous.push(text);
|
||||
return;
|
||||
}
|
||||
// 唯一后缀匹配(行键可能带别名,而 where 写作裸列名;反向亦然)
|
||||
const bare = text.includes('.') ? text.split('.').pop()! : text;
|
||||
if (available.has(bare)) return;
|
||||
missing.push(text);
|
||||
};
|
||||
|
||||
const walkOperand = (value: unknown): void => {
|
||||
if (typeof value !== 'object' || value === null) return;
|
||||
const ops = value as Record<string, unknown>;
|
||||
for (const [op, operand] of Object.entries(ops)) {
|
||||
if (op === '$col') {
|
||||
check(String(operand));
|
||||
continue;
|
||||
}
|
||||
if (typeof operand === 'object' && operand !== null && !Array.isArray(operand)) {
|
||||
walkOperand(operand);
|
||||
} else if (Array.isArray(operand)) {
|
||||
for (const item of operand) walkOperand(item);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const walkWhere = (cond: WhereCondition): void => {
|
||||
for (const [key, value] of Object.entries(cond)) {
|
||||
if (key === '$and' || key === '$or') {
|
||||
for (const sub of (Array.isArray(value) ? value : [value]) as WhereCondition[]) walkWhere(sub);
|
||||
continue;
|
||||
}
|
||||
if (key === '$not') {
|
||||
walkWhere(value as WhereCondition);
|
||||
continue;
|
||||
}
|
||||
if (key === '$exists') continue; // 由子查询执行结果填充
|
||||
if (/^\s*CASE\b/i.test(key)) continue; // CASE 键内含表达式,另行求值
|
||||
check(key);
|
||||
walkOperand(value);
|
||||
}
|
||||
};
|
||||
walkWhere(where);
|
||||
|
||||
if (ambiguous.length > 0) {
|
||||
throw new DatabaseError(
|
||||
`Ambiguous column${ambiguous.length > 1 ? 's' : ''} ${ambiguous.map((c) => `"${c}"`).join(', ')}`
|
||||
+ ` in ${opts.context}: present in multiple joined tables. Qualify with a table alias.`,
|
||||
'COLUMN_NOT_FOUND',
|
||||
{ columns: ambiguous },
|
||||
);
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
throw new DatabaseError(
|
||||
`Unknown column${missing.length > 1 ? 's' : ''} ${missing.map((c) => `"${c}"`).join(', ')}`
|
||||
+ ` in ${opts.context} of table "${stmt.from}"`,
|
||||
'COLUMN_NOT_FOUND',
|
||||
{ columns: missing, from: stmt.from },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列列表是否包含 CASE WHEN 表达式 */
|
||||
private hasCaseColumn(columns: string[]): boolean {
|
||||
return columns.some((col) => /^\s*CASE\b/i.test(col));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user