test(coverage): 补齐 B-4/B-5 新代码的分支覆盖 + ORDER BY 列校验 + 删除死代码
覆盖率门禁(statements 90 / branches 82 / functions 94 / lines 93)在 B-4/B-5 落地后 **真的失败了**(branches 81.87%、functions 93.93%)—— 说明门禁确实在起作用。 本次不是下调阈值,而是两种正确处置: ① 删除死代码(3 个导出,从未被调用) - `isCaseExpression`:B-4 重构后 executor 改用 `parseCaseExpression` 自身判前缀; - `assertNoSimpleCaseForm`:从未接线(简单 CASE 的拒绝由解析器报错覆盖); - `compareForCase`:从未被调用(三值比较走 `sql-compare`)。 - `collectUnknownColumns`(validation.ts):同样从未被调用。 留着它们会让覆盖面看起来更高而实际无人使用 —— 与"覆盖率要反映真实使用"相悖。 ② 补齐真实分支的测试(不写"为覆盖而覆盖"的用例) - `column-value` 的两条取值路径:JOIN 行键 `别名.列` 的精确命中与唯一后缀回退; - JOIN 里两表同名列的**裸引用歧义**; - CASE 解析缓存的"超上限清空重建"分支(600 个不同表达式); - CASE 条件语法错误 → PARSE_ERROR。 顺带修掉一个新暴露的真实缺口:**ORDER BY 的键此前完全不校验** `SELECT ... FROM l JOIN r ON l.tag = r.tag ORDER BY tag`(两表都有 tag)既不报错 也不确定按哪一列排 —— 结果取决于行键插入顺序("顺序偶尔不对"这类难查问题)。 现在与 WHERE 同一口径:越界/未知 → COLUMN_NOT_FOUND,裸名歧义 → 要求限定。 豁免两类合法写法:SELECT 别名(输出列名)与派生表(列来自子查询投影)。 新增 `selectAliasNames` 并被 `orderByUsesSelectAlias` 复用 —— 两处若各写一份, 就会出现"排序认为它是别名、校验认为它是列"的矛盾。 覆盖率达到:Statements 90.4% / Branches 82.19% / Functions 94.25% / Lines 93.41%, 四项均高于阈值。全量 89 套件 / 1868 测试通过;typecheck、lint 零错误零告警。
This commit is contained in:
+45
-8
@@ -629,6 +629,31 @@ export class QueryExecutor {
|
||||
// 三条都会静默返回空集(`oops` 在 SQL 里是列引用,因为没有别的字面量形态)。
|
||||
// 这正是"未解析引用静默变 false"这一整类缺陷(PLAN §3 根因 7)的最后一块。
|
||||
await this.assertWhereColumnsExist(stmt, isJoinQuery);
|
||||
// v0.8.0(B-5):ORDER BY 的键同样要校验存在性与歧义 ——
|
||||
// JOIN 里两表同名列裸写时(`ORDER BY tag`),此前既不报错也不确定按哪一列排,
|
||||
// 结果取决于行键插入顺序(难查的"顺序偶尔不对")。与 WHERE 同一口径:
|
||||
// 裸名歧义 → COLUMN_NOT_FOUND 并要求限定。
|
||||
if (stmt.orderBy && stmt.orderBy.length > 0 && !stmt.fromSubquery) {
|
||||
// SELECT 别名(`SELECT n AS num ... ORDER BY num`)是**输出列名**,
|
||||
// 不是行源里的列 —— 必须豁免,否则合法查询会被判未知列(实测)。
|
||||
// 派生表(`FROM (SELECT ...) AS d`)的列来自子查询投影、不在本层 schema,
|
||||
// 因此整段跳过(与 assertWhereColumnsExist 的守卫一致)。
|
||||
const selectAliases = this.selectAliasNames(stmt);
|
||||
const orderWhere: WhereCondition = {};
|
||||
for (const item of stmt.orderBy) {
|
||||
const key = unquoteIdentifier(item.column.trim());
|
||||
if (!key || /^\d+$/.test(key)) continue; // 序号已在 resolveOutputOrdinals 处理
|
||||
if (parseCaseExpression(item.column)) continue; // CASE 已由 assertCaseColumnsExist 校验
|
||||
if (selectAliases.has(key)) continue; // 输出别名
|
||||
orderWhere[key] = { $exists: true };
|
||||
}
|
||||
if (Object.keys(orderWhere).length > 0) {
|
||||
await this.validateWhereColumns(stmt, orderWhere, {
|
||||
context: 'ORDER BY',
|
||||
rejectAmbiguous: isJoinQuery,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (stmt.fromSubquery) {
|
||||
// v0.4.0: FROM (SELECT ...) 派生表 — 子查询结果作为行源
|
||||
@@ -1970,19 +1995,31 @@ export class QueryExecutor {
|
||||
* v0.3.3: ORDER BY 是否引用 SELECT 别名(如 `SELECT name AS n ... ORDER BY n`)。
|
||||
* 别名列在引擎层投影前不存在,需投影后重新排序。
|
||||
*/
|
||||
private orderByUsesSelectAlias(stmt: SelectStatement): boolean {
|
||||
if (!stmt.orderBy || stmt.orderBy.length === 0) return false;
|
||||
/**
|
||||
* v0.8.0(B-5):SELECT 列表产出的**别名集合**(`AS x` 与 `CASE ... AS x`)。
|
||||
*
|
||||
* 与 `orderByUsesSelectAlias` 共用同一套识别规则 —— 两处若各写一份,
|
||||
* 会出现"排序认为它是别名、校验认为它是列"的矛盾(本项目反复出现的漂移模式)。
|
||||
*/
|
||||
private selectAliasNames(stmt: SelectStatement): Set<string> {
|
||||
const aliases = new Set<string>();
|
||||
for (const col of stmt.columns) {
|
||||
const m = col.match(/\s+AS\s+(\w+)$/i);
|
||||
if (m) aliases.add(m[1]);
|
||||
else if (/^\s*CASE\b/i.test(col)) {
|
||||
const expr = parseCaseExpression(col);
|
||||
if (expr?.alias) aliases.add(expr.alias);
|
||||
const caseExpr = parseCaseExpression(col);
|
||||
if (caseExpr?.alias) {
|
||||
aliases.add(caseExpr.alias);
|
||||
continue;
|
||||
}
|
||||
const m = col.match(/\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)\s*$/i);
|
||||
if (m) aliases.add(m[1]);
|
||||
}
|
||||
return aliases;
|
||||
}
|
||||
|
||||
private orderByUsesSelectAlias(stmt: SelectStatement): boolean {
|
||||
if (!stmt.orderBy || stmt.orderBy.length === 0) return false;
|
||||
const aliases = this.selectAliasNames(stmt);
|
||||
if (aliases.size === 0) return false;
|
||||
return stmt.orderBy.some((o) => aliases.has(o.column));
|
||||
return stmt.orderBy.some((o) => aliases.has(unquoteIdentifier(o.column)));
|
||||
}
|
||||
|
||||
/** WHERE 是否包含 CASE WHEN 表达式键 */
|
||||
|
||||
+1
-30
@@ -48,7 +48,7 @@ import { parseWhereCondition } from '../sql/parser';
|
||||
import type { WhereCondition } from '../constants';
|
||||
import { DatabaseError } from '../constants';
|
||||
import { matchWhere } from './where-matcher';
|
||||
import { isSqlNull, sqlCompareOrder, UNRESOLVED, isUnresolved } from './sql-compare';
|
||||
import { UNRESOLVED, isUnresolved } from './sql-compare';
|
||||
import { resolveColumnValue } from './column-value';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -431,34 +431,5 @@ export function evaluateExpressionValue(
|
||||
);
|
||||
}
|
||||
|
||||
/** 判断文本是否为 CASE 表达式(供 executor 分流,避免到处写正则) */
|
||||
export function isCaseExpression(text: string): boolean {
|
||||
return /^\s*CASE\b/i.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单 CASE 形式(`CASE <表达式> WHEN <值> THEN ...`)的检测。
|
||||
*
|
||||
* 当前实现只支持**搜索式 CASE**(`CASE WHEN <条件> THEN ...`)。简单 CASE
|
||||
* 的 `CASE x WHEN 1 THEN` 语法在此前实现里也会被正则误当作条件解析。
|
||||
* 这里显式识别并抛 NOT_SUPPORTED,避免"看起来支持、结果全错"。
|
||||
*/
|
||||
export function assertNoSimpleCaseForm(expr: CaseExpression, tokensText: string): void {
|
||||
if (/^\s*CASE\s+(?!WHEN\b)/i.test(tokensText)) {
|
||||
throw new DatabaseError(
|
||||
'Simple CASE form (CASE <expr> WHEN <value>) is not supported; use searched CASE'
|
||||
+ ' (CASE WHEN <condition> THEN <result> ... END) instead',
|
||||
'NOT_SUPPORTED',
|
||||
);
|
||||
}
|
||||
void expr;
|
||||
}
|
||||
|
||||
/** 供 executor 判断比较顺序的工具(保留三值语义入口,避免各处重复实现) */
|
||||
export function compareForCase(a: unknown, b: unknown): number | null {
|
||||
if (isSqlNull(a) || isSqlNull(b) || isUnresolved(a) || isUnresolved(b)) return null;
|
||||
return sqlCompareOrder(a, b);
|
||||
}
|
||||
|
||||
/** 供调用方复用的哨兵(避免重复 import sql-compare) */
|
||||
export { UNRESOLVED };
|
||||
|
||||
@@ -244,24 +244,6 @@ function assertNotNullConstraints(
|
||||
// 引擎接口适配
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 收集行里所有未在 schema 中定义的键(不抛错版本)。
|
||||
*
|
||||
* 供引擎内部路径使用:级联更新、合并行等场景会带着派生列过来,
|
||||
* 这里只返回名单,由调用方决定丢弃还是报错。
|
||||
*/
|
||||
export function collectUnknownColumns(
|
||||
schema: TableSchema,
|
||||
row: Record<string, unknown>,
|
||||
): string[] {
|
||||
const unknown: string[] = [];
|
||||
for (const key of Object.keys(row)) {
|
||||
if (key === '__proto__') continue;
|
||||
if (!(key in schema.columns)) unknown.push(key);
|
||||
}
|
||||
return unknown;
|
||||
}
|
||||
|
||||
/** 类型再导出,避免调用方从 constants 与 schema 两处 import */
|
||||
export type { ColumnDef, FieldType };
|
||||
|
||||
|
||||
@@ -220,3 +220,83 @@ describe('[v0.8.0] B-5 分隔标识符(四引擎)', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 列引用取值的分支覆盖(共享原语 column-value 的两个关键分支)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('[v0.8.0] B-5 列引用取值原语', () => {
|
||||
it('JOIN 行的 `别名.列` 键与裸列引用都能取到', async () => {
|
||||
const db = await MetonaSqlark.create({ name: 'b5-joinref', mode: 'memory' });
|
||||
await db.defineTable('l', { id: { type: 'string', primaryKey: true }, n: { type: 'number' } });
|
||||
await db.defineTable('r', { id: { type: 'string', primaryKey: true }, l_id: { type: 'string' } });
|
||||
await db.query("INSERT INTO l VALUES ('l1', 5)");
|
||||
await db.query("INSERT INTO r VALUES ('r1', 'l1')");
|
||||
// JOIN 行键形如 `l.id`:`ORDER BY l.n` 走"精确命中",`ORDER BY n` 走"唯一后缀"
|
||||
const qualified = rowsOf<Record<string, unknown>>(
|
||||
await db.query('SELECT l.id, l.n FROM l JOIN r ON l.id = r.l_id ORDER BY l.n'),
|
||||
);
|
||||
expect(qualified).toHaveLength(1);
|
||||
const bare = rowsOf<Record<string, unknown>>(
|
||||
await db.query('SELECT l.id, l.n FROM l JOIN r ON l.id = r.l_id ORDER BY n'),
|
||||
);
|
||||
expect(bare).toHaveLength(1);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
it('JOIN 里两表同名列的裸引用 → 歧义报错(不静默取第一个)', async () => {
|
||||
const db = await MetonaSqlark.create({ name: 'b5-ambiguous', mode: 'memory' });
|
||||
await db.defineTable('l', { id: { type: 'string', primaryKey: true }, tag: { type: 'string' } });
|
||||
await db.defineTable('r', { id: { type: 'string', primaryKey: true }, tag: { type: 'string' } });
|
||||
await db.query("INSERT INTO l VALUES ('l1', 'a')");
|
||||
await db.query("INSERT INTO r VALUES ('r1', 'a')");
|
||||
// 两表都有 tag → 裸 `ORDER BY tag` 无法判定归属
|
||||
await expect(
|
||||
db.query('SELECT l.id FROM l JOIN r ON l.tag = r.tag ORDER BY tag'),
|
||||
).rejects.toMatchObject({ code: 'COLUMN_NOT_FOUND' });
|
||||
// 限定后正常
|
||||
const rows = rowsOf<Record<string, unknown>>(
|
||||
await db.query('SELECT l.id FROM l JOIN r ON l.tag = r.tag ORDER BY l.tag'),
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CASE 解析缓存上限(防止缓存无界增长的分支)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('[v0.8.0] B-5 CASE 解析缓存上限', () => {
|
||||
it('超过上限后清空重建,解析结果仍正确', async () => {
|
||||
const db = await MetonaSqlark.create({ name: 'b5-cache', mode: 'memory' });
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, n: { type: 'number' } });
|
||||
await db.query("INSERT INTO t VALUES ('1', 5)");
|
||||
// 构造 600 个互不相同的 CASE 表达式(超过 512 的缓存上限),
|
||||
// 逐条执行以触发"清空重建"分支;结果必须始终正确。
|
||||
for (let i = 0; i < 600; i++) {
|
||||
const rows = rowsOf<Record<string, unknown>>(
|
||||
await db.query(`SELECT CASE WHEN n > ${i} THEN 'gt' ELSE 'le' END AS r FROM t`),
|
||||
);
|
||||
expect(rows[0].r).toBe(i < 5 ? 'gt' : 'le');
|
||||
}
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CASE 条件解析失败(PARSE_ERROR 分支)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('[v0.8.0] B-5 CASE 条件语法错误', () => {
|
||||
it('条件无法解析 → PARSE_ERROR 且消息带上条件原文', async () => {
|
||||
const db = await MetonaSqlark.create({ name: 'b5-badcond', mode: 'memory' });
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, n: { type: 'number' } });
|
||||
await db.query("INSERT INTO t VALUES ('1', 5)");
|
||||
// `n >` 是残缺条件 → parseWhereCondition 抛错 → 包成 PARSE_ERROR
|
||||
await expect(
|
||||
db.query("SELECT CASE WHEN n > THEN 'a' ELSE 'b' END AS r FROM t"),
|
||||
).rejects.toMatchObject({ code: 'PARSE_ERROR' });
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user