From d14663ef80d687457661978b4aa8fd92bba87a57 Mon Sep 17 00:00:00 2001 From: thzxx Date: Tue, 15 Sep 2026 07:51:10 +0800 Subject: [PATCH] =?UTF-8?q?test(coverage):=20=E8=A1=A5=E9=BD=90=20B-4/B-5?= =?UTF-8?q?=20=E6=96=B0=E4=BB=A3=E7=A0=81=E7=9A=84=E5=88=86=E6=94=AF?= =?UTF-8?q?=E8=A6=86=E7=9B=96=20+=20ORDER=20BY=20=E5=88=97=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=20+=20=E5=88=A0=E9=99=A4=E6=AD=BB=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖率门禁(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 零错误零告警。 --- src/query/executor.ts | 53 +++++++++++++++++--- src/query/expression.ts | 31 +----------- src/table/validation.ts | 18 ------- tests/v080-output-ordinals.test.ts | 80 ++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 56 deletions(-) diff --git a/src/query/executor.ts b/src/query/executor.ts index efc3366..8f181a5 100644 --- a/src/query/executor.ts +++ b/src/query/executor.ts @@ -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 { const aliases = new Set(); 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 表达式键 */ diff --git a/src/query/expression.ts b/src/query/expression.ts index aaccd43..12ea61b 100644 --- a/src/query/expression.ts +++ b/src/query/expression.ts @@ -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 WHEN ) is not supported; use searched CASE' - + ' (CASE WHEN THEN ... 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 }; diff --git a/src/table/validation.ts b/src/table/validation.ts index 61d5e92..b08ee38 100644 --- a/src/table/validation.ts +++ b/src/table/validation.ts @@ -244,24 +244,6 @@ function assertNotNullConstraints( // 引擎接口适配 // --------------------------------------------------------------------------- -/** - * 收集行里所有未在 schema 中定义的键(不抛错版本)。 - * - * 供引擎内部路径使用:级联更新、合并行等场景会带着派生列过来, - * 这里只返回名单,由调用方决定丢弃还是报错。 - */ -export function collectUnknownColumns( - schema: TableSchema, - row: Record, -): 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 }; diff --git a/tests/v080-output-ordinals.test.ts b/tests/v080-output-ordinals.test.ts index 2063083..efd739c 100644 --- a/tests/v080-output-ordinals.test.ts +++ b/tests/v080-output-ordinals.test.ts @@ -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>( + 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>( + 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>( + 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>( + 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(); + }); +});