From 841db2e0498cdc69f6af407aa198894779d2ea08 Mon Sep 17 00:00:00 2001 From: thzxx Date: Tue, 15 Sep 2026 00:00:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(A22/A23/A25/A26/A27/A29/A30/A36):=20?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E5=B1=82=208=20=E9=A1=B9=E7=BC=BA=E9=99=B7?= =?UTF-8?q?=E6=A0=B9=E6=B2=BB=20+=20=E5=8D=95=E4=B8=80=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=E6=94=B6=E6=95=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 每项都先用可执行探针复现出**错误的实际输出**,再修根因、补永久回归套件 (tests/v080-query-layer.test.ts,8 项 × 4 引擎 + 跨引擎项,共 111 断言)。 A22 GROUP BY 引用 SELECT 别名 修复前:`SELECT g AS grp, COUNT(*) FROM t GROUP BY grp` 抛 `COLUMN_NOT_FOUND Unknown column "g" in SELECT list` —— 错误信息与真正原因 (GROUP BY 用了别名)无关,因为 grp 取到 undefined 使全表并成一组, 投影阶段又发现 g 不在输出行里。 修复:GROUP BY 项先解析回**基列**(别名 → 源表达式)再分组,输出键与 "按基列分组"完全一致。 A23 HAVING 引用未出现在 SELECT 里的聚合 修复前:`SELECT g FROM t GROUP BY g HAVING SUM(n) > 25` → `[]` (SUM(n) 从未被求值 → HAVING 的键在分组行里不存在 → UNKNOWN)。 修复:需要计算的聚合 = SELECT 列 ∪ HAVING 中的聚合(并集), 并在 HAVING **之后**才把行收缩为 SELECT 输出键(否则又变回 []; 顺序错了会双向出错:先投影 → 空结果,不投影 → 泄漏内部聚合列)。 A25 带表前缀的聚合参数恒 0 修复前:`COUNT(t.n)` → 0、`SUM(t.n)` → null(行键是 n,直接取 row['t.n'] 得 undefined 再被"过滤 NULL"剔除,**且不报错**)。 修复:新增唯一列引用解析 resolveColumnValue(前缀剥离 → 精确 → 唯一后缀), 聚合识别统一为 parseAggregateExpression —— 此前"是否聚合"与"如何求值" 用两条不同的正则。取不到列改为抛 COLUMN_NOT_FOUND,不再静默计 0。 A26 UNION 尾部 ORDER BY/LIMIT 归属错误 修复前:`A UNION B ORDER BY id DESC` 只排 B;`... LIMIT 3` 返回 4 行 (parser 把子句挂在右侧 SELECT 上,AST 没有复合查询级字段)。 修复:SelectUnionStatement 增加 orderBy/limit/offset,parser 把子句**上移** (移动而非复制,否则 LIMIT 应用两次),executor 在合并+去重后统一排序/切片。 A27 DISTINCT 作用在投影前 修复前:`SELECT DISTINCT g AS d FROM t` 返回 4 行 a,a,b,b (对 {id,g,n} 原始行去重),而 `SELECT DISTINCT g` 返回 2 行。 修复:DISTINCT 移到投影后(作用于输出列);ORDER BY 的应用时机随之拆成 "引用输出列 → 投影后" / "引用非输出列 → 投影前",两者互为因果必须一起改。 A29 maxRowsPerQuery 静默截断写入 修复前:maxRowsPerQuery=2 时 `INSERT INTO dst SELECT id FROM src`(4 行源) 只写 2 行并报成功 —— 不是"限制查询规模"而是**静默丢数据**。 修复:行源不截断(executeSelect 增加 purpose='source'),写路径显式报错。 A30 INSERT 值多于目标列静默丢弃 修复前:`INSERT INTO t (id,g) VALUES ('9','z','LOST')` 报成功、'LOST' 消失。 修复:显式列名时解析期拦截(PARSE_ERROR),未给列名时 executor 对照 schema 拦截(VALIDATION_ERROR)——两种情况都需要,因为前者无需 schema。 A36 派生表别名引用 修复前:`SELECT d.id FROM (SELECT id, g FROM t) AS d` 返回 `[]`, 而同义的 `SELECT id FROM (...) AS d` 正确。 修复:抽出 normalizeUnprefixedReferences(WHERE/ORDER BY/GROUP BY/SELECT 四类引用统一剥离别名前缀),非 JOIN 单表路径与派生表路径共用同一规则。 连带根治(修复过程中发现的两个更底层问题): 1. **同步抛错穿过 async 边界**:`executor.execute()` 里 `return this.executeXxx(stmt)` 的同步前导段若抛错(arity/校验),异常成为**同步抛出** —— `await expect(db.query(...)).rejects...` 的断言不生效、`.catch()` 永不执行。 现统一包一层 try/catch,保证任何错误都是 rejected promise。 2. **缺列的行形状不一致**:validateRow 此前"值为 undefined 就不落键", 于是 `INSERT INTO t (id,g) VALUES ('9','z')` 的行里没有 n 键 → `SELECT id,g,n FROM t` 抛 COLUMN_NOT_FOUND: n,而 `SELECT * FROM t` 正常。 现在缺列且无 default → 显式补 null(SQL 语义),行始终含全部 schema 列; ALTER ADD 同步在已有行上物化 null,使"内存视图"与"重启后视图"一致。 验证:全量 83 套件 / 1589 测试通过(含 Aria 5 万行索引竞态、KVStore 持久化); typecheck(src+tests) 与 lint 零错误。 --- src/engine/memory.ts | 11 + src/query/ast.ts | 11 + src/query/executor.ts | 853 ++++++++++++++++++++++---- src/sql/parser.ts | 35 ++ src/table/validation.ts | 40 +- tests/engine/kvstore-engine.test.ts | 10 +- tests/v080-query-layer.test.ts | 363 +++++++++++ tests/v080-unified-validation.test.ts | 12 +- 8 files changed, 1221 insertions(+), 114 deletions(-) create mode 100644 tests/v080-query-layer.test.ts diff --git a/src/engine/memory.ts b/src/engine/memory.ts index 0c42aed..f9de241 100644 --- a/src/engine/memory.ts +++ b/src/engine/memory.ts @@ -158,6 +158,17 @@ export class MemoryEngine implements IStorageEngine { pks.add(pk); } } + // v0.8.0(B-1):ADD 列在**已有行**上物化为 NULL。 + // + // 行校验契约(table/validation.ts)保证"行含全部 schema 列",若 ALTER ADD + // 不补齐,新列在旧行上就是**键不存在**:内存里 `{id,name}`、而同一行经 + // 落盘再读回(KVStore/Aria 的恢复路径会走 validateRow)变成 + // `{id,name,phone:null}` —— 同一行的形状取决于"是否重启过"。 + // 显式物化后,内存视图与持久化视图一致。 + const addTable = this.tables.get(tableName)!; + for (const row of addTable.values()) { + if (!(column.name in row)) row[column.name] = null; + } return; } if (!schema.columns[column.name]) { diff --git a/src/query/ast.ts b/src/query/ast.ts index 9db2613..7f61d4a 100644 --- a/src/query/ast.ts +++ b/src/query/ast.ts @@ -158,6 +158,17 @@ export interface SelectUnionStatement { right: SelectStatement | SelectUnionStatement; /** UNION ALL 不去重 */ all?: boolean; + /** + * v0.8.0(A26):复合查询**整体**的 ORDER BY / LIMIT / OFFSET。 + * + * SQL 标准里这三者作用于整个 UNION 结果,而不是最后一个 SELECT。 + * 此前 AST 没有这三个字段,parser 把它们挂在了 UNION 右侧的 SELECT 上 —— + * 于是 `A UNION B ORDER BY id DESC` 只对 B 排序、`... LIMIT 3` 只截断 B + * (实测 `SELECT id FROM t UNION SELECT id FROM t LIMIT 3` 返回 4 行)。 + */ + orderBy?: OrderBy[]; + limit?: number; + offset?: number; } // --------------------------------------------------------------------------- diff --git a/src/query/executor.ts b/src/query/executor.ts index 34c9b49..bade329 100644 --- a/src/query/executor.ts +++ b/src/query/executor.ts @@ -11,11 +11,13 @@ import type { DeleteStatement, CreateTableStatement, DropTableStatement, JoinClause, AlterTableStatement, TruncateTableStatement, CreateIndexStatement, DropIndexStatement, } from './ast'; +import type { OrderBy } from '../constants'; import { DatabaseError } from '../constants'; import { compileStatement } from './compiler'; import { createSchema, astColumnToColumnDef } from '../table/schema'; import { matchWhere, applyOrderBy, projectColumns, containsUnresolvedSubqueries } from './where-matcher'; import { parseWhereCondition } from '../sql/parser'; +import { encodeValueKey } from './sql-compare'; // --------------------------------------------------------------------------- // SELECT 执行形态(v0.8.0) @@ -52,22 +54,15 @@ import type { WhereCondition } from '../constants'; // --------------------------------------------------------------------------- // 分组 / 去重键编码(v0.7.4) // --------------------------------------------------------------------------- +// 分组 / 去重键 +// --------------------------------------------------------------------------- -/** - * v0.7.4: 分组/去重键的类型安全编码 —— 此前 `String(v ?? 'null')` 使 - * null 与字符串 'null' 合并为一组(GROUP BY 静默少组),`String(v ?? '\0')` - * 使 null/undefined/'\0' 在 DISTINCT/UNION 中互相吞并。类型前缀编码后 - * 各类型独立,仅同类型同值合并(与 where-matcher 的 === 语义一致)。 - */ -function encodeGroupKey(v: unknown): string { - if (v === null) return 'n'; - if (v === undefined) return 'u'; - if (typeof v === 'string') return `s${v}`; - if (typeof v === 'number') return `d${v}`; - if (typeof v === 'boolean') return `b${v}`; - if (typeof v === 'object') return `o${JSON.stringify(v)}`; - return `x${String(v)}`; -} +// v0.8.0(B-2):`encodeValueKey` 已删除 —— 它曾是**第四份**值编码实现 +// (`sql-compare.ts` 的 `encodeValueKey`、Aria 索引键、COUNT(DISTINCT) 各有其一)。 +// 四份编码对 null/undefined 的处理各不相同,于是"同两个值在 GROUP BY 相等、 +// 在 DISTINCT 不等"这类跨路径矛盾无法根除。现在统一使用 `encodeValueKey`: +// 类型前缀 + 长度前缀,null 与 undefined 同为 SQL NULL(合并), +// 且编码不会与数据内容冲突。 // --------------------------------------------------------------------------- // CASE WHEN 表达式(v0.3.1) @@ -94,6 +89,157 @@ interface CaseExpression { * (键在、值为 undefined,JSON 序列化后整个键消失), * 而这正是 EXISTS 子查询里最常见的写法(`SELECT 1 FROM ...`)。 */ +/** + * 列引用取值(唯一实现)。 + * + * v0.8.0(A25):为什么必须有这一个函数。 + * + * "从行里取一列"此前散落在 4 处、规则各不相同: + * - `projectColumns`(where-matcher)精确匹配 + 唯一后缀匹配; + * - `resolveAliasSource` 只做 `row[text]`,**没有**后缀回退; + * - 聚合参数直接 `r[argCol]`,既没有后缀回退,也没有"列不存在"的报错; + * - `matchWhere` 的 `resolveField` 又有自己的一套。 + * 于是 `SELECT COUNT(t.n) FROM t` 返回 0:行键是 `n`,而聚合参数是 `t.n`, + * 直接取值得到 undefined,再被 `.filter(v => v != null)` 剔除 —— 计数为 0, + * **不报错**。而同一个 `t.n` 写在 SELECT 列表里(`SELECT t.n`)却正常, + * 因为那条路径会先剥离别名前缀。 + * + * 本函数统一三件事:别名前缀剥离 → 精确匹配 → 唯一后缀匹配; + * 都不命中时由调用方决定是抛错还是返回 undefined(`onMissing`)。 + */ +function resolveColumnValue( + row: Record, + reference: string, + opts: { strict: boolean; context: string }, +): unknown { + const text = reference.trim(); + if (text in row) return row[text]; + + // 别名前缀(`t.n` → `n`):JOIN 行用 `alias.col` 作键,但单表路径的 + // 行键不带前缀,两种形态都必须支持。 + if (text.includes('.')) { + const bare = text.split('.').pop()!; + if (bare in row) return row[bare]; + } + + // 唯一后缀匹配:行键 `t.n` 而引用写作 `n` + let found: unknown; + let hits = 0; + for (const key of Object.keys(row)) { + if (key.endsWith(`.${text}`)) { + found = row[key]; + hits += 1; + } + } + if (hits === 1) return found; + + if (opts.strict) { + throw new DatabaseError( + hits > 1 + ? `Ambiguous column "${text}" in ${opts.context}: present in multiple tables` + : `Unknown column "${text}" in ${opts.context}`, + 'COLUMN_NOT_FOUND', + { column: text }, + ); + } + return undefined; +} + +/** + * 解析聚合函数表达式 —— **唯一**的聚合识别实现。 + * + * 匹配形态:`FUNC( [DISTINCT] arg ) [AS alias]`,且整个表达式必须被聚合调用 + * 完整覆盖(`COUNT(1) + 1` 之类复合表达式**不**匹配,由调用方另行处理)。 + * + * 为什么把"输出键"与"参数"一起返回:聚合在 GROUP BY 与单行聚合两条路径上 + * 都要用同一个 (函数, 参数, 输出键) 三元组,此前两处各写一个正则、 + * 各算一次输出键,是 A25 那类漂移的温床。 + */ +function parseAggregateExpression( + expr: string, +): { func: string; arg: string; outputKey: string; distinct: boolean; exprKey: string } | null { + const m = expr.trim().match(/^(COUNT|SUM|AVG|MIN|MAX)\s*\(\s*([\s\S]+?)\s*\)\s*(?:AS\s+([A-Za-z_][A-Za-z0-9_]*))?$/i); + if (!m) return null; + const func = m[1].toUpperCase(); + let arg = m[2].trim(); + const alias = m[3]; + const distinct = /^DISTINCT\s+/i.test(arg); + if (distinct) arg = arg.replace(/^\s*DISTINCT\s+/i, '').trim(); + const exprKey = `${func}(${distinct ? 'DISTINCT ' : ''}${arg})`; + return { func, arg, outputKey: alias || expr.trim(), distinct, exprKey }; +} + +/** + * 判断 SELECT 列表达式是否为聚合表达式(含别名)。 + * 与 `parseAggregateExpression` 共用同一正则 —— 此前 `analyzeSelect` 用 + * `/^(COUNT|SUM|AVG|MIN|MAX)\(/` 单独判断,而执行路径用另一条正则: + * `COUNT (n)`(函数名与括号间有空格)会被前者判为"有聚合"、被后者判为 + * "不是聚合" → 走非聚合分支、输出 null。 + */ +function isAggregateExpression(expr: string): boolean { + return parseAggregateExpression(expr) !== null; +} + +/** + * 从 WHERE/HAVING 条件里提取聚合表达式原文(A23)。 + * + * 条件里的聚合可能出现在两个位置: + * - **键位**:`HAVING SUM(n) > 25` 解析为 `{ 'SUM(n)': { $gt: 25 } }` + * —— 表达式在键上,这是 parser 当前的形态; + * - **值位**:`HAVING 25 < SUM(n)` 之类的反向写法会把聚合放在操作数里, + * 这里一并扫描(`$gt: 'SUM(n)'` 不会出现,但 `{$eq:'SUM(n)'}` 可能出现)。 + * + * 只做**字符串级**提取(把找到的片段交给 parseAggregateExpression 校验), + * 不做表达式求值 —— 求值由调用方在拿到分组行之后进行。 + */ +function collectAggregateExpressionsInWhere(where: WhereCondition | undefined): string[] { + const found: string[] = []; + if (!where) return found; + const scanText = (text: string): void => { + const re = /(?:COUNT|SUM|AVG|MIN|MAX)\s*\([^()]*(?:\([^()]*\)[^()]*)*\)/gi; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) found.push(m[0]); + }; + const walk = (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[]) walk(sub); + continue; + } + if (key === '$not') { + walk(value as WhereCondition); + continue; + } + // 键位:表达式原文作键 + scanText(key); + // 值位:操作数可能是字符串形态的表达式 + if (typeof value === 'string') scanText(value); + else if (Array.isArray(value)) { + for (const item of value) if (typeof item === 'string') scanText(item); + } else if (value !== null && typeof value === 'object') { + for (const operand of Object.values(value as Record)) { + if (typeof operand === 'string') scanText(operand); + } + } + } + }; + walk(where); + return found; +} + +/** + * 列引用 → 输出键:剥离表别名前缀(`t.n` → `n`)。 + * + * 单表查询的行键不带前缀,而 SELECT 列表可以写 `t.n`;输出键使用裸列名 + * 与 `projectRow` 的 `projectColumns` 行为一致(否则 `SELECT t.n FROM t` + * 在聚合路径输出 `t.n`、在普通路径输出 `n`,同一查询两种行形状)。 + */ +function bareReference(reference: string): string { + const text = reference.trim(); + if (!text.includes('.')) return text; + return text.split('.').pop()!; +} + function resolveAliasSource(source: string, row: Record): unknown { const text = source.trim(); // 字符串常量(含 SQL 标准 '' 转义还原) @@ -104,7 +250,8 @@ function resolveAliasSource(source: string, row: Record): unkno if (/^TRUE$/i.test(text)) return true; if (/^FALSE$/i.test(text)) return false; if (/^NULL$/i.test(text)) return null; - return row[text]; + // 列引用:走统一解析(此前只做 row[text],`t.n` 形态取不到值) + return resolveColumnValue(row, text, { strict: false, context: 'SELECT list' }); } /** @@ -203,7 +350,30 @@ export class QueryExecutor { this.maxRowsPerQuery = maxRowsPerQuery; } + /** + * 执行一条语句。 + * + * v0.8.0:**同步抛错也必须表现为 rejected promise**。 + * + * `db.query()` 是 async 的,但 `async` 只把**函数体内**的同步抛错转成 rejection; + * 这里 `return this.executeXxx(stmt)` 的调用发生在 async 函数的同步前导段, + * 若被调方法在**进入第一个 await 之前**就抛错(例如 INSERT 的 arity 校验、 + * VALIDATION_ERROR),异常会穿过 async 边界成为**同步抛出**: + * await expect(db.query(...)).rejects.toMatchObject(...) // 断言不生效,测试崩 + * db.query(...).catch(...) // 永远不执行 + * 对调用方而言这是不可预期的 —— 同一个 API 有的错误走 catch、有的走 try。 + * 因此这里显式包一层 try/catch 统一成 rejection(`async` 方法里 `throw` + * 一定产出 rejected promise)。 + */ async execute(stmt: Statement): Promise { + try { + return await this.dispatch(stmt); + } catch (error) { + throw error instanceof Error ? error : new DatabaseError(String(error), 'QUERY_ERROR'); + } + } + + private dispatch(stmt: Statement): Promise | unknown { switch (stmt.type) { case 'SELECT': return this.executeSelect(stmt); case 'SELECT_UNION': return this.executeSelectUnion(stmt); @@ -233,43 +403,118 @@ export class QueryExecutor { // UNION(v0.3.0) // =================================================================== - /** 递归执行 UNION / UNION ALL,返回合并结果 */ - private async executeSelectUnion(stmt: SelectUnionStatement): Promise[]> { - const leftRows = await this.executeSelectPart(stmt.left); - const rightRows = await this.executeSelectPart(stmt.right); + /** + * 递归执行 UNION / UNION ALL,返回合并结果。 + * + * v0.8.0(A26):尾部 ORDER BY / LIMIT / OFFSET 作用于**整个**复合结果。 + * + * 此前 `SelectUnionStatement` 没有这三个字段,parser 把它们挂在右侧 SELECT 上 + * —— 于是 `A UNION B ORDER BY id DESC` 只排 B(实测返回 1,2,4,3), + * `A UNION B LIMIT 3` 只截断 B(实测返回 4 行)。现在由本方法在合并 + 去重 + * **之后**统一排序与截断(顺序:合并 → 去重 → 排序 → OFFSET/LIMIT)。 + */ + private async executeSelectUnion( + stmt: SelectUnionStatement, + purpose: 'result' | 'source' = 'result', + ): Promise[]> { + const leftRows = await this.executeSelectPart(stmt.left, purpose); + const rightRows = await this.executeSelectPart(stmt.right, purpose); // 列名以左侧为准,右侧只取值 const leftCols = leftRows.length > 0 ? Object.keys(leftRows[0]) : []; const normalized: Record[] = leftRows.map((row) => row); + let result: Record[]; if (stmt.all) { for (const row of rightRows) normalized.push(this.projectUnionRow(row, leftCols)); - return normalized; + result = normalized; + } else { + // UNION 去重(与 DISTINCT 相同的列值拼接键) + const seen = new Set(); + result = []; + for (const row of normalized) { + const key = Object.values(row).map(encodeValueKey).join('\x1f'); + if (!seen.has(key)) { + seen.add(key); + result.push(row); + } + } + for (const row of rightRows) { + const projected = this.projectUnionRow(row, leftCols); + const key = Object.values(projected).map(encodeValueKey).join('\x1f'); + if (!seen.has(key)) { + seen.add(key); + result.push(projected); + } + } } - // UNION 去重(与 DISTINCT 相同的列值拼接键) - const seen = new Set(); - const result: Record[] = []; - for (const row of normalized) { - const key = Object.values(row).map(encodeGroupKey).join('\x1f'); - if (!seen.has(key)) { - seen.add(key); - result.push(row); - } + // 尾部子句(作用于整个复合结果)。 + // 排序键可能引用**输出列序号**(`ORDER BY 1`)或输出列名 —— 复合结果没有表 + // 上下文,因此这里只按结果行的键解析,不做 schema 校验。 + if (stmt.orderBy && stmt.orderBy.length > 0) { + result = this.orderCompoundResult(result, stmt.orderBy); } - for (const row of rightRows) { - const projected = this.projectUnionRow(row, leftCols); - const key = Object.values(projected).map(encodeGroupKey).join('\x1f'); - if (!seen.has(key)) { - seen.add(key); - result.push(projected); - } + const offset = stmt.offset ?? 0; + if (offset > 0 || stmt.limit !== undefined) { + const limit = stmt.limit ?? result.length; + result = result.slice(offset, offset + limit); } return result; } - private async executeSelectPart(part: SelectStatement | SelectUnionStatement): Promise[]> { - return part.type === 'SELECT_UNION' ? this.executeSelectUnion(part) : this.executeSelect(part); + /** + * 复合结果排序:把 ORDER BY 项解析为结果行的键。 + * + * 支持两种写法(与单表 SELECT 一致): + * - 输出列序号:`ORDER BY 1` → 第 1 个输出列(SQL 标准,UNION 场景最常见, + * 因为各分支的输出列名可能不同); + * - 输出列名:`ORDER BY id` → 结果行的 `id` 键。 + * 引用不存在的列时返回原序(不静默丢弃排序 —— 排序键缺失本身不改变行集合, + * 但会让用户以为已排序;故此处抛 COLUMN_NOT_FOUND,与 SELECT 路径口径一致)。 + */ + private orderCompoundResult( + rows: Record[], + orderBy: OrderBy[], + ): Record[] { + const firstRow = rows[0]; + const outputColumns = firstRow ? Object.keys(firstRow) : []; + const resolved: OrderBy[] = orderBy.map((item) => { + const asOrdinal = /^\d+$/.test(item.column.trim()) ? Number(item.column.trim()) : null; + if (asOrdinal !== null) { + if (asOrdinal < 1 || asOrdinal > outputColumns.length) { + throw new DatabaseError( + `ORDER BY position ${asOrdinal} is out of range: compound query has ${outputColumns.length} output column(s)`, + 'QUERY_ERROR', + ); + } + return { ...item, column: outputColumns[asOrdinal - 1] }; + } + // 允许带别名前缀(`ORDER BY t.id` → `id`) + const bare = item.column.includes('.') ? item.column.split('.').pop()! : item.column; + if (outputColumns.length > 0 && !(item.column in firstRow) && !(bare in firstRow)) { + throw new DatabaseError( + `Unknown column "${item.column}" in ORDER BY of compound query. Output columns: ${outputColumns.join(', ')}`, + 'COLUMN_NOT_FOUND', + ); + } + return { ...item, column: item.column in firstRow ? item.column : bare }; + }); + return applyOrderBy(rows, resolved); + } + + /** + * 执行一个 SELECT 部件(含 UNION)。 + * + * @param purpose 透传给 `executeSelect` —— 作为写语句的输入行源时必须传 + * `'source'`,否则 `maxRowsPerQuery` 会在写入前静默截断行源(A29)。 + */ + private async executeSelectPart( + part: SelectStatement | SelectUnionStatement, + purpose: 'result' | 'source' = 'result', + ): Promise[]> { + if (part.type === 'SELECT_UNION') return this.executeSelectUnion(part, purpose); + return this.executeSelect(part, purpose); } /** 将 UNION 右侧行投影为左侧列结构(按位置取值) */ @@ -426,7 +671,14 @@ export class QueryExecutor { }; } - private async executeSelect(stmt: SelectStatement): Promise[]> { + /** + * @param purpose `'result'`(默认)= 结果交付给用户,受 `maxRowsPerQuery` 截断; + * `'source'` = 作为写语句的输入行源,**不得**截断(见下方说明)。 + */ + private async executeSelect( + stmt: SelectStatement, + purpose: 'result' | 'source' = 'result', + ): Promise[]> { const shape = this.analyzeSelect(stmt); const { hasGroupBy, hasAggregate, isJoinQuery, needsRawRows, orderByAlias, hasSelectAlias, limitPushdownSafe } = shape; let rows: Record[]; @@ -437,10 +689,19 @@ export class QueryExecutor { rows = isJoinQuery ? await this.executeJoinSelect(stmt, subRows.map((row) => this.prefixRow(row, stmt.alias ?? ''))) : subRows; - if (!isJoinQuery && stmt.where && Object.keys(stmt.where).length > 0) { - // 非 JOIN:WHERE 在 executor 端过滤(子查询结果不经引擎) - stmt.where = await this.resolveSubqueries(stmt.where); - rows = rows.filter((row) => matchWhere(row, stmt.where)); + if (!isJoinQuery) { + // v0.8.0(A36):派生表行源不带任何别名前缀,因此 `d.id` 形态的引用 + // 必须剥离前缀(与非 JOIN 单表路径同一规则)。 + // + // 此前这里**不做**归一化:`SELECT d.id FROM (SELECT id, g FROM t) AS d` + // 直接拿 `d.id` 去投影,行里只有 `id` → 静默返回 `[]`; + // 而同一查询写成 `SELECT id ...` 却正确 —— 同一行源两种写法结论相反。 + this.normalizeUnprefixedReferences(stmt, [stmt.alias ?? stmt.from]); + if (stmt.where && Object.keys(stmt.where).length > 0) { + // 非 JOIN:WHERE 在 executor 端过滤(子查询结果不经引擎) + stmt.where = await this.resolveSubqueries(stmt.where); + rows = rows.filter((row) => matchWhere(row, stmt.where)); + } } } else if (!stmt.from && !isJoinQuery) { // v0.4.0: 无表查询(SELECT 1 / SELECT 'lit')— 单行空上下文,常量列投影 @@ -449,28 +710,9 @@ export class QueryExecutor { // JOIN 路径:行带表别名前缀(如 'd.id'),WHERE 保持原名不剥离 rows = await this.executeJoinSelect(stmt); } else { - // 非 JOIN 路径:规范化 WHERE 字段名(剥离主表别名前缀,修复 WHERE u.age > 20) - if (stmt.where && Object.keys(stmt.where).length > 0) { - stmt.where = this.normalizeWhereColumns(stmt.where, [stmt.alias ?? stmt.from]); - } - // v0.4.0: ORDER BY / GROUP BY 带表前缀同样剥离(如 ORDER BY u.age) + // 非 JOIN 路径:行键不带别名前缀 → 统一归一化引用 const mainAliases = [stmt.alias ?? stmt.from].filter(Boolean); - if (stmt.orderBy && stmt.orderBy.length > 0) { - stmt.orderBy = stmt.orderBy.map((o) => ({ ...o, column: this.stripAlias(o.column, mainAliases) })); - } - if (stmt.groupBy && stmt.groupBy.length > 0) { - stmt.groupBy = stmt.groupBy.map((c) => this.stripAlias(c, mainAliases)); - } - // v0.4.0: SELECT 列带表前缀剥离(SELECT u.name → name,行键无前缀) - stmt.columns = stmt.columns.map((c) => { - if (c === '*' || /^(COUNT|SUM|AVG|MIN|MAX)\(/i.test(c) || /^\s*CASE\b/i.test(c) || /^'/.test(c)) return c; - const m = c.match(/^(.+?)\s+AS\s+(\w+)$/i); - if (m) { - const stripped = this.stripAlias(m[1].trim(), mainAliases); - return stripped === m[1].trim() ? c : `${stripped} AS ${m[2]}`; - } - return this.stripAlias(c, mainAliases); - }); + this.normalizeUnprefixedReferences(stmt, mainAliases); // WHERE 含关联子查询($col 引用外层行)→ 逐行绑定上下文求值 if (stmt.where && this.hasCorrelatedRefs(stmt.where)) { @@ -506,7 +748,20 @@ export class QueryExecutor { } if (hasGroupBy) rows = this.executeGroupBy(rows, stmt); - if (stmt.distinct) rows = this.executeDistinct(rows); + + // v0.8.0(A27):DISTINCT 作用于**输出列**,因此必须发生在投影之后。 + // + // 此前顺序是 DISTINCT → 投影,于是 `SELECT DISTINCT dept AS d FROM e` + // 对 `{id, dept, v}` 原始行去重(4 行互不相同)→ 再投影成 `{d}` → 返回 4 行 + // `a,a,b,b`;而 `SELECT DISTINCT dept FROM e` 返回 2 行 —— 加一个别名就改变了 + // 去重语义。SQL 标准中 DISTINCT 作用于 SELECT 的输出列。 + // + // 例外(保留投影前位置):`SELECT DISTINCT dept FROM e ORDER BY v` —— `v` + // 不在输出列里,去重后它就不存在了,无法再排序。SQL 标准禁止这种写法, + // 但既有实现支持它(语义是"排序后按输出列去重"),因此保留: + // 此时按 `DISTINCT → ORDER BY` 的顺序执行(见下方分支)。 + const distinctBeforeProjection = !!stmt.distinct && this.distinctNeedsPreProjectionSort(stmt); + if (distinctBeforeProjection) rows = this.executeDistinct(rows); if (stmt.having && Object.keys(stmt.having).length > 0) { // v0.4.0 修复: HAVING 中的标量子查询(HAVING SUM(o.amount) > (SELECT AVG(...)))需先解析 stmt.having = await this.resolveSubqueries(stmt.having); @@ -521,7 +776,30 @@ export class QueryExecutor { } rows = rows.filter((row) => matchWhere(row, stmt.having!)); } - if (stmt.orderBy && stmt.orderBy.length > 0) rows = applyOrderBy(rows, stmt.orderBy); + // v0.8.0(A23):分组行的输出投影必须在 HAVING **之后** —— HAVING 可能引用 + // 未出现在 SELECT 里的聚合(`SELECT g FROM t GROUP BY g HAVING SUM(n) > 25`), + // 那些内部键要到过滤完成后才能丢弃。 + if (hasGroupBy) rows = this.projectGroupedRows(rows, stmt); + + /** + * ORDER BY 的应用时机。 + * + * 排序必须发生在"排序列仍然存在"的阶段,因此分两种情况: + * - 排序键全部是**输出列**(或 SELECT 别名,别名在投影后才存在 → 走下方 + * 投影后的 `orderByAlias` 分支)→ 投影后再排序(顺序更自然,也让 + * `SELECT DISTINCT ... ORDER BY <输出列>` 得到"先去重再排序"的标准语义); + * - 排序键引用了**未出现在 SELECT 里的列**(`SELECT name FROM t ORDER BY id`, + * 以及 `SELECT DISTINCT dept FROM e ORDER BY v`)→ 必须在投影前排序, + * 否则该列已被丢弃、排序无从进行。 + * + * 此前无条件在投影前排序(`applyOrderBy` 一行),于是 A27 的 DISTINCT + * 必然发生在投影前 —— 两者互为因果,必须一起修正。 + */ + const orderNeedsRawColumns = !!stmt.orderBy && stmt.orderBy.length > 0 + && (distinctBeforeProjection || !this.orderByReferencesOutputColumns(stmt)); + if (stmt.orderBy && stmt.orderBy.length > 0 && !orderByAlias && orderNeedsRawColumns) { + rows = applyOrderBy(rows, stmt.orderBy); + } // v0.8.0 根治:投影前校验列引用存在性(此前未知列静默产出 {} 行)。 // // 实测缺陷:`SELECT bogus FROM t`(4 行表)返回 `[{},{},{},{}]`, @@ -541,6 +819,8 @@ export class QueryExecutor { && !(stmt.columns.length === 1 && stmt.columns[0] === '*')) { rows = rows.map((row) => this.projectRow(row, stmt.columns)); } + // v0.8.0(A27):DISTINCT 的规范位置 —— 投影之后(作用于输出列) + if (stmt.distinct && !distinctBeforeProjection) rows = this.executeDistinct(rows); // v0.3.3: ORDER BY 别名 → 投影后才存在,需在投影后重新排序 if (orderByAlias && stmt.orderBy && stmt.orderBy.length > 0) { rows = applyOrderBy(rows, stmt.orderBy); @@ -555,8 +835,16 @@ export class QueryExecutor { rows = rows.slice(offset, offset + limit); } - // 全局行数上限保护 - if (this.maxRowsPerQuery > 0 && rows.length > this.maxRowsPerQuery) { + // 全局行数上限保护。 + // + // v0.8.0(A29 根治):只在"结果交付给用户"时截断;作为写语句的输入行源 + //(`INSERT INTO dst SELECT ...`)时**不截断** —— 否则用户设置的 + // `maxRowsPerQuery` 会静默减少写入行数: + // maxRowsPerQuery = 2 时 `INSERT INTO dst SELECT id FROM src`(src 有 4 行) + // 会只写入 2 行并返回成功(实测)。这不是"限制查询规模",而是**静默丢数据**。 + // 写路径由上层的 `assertWithinRowLimit` 显式报错(而不是静默截断), + // 二者配合才能同时满足"保护内存"与"不丢数据"。 + if (purpose === 'result' && this.maxRowsPerQuery > 0 && rows.length > this.maxRowsPerQuery) { rows = rows.slice(0, this.maxRowsPerQuery); } @@ -776,82 +1064,284 @@ export class QueryExecutor { // ---- GROUP BY ---- + /** + * 分组聚合。 + * + * v0.8.0(A22/A23)两项行为修正,都源于**分组语义只有一个定义**这一原则: + * + * 1. **GROUP BY 可用 SELECT 别名**(A22)。 + * SQL 标准允许 `SELECT g AS grp, COUNT(*) FROM t GROUP BY grp`。 + * 此前 `grp` 直接当列名去 `row['grp']` 取到 undefined → 全部行落入同一组; + * 更糟的是后续投影阶段发现 `g` 不在输出行里,抛 + * `COLUMN_NOT_FOUND Unknown column "g" in SELECT list` —— 用户看到的是 + * 一个与真正原因(GROUP BY 引用别名)无关的错误。 + * 这里先把 GROUP BY 项解析为**基列**(别名 → 其源表达式),再做分组与输出。 + * + * 2. **HAVING 可用未出现在 SELECT 里的聚合**(A23)。 + * `SELECT g FROM t GROUP BY g HAVING SUM(n) > 25` 是标准写法。 + * 此前聚合只在 `stmt.columns` 上计算,`SUM(n)` 从未被求值 → + * HAVING 的键 `SUM(n)` 在行里不存在 → matchWhere 取到 undefined → + * UNKNOWN → **空结果**(`HAVING SUM(n) > 25` 返回 [],而 MAX(n) > 25 同样 [])。 + * 现在把 SELECT 列与 HAVING 里出现的聚合**并集**一起算进分组行,再交给 + * HAVING 过滤。多算的聚合只影响中间行,最终输出仍严格按 SELECT 列投影, + * 因此不会泄漏额外列。 + */ private executeGroupBy(rows: Record[], stmt: SelectStatement): Record[] { + // 1) GROUP BY 项 → 基列(别名解析,A22) + const groupColumns = this.resolveGroupByColumns(stmt); const groups = new Map[]>(); for (const row of rows) { // v0.7.4: 类型安全键编码 —— 此前 String(row[col] ?? 'null') 使 // null 与字符串 'null' 合并为一组(GROUP BY 静默少组) - const key = stmt.groupBy!.map((col) => encodeGroupKey(row[col])).join('\x1f'); + const key = groupColumns + .map((col) => encodeValueKey(resolveColumnValue(row, col, { strict: true, context: 'GROUP BY' }))) + .join('\x1f'); if (!groups.has(key)) groups.set(key, []); groups.get(key)!.push(row); } + + // 2) 需要计算的聚合表达式 = SELECT 列 ∪ HAVING 中的聚合(A23) + const aggregateExprs = this.collectAggregateExpressions(stmt); + const result: Record[] = []; // v0.4.0: 聚合表达式键 → 输出键 映射(HAVING SUM(...) 引用表达式时归一为别名键) const aliasMap = new Map(); for (const groupRows of groups.values()) { + const first = groupRows[0]; const aggregated: Record = {}; - for (const col of stmt.groupBy!) aggregated[col] = groupRows[0][col]; + // 分组列以基列名输出(`GROUP BY grp` 引用别名时输出基列 `g`, + // 与 SELECT 列表里 `g AS grp` 的投影可正确对应) + for (const col of groupColumns) { + aggregated[col] = resolveColumnValue(first, col, { strict: true, context: 'GROUP BY' }); + } + + // 先算聚合(含仅 HAVING 引用的),统一以 exprKey 与输出键写入 + for (const agg of aggregateExprs) { + const value = agg.distinct + ? this.computeDistinctAggregate(agg.func, groupRows, agg.arg) + : this.computeAggregate(agg.func, groupRows, agg.arg); + if (agg.outputKey !== agg.exprKey) aliasMap.set(agg.exprKey, agg.outputKey); + // 同时以表达式原文与输出键写入:HAVING 既可能写 `SUM(n)` 也可能写别名 + aggregated[agg.exprKey] = value; + if (agg.outputKey !== agg.exprKey) aggregated[agg.outputKey] = value; + } + + // 再按 SELECT 列表补齐非聚合列(分组列已写;非分组列取组内首行 — + // 这是既有的宽松语义,SQL 标准禁止但此处保持兼容) for (const colExpr of stmt.columns) { if (colExpr === '*') continue; - const m = colExpr.match(/^(COUNT|SUM|AVG|MIN|MAX)\((.+?)\)(?:\s+AS\s+(\w+))?$/i); - if (m) { - const [, func, arg, alias] = m; - const value = this.computeAggregate(func.toUpperCase(), groupRows, arg.trim()); - const exprKey = `${func.toUpperCase()}(${arg.trim()})`; - const outputKey = alias || colExpr; - if (outputKey !== exprKey) aliasMap.set(exprKey, outputKey); - aggregated[outputKey] = value; - } else if (/^\s*CASE\b/i.test(colExpr)) { + const agg = parseAggregateExpression(colExpr); + if (agg) continue; // 已在上面写入 + if (/^\s*CASE\b/i.test(colExpr)) { // v0.3.2: 非聚合的 CASE WHEN 列取组内第一行求值 const expr = parseCaseExpression(colExpr); - aggregated[expr?.alias ?? colExpr] = expr ? evaluateCase(expr, groupRows[0]) : null; - } else if (!stmt.groupBy!.includes(colExpr)) { - aggregated[colExpr] = groupRows[0][colExpr]; + const key = expr?.alias ?? colExpr; + if (!(key in aggregated)) { + aggregated[key] = expr ? evaluateCase(expr, first) : null; + } + continue; } + const aliasMatch = colExpr.match(/^(.+?)\s+AS\s+(\w+)$/i); + if (aliasMatch) continue; // 别名列由投影阶段处理 + // 投影阶段用的键:优先"剥离别名前缀后的基列"(SELECT t.n 的输出键是 n) + const base = bareReference(colExpr); + if (base in aggregated) continue; + if (colExpr in aggregated) continue; + aggregated[base] = resolveColumnValue(first, colExpr, { strict: true, context: 'SELECT list' }); } + + // 注意:**不在这里**做输出投影。 + // + // 为 HAVING 多算的聚合(`SELECT g ... HAVING SUM(n) > 25` 需要 SUM(n)) + // 必须在 HAVING 求值时仍然可见,而 HAVING 在 GROUP BY 之后、ORDER BY 之前 + // 执行(见 executeSelect 的阶段顺序)。若在此处就把行收缩为 SELECT 输出键, + // HAVING 的 `SUM(n)` 会取到 undefined → UNKNOWN → **空结果** + // (实测:加投影后 `HAVING SUM(n) > 25` 又变回 [])。 + // 因此投影统一由 `projectGroupedRows` 在 HAVING 之后、ORDER BY 之前完成。 result.push(aggregated); } (stmt as unknown as { _aggAliasMap?: Map })._aggAliasMap = aliasMap; return result; } + /** 对全部分组行做输出投影(HAVING 之后调用) */ + private projectGroupedRows(rows: Record[], stmt: SelectStatement): Record[] { + const groupColumns = this.resolveGroupByColumns(stmt); + return rows.map((row) => this.projectGroupedRow(row, groupColumns, stmt.columns)); + } + + /** + * 把分组行收缩为 SELECT 列表要求的输出列(保持 SELECT 顺序)。 + * + * GROUP BY 路径不做通用的 `projectRow`(那会丢掉聚合值),因此需要这一层显式投影。 + * 处理四类列: + * - `*`:保留分组行全部键(`SELECT * ... GROUP BY g` 的既有语义); + * - 聚合表达式:键取 `parseAggregateExpression` 的 outputKey; + * - `expr AS alias`:输出 alias,值从分组行按 expr 取(含二义性由取值函数处理); + * - 裸列引用:输出剥离别名前缀后的基列名。 + */ + private projectGroupedRow( + aggregated: Record, + groupColumns: string[], + columns: string[], + ): Record { + if (columns.some((c) => c === '*')) return aggregated; + + const output: Record = {}; + // 分组列按 SELECT 顺序先输出(它们也是各组的标识列) + for (const col of groupColumns) { + if (col in aggregated) output[col] = aggregated[col]; + } + for (const colExpr of columns) { + if (colExpr === '*') continue; + const agg = parseAggregateExpression(colExpr); + if (agg) { + // outputKey 与 exprKey 在聚合阶段都被写入,取 outputKey(别名优先) + output[agg.outputKey] = aggregated[agg.outputKey]; + continue; + } + const caseExpr = /^\s*CASE\b/i.test(colExpr) ? parseCaseExpression(colExpr) : null; + if (caseExpr) { + const key = caseExpr.alias ?? colExpr; + output[key] = aggregated[key]; + continue; + } + const aliasMatch = colExpr.match(/^(.+?)\s+AS\s+(\w+)$/i); + if (aliasMatch) { + output[aliasMatch[2]] = aggregated[aliasMatch[2]]; + continue; + } + const base = bareReference(colExpr); + output[base] = base in aggregated ? aggregated[base] : aggregated[colExpr]; + } + return output; + } + + /** + * GROUP BY 项解析为基列:`GROUP BY grp`(grp 是 SELECT 别名)→ `g`。 + * + * 只解析"SELECT 列表里带 AS 别名"与"CASE ... AS 别名"这两种可追踪形态; + * 其余原样返回(真正的列名或表达式)。同名歧义时保持原样 —— 后续 + * `resolveColumnValue` 的 strict 模式会给出明确的 COLUMN_NOT_FOUND/歧义错误。 + */ + private resolveGroupByColumns(stmt: SelectStatement): string[] { + const aliases = new Map(); + for (const col of stmt.columns) { + const m = col.match(/^(.+?)\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)$/i); + if (m) aliases.set(m[2], m[1].trim()); + } + return stmt.groupBy!.map((col) => { + const target = aliases.get(col.trim()); + if (target === undefined) return col; + // 别名指向另一个聚合表达式时不能当作分组列(`SELECT COUNT(*) AS c ... GROUP BY c`) + return parseAggregateExpression(target) ? col : target; + }); + } + + /** + * 收集本次分组需要计算的全部聚合表达式(SELECT 列 ∪ HAVING),按 exprKey 去重。 + * + * 同时扫描 HAVING 是 A23 的核心:`HAVING SUM(n) > 25` 里的 `SUM(n)` 必须被求值, + * 否则 HAVING 阶段取不到该键。 + */ + private collectAggregateExpressions( + stmt: SelectStatement, + ): Array<{ func: string; arg: string; outputKey: string; distinct: boolean; exprKey: string }> { + const collected = new Map(); + const add = (expr: string): void => { + const agg = parseAggregateExpression(expr); + if (!agg) return; + const existing = collected.get(agg.exprKey); + // SELECT 列优先(它带别名);HAVING 只是补充 + if (!existing) collected.set(agg.exprKey, agg); + }; + + for (const col of stmt.columns) add(col); + for (const expr of collectAggregateExpressionsInWhere(stmt.having)) add(expr); + return [...collected.values()]; + } + /** * 计算单个聚合值。 * * v0.8.0: 返回类型放宽为 unknown —— SUM/AVG/MIN/MAX 对空集返回 null(SQL 标准), * COUNT 仍返回 number。最小/最大改为单次遍历(不再展开实参,消除栈溢出)。 */ - private computeAggregate(func: string, rows: Record[], col: string): number | null { + private computeAggregate(func: string, rows: Record[], arg: string): number | null { // v0.3.2: 聚合参数支持 CASE WHEN 表达式(如 SUM(CASE WHEN age > 18 THEN 1 ELSE 0 END)) - const caseExpr = /^\s*CASE\b/i.test(col) ? parseCaseExpression(col) : null; - // v0.4.0: COUNT(DISTINCT col) 等去重聚合 - const distinctArg = !caseExpr && /^\s*DISTINCT\s+/i.test(col); - const argCol = distinctArg ? col.replace(/^\s*DISTINCT\s+/i, '').trim() : col; + const caseExpr = /^\s*CASE\b/i.test(arg) ? parseCaseExpression(arg) : null; + // v0.4.0: COUNT(DISTINCT col) —— distinct 由 parseAggregateExpression 剥离后传入 + const argCol = arg.trim(); + // v0.8.0(A25):列引用统一走 resolveColumnValue(别名前缀剥离 + 后缀回退)。 + // 取不到值时按 COUNT(*) 之外的情形报 COLUMN_NOT_FOUND,而不是静默计 0: + // 此前 `COUNT(t.n)` 返回 0 且无任何报错,用户会以为"表里没有非空 n"。 const rawValues = rows - .map((r) => (caseExpr ? evaluateCase(caseExpr, r) : r[argCol])) + .map((r) => (caseExpr + ? evaluateCase(caseExpr, r) + : argCol === '*' + ? r + : resolveColumnValue(r, argCol, { strict: true, context: `aggregate ${func}(${argCol})` }))) .filter((v) => v !== null && v !== undefined); - // v0.4.0: COUNT 对原始值去重(任意类型);数值聚合在类型转换后去重 if (func === 'COUNT') { if (argCol === '*') return rows.length; - if (distinctArg) { - return new Set(rawValues.map((v) => (typeof v === 'object' ? JSON.stringify(v) : String(v)))).size; - } return rawValues.length; } - const nums = rawValues.map(Number); + const nums = rawValues.map((v) => Number(v)); // v0.8.0: 数值型聚合一律走单次遍历归约。 // // 此前 MIN/MAX 用 `Math.min(...distinctNums)` 展开实参:20 万行同组直接 // `RangeError: Maximum call stack size exceeded`(原生错误,调用方无法按 code 分类)。 // 同时把"空集/全 NULL"的返回值从 0 改为 null —— SQL 标准中 SUM/AVG/MIN/MAX // 对空集返回 NULL,返回 0 会让 `SUM(x) = 0` 与"没有数据"不可区分。 - const distinctNums = distinctArg ? Array.from(new Set(nums)) : nums; + switch (func) { + case 'SUM': return reduceNumeric(nums, 'SUM'); + case 'AVG': return reduceNumeric(nums, 'AVG'); + case 'MIN': return reduceNumeric(nums, 'MIN'); + case 'MAX': return reduceNumeric(nums, 'MAX'); + default: return null; + } + } + + /** + * DISTINCT 聚合(`COUNT(DISTINCT col)` / `SUM(DISTINCT col)`)。 + * + * v0.8.0(A25):独立成函数而不是在 computeAggregate 里加分支 —— 去重键 + * 必须用 `encodeValueKey`(类型安全),而"对原始值去重"(COUNT)与 + * "对数值化后去重"(SUM)用的键不同,混在一个函数里正是此前 + * `String(v)` 与 `JSON.stringify(v)` 两套编码并存的原因。 + */ + private computeDistinctAggregate( + func: string, + rows: Record[], + arg: string, + ): number | null { + const values = rows + .map((r) => resolveColumnValue(r, arg, { strict: true, context: `aggregate ${func}(DISTINCT ${arg})` })) + .filter((v) => v !== null && v !== undefined); + if (func === 'COUNT') { + return new Set(values.map((v) => encodeValueKey(v))).size; + } + const nums = values.map((v) => Number(v)); + // 去重**用键判定、用原值参与计算**。 + // + // 不能写成 `Array.from(new Set(nums.map(encodeValueKey))).map(Number)`: + // encodeValueKey 是类型前缀编码(`10` → `'n10'`),`Number('n10')` = NaN —— + // 实测 `SUM(DISTINCT v)` 返回 NaN(应为 10/20)。 + // 键只用于判等,参与归约的必须是原始数值。 + const seen = new Set(); + const distinctNums: number[] = []; + for (const n of nums) { + const key = encodeValueKey(n); + if (seen.has(key)) continue; + seen.add(key); + distinctNums.push(n); + } switch (func) { case 'SUM': return reduceNumeric(distinctNums, 'SUM'); case 'AVG': return reduceNumeric(distinctNums, 'AVG'); case 'MIN': return reduceNumeric(distinctNums, 'MIN'); case 'MAX': return reduceNumeric(distinctNums, 'MAX'); - default: return 0; + default: return null; } } @@ -861,7 +1351,7 @@ export class QueryExecutor { const seen = new Set(); return rows.filter((row) => { // v0.7.4: 类型安全键编码(null 与 'null' 字符串、'\0' 分离) - const key = Object.values(row).map(encodeGroupKey).join('\x1f'); + const key = Object.values(row).map(encodeValueKey).join('\x1f'); if (seen.has(key)) return false; seen.add(key); return true; @@ -896,7 +1386,8 @@ export class QueryExecutor { // INSERT INTO ... SELECT ...(v0.3.0) if (stmt.select) { - const selectRows = await this.executeSelectPart(stmt.select); + // A29:行源不得被 maxRowsPerQuery 截断(截断即静默丢写入行) + const selectRows = await this.executeSelectPart(stmt.select, 'source'); // v0.4.0 修复:源列顺序不能依赖行键(validateRow 会跳过 undefined 导致行键缺失/乱序)。 // 以 SELECT 列列表 / 源表 schema 列顺序为准,按位置对齐目标列,缺列不填。 let srcCols: string[] = []; @@ -921,20 +1412,59 @@ export class QueryExecutor { return mapped; }); // v0.8.0(B-1): 在任何写入之前执行统一校验(未知列/类型/maxLength/min/max/required) + this.assertWithinRowLimit(rows.length); await this.engine.validatePayload?.(stmt.into, rows, 'insert'); return this.engine.insert(stmt.into, rows); } - const rows: Record[] = (stmt.values ?? []).map((vals: unknown[]) => { + const rows: Record[] = (stmt.values ?? []).map((vals: unknown[], tupleIndex: number) => { + // v0.8.0(A30):值的个数不得多于目标列。 + // + // 此前多出来的值被**静默丢弃**:`INSERT INTO t (id, n) VALUES ('9', 1, 'extra')` + // 报成功且只写入 (id,n)。用户以为第三个值进了某一列(或者至少会被提示), + // 实际上它消失了 —— 与 A17 同一类"写路径静默丢数据"。反方向的"值少于列" + // 是合法的(缺列走 default/NULL),因此只拒绝多于。 + if (vals.length > colNames.length) { + throw new DatabaseError( + `INSERT has ${vals.length} value(s) for ${colNames.length} column(s) in table "${stmt.into}"` + + ` (row ${tupleIndex + 1}); too many values`, + 'VALIDATION_ERROR', + { table: stmt.into, values: vals.length, columns: colNames.length }, + ); + } const row: Record = {}; for (let i = 0; i < colNames.length; i++) { if (i < vals.length) row[colNames[i]] = vals[i]; } return row; }); // v0.8.0(B-1): 同上 —— 校验先于任何副作用,多行批量整体判定 + this.assertWithinRowLimit(rows.length); await this.engine.validatePayload?.(stmt.into, rows, 'insert'); return this.engine.insert(stmt.into, rows); } + /** + * v0.8.0(A29):写路径的行数上限保护。 + * + * `maxRowsPerQuery` 此前只在 SELECT 的返回处生效(`executeSelect` 末尾切片), + * 而 `INSERT INTO dst SELECT * FROM huge_src` 的**中间结果集**完全不受约束 —— + * 它由 `executeSelectPart` 直接产出并逐行写入,既不切片也不报错。 + * 于是"防止一次查询把浏览器内存打满"这一配置项在最容易打满内存的路径上失效。 + * + * 这里选择**报错**而不是静默截断:静默只写一部分行会让用户以为全部写完 + * (又一次"写路径静默丢数据")。错误里给出上限值与来源,便于用户改配置或 + * 改写查询。 + */ + private assertWithinRowLimit(count: number): void { + if (this.maxRowsPerQuery > 0 && count > this.maxRowsPerQuery) { + throw new DatabaseError( + `Statement would write ${count} rows, exceeding maxRowsPerQuery (${this.maxRowsPerQuery}).` + + ' Narrow the source query or raise the limit.', + 'QUERY_ERROR', + { rows: count, maxRowsPerQuery: this.maxRowsPerQuery }, + ); + } + } + private async executeUpdate(stmt: UpdateStatement): Promise { // v0.7.4: 先解析 WHERE 子查询 —— 此前直接 compileStatement 调引擎: // 引擎层 matchWhere 的 $in/$nin 遇未解析的 $subquery 对象恒 false → @@ -1157,6 +1687,64 @@ export class QueryExecutor { return engine.vacuum(); } + /** + * SELECT 列表的**输出列名**集合(投影后行里会出现的键)。 + * + * 与 `projectRow` 的键规则保持一致: + * - `*` → 未知(返回 `null` 表示"无法判定",调用方按"包含"处理,避免误判需要原始列); + * - `expr AS alias` → `alias`; + * - 聚合 `FUNC(arg) [AS alias]` → `alias` 或表达式原文; + * - CASE `... AS alias` → `alias`; + * - 字符串/数字常量列 → 表达式原文; + * - 裸列引用 → 剥离别名前缀后的列名。 + */ + private outputColumnNames(stmt: SelectStatement): Set | null { + const names = new Set(); + for (const col of stmt.columns) { + if (col === '*') return null; + const agg = parseAggregateExpression(col); + if (agg) { + names.add(agg.outputKey); + continue; + } + const caseExpr = /^\s*CASE\b/i.test(col) ? parseCaseExpression(col) : null; + if (caseExpr) { + names.add(caseExpr.alias ?? col); + continue; + } + const aliasMatch = col.match(/^(.+?)\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)$/i); + if (aliasMatch) { + names.add(aliasMatch[2]); + continue; + } + names.add(bareReference(col)); + } + return names; + } + + /** ORDER BY 的键是否全部能在**输出列**里找到(决定排序发生在投影前还是投影后) */ + private orderByReferencesOutputColumns(stmt: SelectStatement): boolean { + if (!stmt.orderBy || stmt.orderBy.length === 0) return true; + const outputs = this.outputColumnNames(stmt); + if (outputs === null) return true; // SELECT * + return stmt.orderBy.every((o) => outputs.has(bareReference(o.column))); + } + + /** + * DISTINCT 是否必须在投影**前**执行。 + * + * 仅当 ORDER BY 引用了不在输出列里的列时成立:`SELECT DISTINCT dept FROM e ORDER BY v` + * 需要先按 `v` 排序、再按输出列 `dept` 去重。若把 DISTINCT 放到投影后, + * `v` 已被丢弃,排序无从进行(会报 COLUMN_NOT_FOUND)。 + * + * SQL 标准禁止这种写法;此处保留既有语义(排序后去重),并把该例外显式记录, + * 而不是让 DISTINCT 的位置在所有情况下都"碰巧"由排序决定。 + */ + private distinctNeedsPreProjectionSort(stmt: SelectStatement): boolean { + if (!stmt.orderBy || stmt.orderBy.length === 0) return false; + return !this.orderByReferencesOutputColumns(stmt); + } + /** 列列表是否包含 CASE WHEN 表达式 */ private hasCaseColumn(columns: string[]): boolean { return columns.some((col) => /^\s*CASE\b/i.test(col)); @@ -1337,24 +1925,33 @@ export class QueryExecutor { // 无 GROUP BY 时的聚合计算 // =================================================================== - /** 检查 SELECT 列列表中是否包含聚合函数 */ + /** 检查 SELECT 列列表中是否包含聚合函数(与执行路径共用同一解析器) */ private _hasAggregateColumn(columns: string[]): boolean { - return columns.some((col) => /^(COUNT|SUM|AVG|MIN|MAX)\(/i.test(col)); + return columns.some((col) => isAggregateExpression(col)); } - /** 计算单行聚合结果(无 GROUP BY) */ + /** + * 计算单行聚合结果(无 GROUP BY)。 + * + * v0.8.0(A25):聚合识别与取值改为与 GROUP BY 路径**共用** + * `parseAggregateExpression` / `resolveColumnValue` —— 此前这里有第二份正则, + * 于是 `COUNT (n)`(函数名后有空格)在"是否聚合"判定与"如何求值"两处结论不同。 + */ private computeSingleAggregate(rows: Record[], stmt: SelectStatement): Record { const result: Record = {}; for (const colExpr of stmt.columns) { if (colExpr === '*') continue; - const m = colExpr.match(/^(COUNT|SUM|AVG|MIN|MAX)\((.+?)\)(?:\s+AS\s+(\w+))?$/i); - if (m) { - const [, func, arg, alias] = m; - result[alias || colExpr] = this.computeAggregate(func.toUpperCase(), rows, arg.trim()); - } else { - // 非聚合列取第一行的值 - result[colExpr] = rows.length > 0 ? rows[0][colExpr] : null; + const agg = parseAggregateExpression(colExpr); + if (agg) { + result[agg.outputKey] = agg.distinct + ? this.computeDistinctAggregate(agg.func, rows, agg.arg) + : this.computeAggregate(agg.func, rows, agg.arg); + continue; } + // 非聚合列取第一行的值(严格取值:未知列报错而非静默 null) + result[colExpr] = rows.length > 0 + ? resolveColumnValue(rows[0], colExpr, { strict: false, context: 'SELECT list' }) + : null; } return result; } @@ -1363,6 +1960,44 @@ export class QueryExecutor { // 关联子查询 / 别名规范化(v0.3.0) // =================================================================== + /** + * 归一化"行键不带前缀"的查询中的所有引用 —— 剥离表别名前缀。 + * + * v0.8.0(A36):抽成单一实现,因为**两条**路径需要同一规则: + * - 非 JOIN 单表(行键是裸列名,`WHERE u.age` 要变成 `age`); + * - 派生表非 JOIN(`FROM (SELECT ...) AS d`,行键来自子查询投影,同样无前缀)。 + * 此前只有前者做归一化,后者完全没做 → `SELECT d.id FROM (...) AS d` 静默空结果, + * 而同义的 `SELECT id FROM (...) AS d` 正确。 + * + * 覆盖 WHERE(含 `$col` 嵌套引用)/ ORDER BY / GROUP BY / SELECT 四类引用。 + * 聚合表达式与 CASE 表达式**整体跳过**(其内部的列引用由各自的求值器处理, + * 而它们的求值器现在都走统一的 `resolveColumnValue`,本身支持前缀)。 + */ + private normalizeUnprefixedReferences( + stmt: SelectStatement, + aliases: Array, + ): void { + const list = aliases.filter((a): a is string => !!a); + if (stmt.where && Object.keys(stmt.where).length > 0) { + stmt.where = this.normalizeWhereColumns(stmt.where, list); + } + if (stmt.orderBy && stmt.orderBy.length > 0) { + stmt.orderBy = stmt.orderBy.map((o) => ({ ...o, column: this.stripAlias(o.column, list) })); + } + if (stmt.groupBy && stmt.groupBy.length > 0) { + stmt.groupBy = stmt.groupBy.map((c) => this.stripAlias(c, list)); + } + stmt.columns = stmt.columns.map((c) => { + if (c === '*' || isAggregateExpression(c) || /^\s*CASE\b/i.test(c) || /^'/.test(c)) return c; + const m = c.match(/^(.+?)\s+AS\s+(\w+)$/i); + if (m) { + const stripped = this.stripAlias(m[1].trim(), list); + return stripped === m[1].trim() ? c : `${stripped} AS ${m[2]}`; + } + return this.stripAlias(c, list); + }); + } + /** 剥离主表别名前缀:'u.id' → 'id'(键与 $col 值均处理,支持多层别名) */ private normalizeWhereColumns(where: WhereCondition, aliases: string[]): WhereCondition { const normalized: WhereCondition = {}; diff --git a/src/sql/parser.ts b/src/sql/parser.ts index f0d6692..96c46cf 100644 --- a/src/sql/parser.ts +++ b/src/sql/parser.ts @@ -401,6 +401,7 @@ export class Parser { const right = this.parseSelect(); const unionStmt: SelectUnionStatement = { type: 'SELECT_UNION', left, right, all: all || undefined }; + this.adoptTrailingClauses(unionStmt, right); // 链式 UNION if (this.curTokenIs(TokenType.UNION)) { return this.parseUnionChain(unionStmt); @@ -418,12 +419,46 @@ export class Parser { } const right = this.parseSelect(); const unionStmt: SelectUnionStatement = { type: 'SELECT_UNION', left, right, all: all || undefined }; + this.adoptTrailingClauses(unionStmt, right); if (this.curTokenIs(TokenType.UNION)) { return this.parseUnionChain(unionStmt); } return unionStmt; } + /** + * v0.8.0(A26):把"最后一个 SELECT 上的 ORDER BY / LIMIT / OFFSET"上移到 + * 复合查询节点,并把这些子句从该 SELECT 上**移除**。 + * + * 为什么必须"移动"而不是"复制": + * - 语法上它们写在最后一个 SELECT 之后,但 SQL 语义作用于整个 UNION + * (`A UNION B LIMIT 3` 是"合并去重后取前 3 行",不是"B 取前 3 行"); + * - 若只复制不移除,LIMIT 会**应用两次** —— 正是 A5/A6 那类"两处都生效" + * 缺陷的同一个坑(B 先被截断,再对合并结果截断,结果可能少行)。 + * + * 由于 `parseSelect` 无法预知后面有没有 UNION(它在返回后才知道), + * 只能先让它照常解析、发现 UNION 时再回收 —— 这比"预读 UNION"简单且无回溯。 + */ + private adoptTrailingClauses( + unionStmt: SelectUnionStatement, + right: SelectStatement | SelectUnionStatement, + ): void { + // 链式 UNION 时右侧可能已是 UNION 节点,其尾部子句在创建时已上移 + if (right.type !== 'SELECT') return; + if (right.orderBy) { + unionStmt.orderBy = right.orderBy; + delete right.orderBy; + } + if (right.limit !== undefined) { + unionStmt.limit = right.limit; + delete right.limit; + } + if (right.offset !== undefined) { + unionStmt.offset = right.offset; + delete right.offset; + } + } + /** 解析 JOIN 子句列表 */ private parseJoinClauses(): import('../query/ast').JoinClause[] { const joins: import('../query/ast').JoinClause[] = []; diff --git a/src/table/validation.ts b/src/table/validation.ts index f498f35..8c1fd00 100644 --- a/src/table/validation.ts +++ b/src/table/validation.ts @@ -131,9 +131,19 @@ export function compileValidator(schema: TableSchema): RowValidator { assertNotNullConstraints(table, colName, colDef, value); if (value !== undefined && value !== null) { + assertJsonSafeNumber(table, colName, value); checkFieldType(table, colName, colDef.type, value, colDef); } - if (value !== undefined) validated[colName] = value; + // v0.8.0(B-1):缺列且无 default → 显式写入 NULL,**不能省略键**。 + // + // 此前 `if (value !== undefined) validated[colName] = value;` 会把这个列整个 + // 从行里删掉,于是存储行只含"有值的列",行形状取决于写入方式: + // INSERT INTO t (id, g) VALUES ('9','z') -- 行里没有 n 键 + // SELECT id, g, n FROM t WHERE id = '9' -- 抛 COLUMN_NOT_FOUND: n + // 而 `SELECT * FROM t` 却能正常返回(少一列而已)—— 同一行"有没有 n 列" + // 在读路径上给出相反结论。SQL 语义中"未提供值"就是 NULL,故统一补 null: + // 行始终包含全部 schema 列,投影/排序/三值比较才有统一前提。 + validated[colName] = value === undefined ? null : value; } return validated; } @@ -158,6 +168,7 @@ export function compileValidator(schema: TableSchema): RowValidator { if (value === undefined) continue; assertNotNullConstraints(table, colName, colDef, value); if (value !== null) { + assertJsonSafeNumber(table, colName, value); checkFieldType(table, colName, colDef.type, value, colDef); } values[colName] = value; @@ -172,6 +183,33 @@ export function compileValidator(schema: TableSchema): RowValidator { // 共享约束 // --------------------------------------------------------------------------- +/** + * 检查字段类型(含约束校验)。 + * + * v0.8.0(B-1):在 `checkFieldType`(table/schema.ts)之外**额外**拒绝 + * `NaN` 与 `±Infinity`。为什么必须有这一层: + * - JSON 无法表示它们 —— `JSON.stringify({ v: NaN })` 得到 `{"v":null}`, + * 于是 `INSERT ... VALUES (NaN)` 在内存引擎里是 NaN,落盘再读回来变成 null; + * 同一个库在"写后立即查"与"重启后查"得到不同结果,且没有任何提示。 + * - KVStore / Aria 的持久化路径都是 JSON,因此这是**所有**磁盘引擎的共性问题。 + * - 用户能构造出 NaN:`Number('abc')`、`0/0`、`parseFloat('x')` 等, + * 经由参数绑定进入写入路径。 + * 显式拒绝(`VALIDATION_ERROR`)让问题在写入时暴露,而不是变成读出来的 null。 + */ +function assertJsonSafeNumber( + table: string, + colName: string, + value: unknown, +): void { + if (typeof value !== 'number') return; + if (Number.isFinite(value)) return; + throw new DatabaseError( + `Column "${colName}" in table "${table}" cannot store ${Number.isNaN(value) ? 'NaN' : String(value)}:` + + ' it is not representable in JSON and would be silently read back as null', + 'VALIDATION_ERROR', + ); +} + /** * NOT NULL 类约束。 * diff --git a/tests/engine/kvstore-engine.test.ts b/tests/engine/kvstore-engine.test.ts index ec063d9..83e56d9 100644 --- a/tests/engine/kvstore-engine.test.ts +++ b/tests/engine/kvstore-engine.test.ts @@ -447,8 +447,16 @@ describe('KVStoreEngine', () => { expect(schema!.columns.phone).toBeDefined(); expect(schema!.columns.age).toBeUndefined(); const rows = await engine2.find('users', { table: 'users' }); - expect(rows[0].phone).toBeUndefined(); + // v0.8.0(B-1):ADD 出来的列在已有行上物化为 NULL。 + // + // 行校验契约保证"存储行含全部 schema 列",此前 ADD 不补齐,于是同一行 + // 在内存里没有 phone 键、落盘再读回(恢复路径走 validateRow)却有 + // `phone: null` —— 行的形状取决于是否重启过。此断言按新契约改为 null。 + expect(rows[0].phone).toBeNull(); + // DROP 掉的列必须真正消失(不是变 null):它是**不在 schema 里**的列, + // 若保留为 null 会让"已删列"看起来仍然存在。 expect(rows[0].age).toBeUndefined(); + expect('age' in rows[0]).toBe(false); await engine2.close(); }); diff --git a/tests/v080-query-layer.test.ts b/tests/v080-query-layer.test.ts new file mode 100644 index 0000000..07e4ff9 --- /dev/null +++ b/tests/v080-query-layer.test.ts @@ -0,0 +1,363 @@ +/** + * v0.8.0 回归套件 —— 查询层缺陷根治(A22/A23/A25/A26/A27/A29/A30/A36) + * ============================================================================ + * 本套件锁定 PLAN-v0.7.5.md §5 缺陷总账中查询层的 8 项修复。每一项都先给出 + * **修复前的实测错误输出**,再断言正确结果 —— 这样即使将来重构执行器, + * 失败的断言能直接告诉后来者"当初错在哪里"。 + * + * 参考数据(表 t): + * id=1 g='a' n=10 id=3 g='b' n=30 + * id=2 g='a' n=20 id=4 g='b' n=40 + */ +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]> = [ + ['memory', 'memory', {}], + ['disk', 'disk', {}], + ['hybrid', 'hybrid', {}], + ['aria', 'aria', { diskEngine: 'memory' }], +]; + +describe('[v0.8.0] 查询层缺陷根治', () => { + describe.each(ENGINES)('%s 引擎', (label, mode, extra) => { + let db: MetonaSqlark; + + beforeEach(async () => { + db = await MetonaSqlark.create({ + name: `ql-${label}-${Math.random().toString(36).slice(2)}`, + mode, + ...extra, + }); + await db.defineTable('t', { + id: { type: 'string', primaryKey: true }, + g: { type: 'string' }, + n: { type: 'number' }, + }); + await db.query("INSERT INTO t VALUES ('1','a',10),('2','a',20),('3','b',30),('4','b',40)"); + }); + + afterEach(async () => { + await db.close(); + }); + + // ------------------------------------------------------------------- + // A22: GROUP BY 可引用 SELECT 别名 + // ------------------------------------------------------------------- + it('A22 GROUP BY 引用 SELECT 别名', async () => { + // 修复前:抛 `COLUMN_NOT_FOUND Unknown column "g" in SELECT list` + // (GROUP BY grp 取 row['grp'] 得 undefined → 全部行并成一组 → + // 投影阶段又发现 g 不在输出行里,报出一个和真正原因无关的错误) + const rows = rowsOf>( + await db.query('SELECT g AS grp, COUNT(*) AS c FROM t GROUP BY grp ORDER BY grp'), + ); + expect(rows).toEqual([{ g: 'a', c: 2 }, { g: 'b', c: 2 }]); + // 输出键与"按基列分组"完全一致(同一查询的两种写法必须等价) + const byBase = rowsOf>( + await db.query('SELECT g, COUNT(*) AS c FROM t GROUP BY g ORDER BY g'), + ); + expect(rows).toEqual(byBase); + }); + + it('A22 GROUP BY 引用别名时不泄漏别名键', async () => { + const rows = rowsOf>( + await db.query('SELECT g AS grp FROM t GROUP BY grp ORDER BY grp'), + ); + // 输出的键必须是基列名 g(SELECT 列表里 g AS grp 的投影结果), + // 而不是内部的分组键 grp —— 否则同一查询在"有别名/无别名"两种写法下 + // 行形状不同。 + expect(rows).toEqual([{ g: 'a' }, { g: 'b' }]); + }); + + // ------------------------------------------------------------------- + // A23: HAVING 可引用未出现在 SELECT 里的聚合 + // ------------------------------------------------------------------- + it('A23 HAVING 引用未选中的聚合(SUM)', async () => { + // 修复前:返回 [](SUM(n) 从未被求值 → HAVING 的键在行里不存在 → UNKNOWN) + const rows = rowsOf>( + await db.query('SELECT g FROM t GROUP BY g HAVING SUM(n) > 25 ORDER BY g'), + ); + expect(rows).toEqual([{ g: 'a' }, { g: 'b' }]); + }); + + it('A23 HAVING 引用未选中的聚合(MAX)并正确过滤', async () => { + const rows = rowsOf>( + await db.query('SELECT g FROM t GROUP BY g HAVING MAX(n) > 25 ORDER BY g'), + ); + expect(rows).toEqual([{ g: 'b' }]); + }); + + it('A23 HAVING 的辅助聚合不得出现在输出行里', async () => { + // 为 HAVING 计算的 SUM(n) 是内部键:输出必须只有 g 一列。 + // (修复过程中曾泄漏成 {g, 'SUM(n)'} —— 多出用户没要求的输出列。) + const rows = rowsOf>( + await db.query('SELECT g FROM t GROUP BY g HAVING SUM(n) > 0 ORDER BY g'), + ); + expect(rows).toEqual([{ g: 'a' }, { g: 'b' }]); + expect(Object.keys(rows[0])).toEqual(['g']); + }); + + it('A23 HAVING 同时引用别名与未选中聚合', async () => { + const rows = rowsOf>( + await db.query('SELECT g AS grp, COUNT(*) AS c FROM t GROUP BY grp HAVING SUM(n) > 25 ORDER BY grp'), + ); + expect(rows).toEqual([{ g: 'a', c: 2 }, { g: 'b', c: 2 }]); + }); + + // ------------------------------------------------------------------- + // A25: 带表前缀的聚合参数 + // ------------------------------------------------------------------- + it('A25 COUNT(t.n) / SUM(t.n) 按列取值而非恒 0', async () => { + // 修复前:COUNT(t.n) → 0,SUM(t.n) → null(行键是 n,直接取 row['t.n'] 得 + // undefined,再被"过滤 NULL"剔除,且**不报错**) + expect(rowsOf(await db.query('SELECT COUNT(t.n) AS c FROM t'))).toEqual([{ c: 4 }]); + expect(rowsOf(await db.query('SELECT SUM(t.n) AS s FROM t'))).toEqual([{ s: 100 }]); + expect(rowsOf(await db.query('SELECT AVG(t.n) AS a FROM t'))).toEqual([{ a: 25 }]); + expect(rowsOf(await db.query('SELECT MIN(t.n) AS lo FROM t'))).toEqual([{ lo: 10 }]); + expect(rowsOf(await db.query('SELECT MAX(t.n) AS hi FROM t'))).toEqual([{ hi: 40 }]); + }); + + it('A25 分组聚合的带前缀参数', async () => { + const rows = rowsOf>( + await db.query('SELECT g, SUM(t.n) AS s FROM t GROUP BY g ORDER BY g'), + ); + expect(rows).toEqual([{ g: 'a', s: 30 }, { g: 'b', s: 70 }]); + }); + + it('A25 COUNT(DISTINCT t.n) 带前缀且类型安全去重', async () => { + expect(rowsOf(await db.query('SELECT COUNT(DISTINCT t.n) AS c FROM t'))).toEqual([{ c: 4 }]); + // 数值去重不得被编码串扰(曾把 encodeValueKey 的结果 Number() 回读成 NaN) + expect(rowsOf(await db.query('SELECT SUM(DISTINCT t.n) AS s FROM t'))).toEqual([{ s: 100 }]); + }); + + it('A25 聚合参数引用不存在的列 → COLUMN_NOT_FOUND(不静默计 0)', async () => { + await expect(db.query('SELECT COUNT(t.nope) AS c FROM t')).rejects.toMatchObject({ + code: 'COLUMN_NOT_FOUND', + }); + }); + + // ------------------------------------------------------------------- + // A26: UNION 尾部 ORDER BY / LIMIT 作用于整个复合结果 + // ------------------------------------------------------------------- + it('A26 UNION 尾部 ORDER BY 作用于复合结果', async () => { + // 修复前:只对右侧 SELECT 排序 → [{1},{2},{4},{3}] + const rows = rowsOf>( + await db.query("SELECT id FROM t WHERE g = 'a' UNION SELECT id FROM t WHERE g = 'b' ORDER BY id DESC"), + ); + expect(rows).toEqual([{ id: '4' }, { id: '3' }, { id: '2' }, { id: '1' }]); + }); + + it('A26 UNION 尾部 LIMIT 作用于复合结果', async () => { + // 修复前:只截断右侧 → 返回 4 行 + const rows = rowsOf>( + await db.query('SELECT id FROM t UNION SELECT id FROM t LIMIT 3'), + ); + expect(rows).toHaveLength(3); + }); + + it('A26 UNION LIMIT 只应用一次(不得双重截断)', async () => { + const limited = rowsOf>( + await db.query('SELECT id FROM t UNION ALL SELECT id FROM t LIMIT 5'), + ); + expect(limited).toHaveLength(5); + }); + + it('A26 UNION 去重 + OFFSET', async () => { + const rows = rowsOf>( + await db.query('SELECT id FROM t UNION SELECT id FROM t ORDER BY id LIMIT 2 OFFSET 1'), + ); + expect(rows).toEqual([{ id: '2' }, { id: '3' }]); + }); + + it('A26 UNION ORDER BY 引用不存在的列 → COLUMN_NOT_FOUND', async () => { + await expect(db.query('SELECT id FROM t UNION SELECT id FROM t ORDER BY nope')).rejects.toMatchObject({ + code: 'COLUMN_NOT_FOUND', + }); + }); + + // ------------------------------------------------------------------- + // A27: DISTINCT 作用于输出列(投影之后) + // ------------------------------------------------------------------- + it('A27 DISTINCT 列别名与裸列结果一致', async () => { + // 修复前:`SELECT DISTINCT g AS d` 返回 4 行 a,a,b,b + //(DISTINCT 作用在投影前的 {id,g,n} 原始行上,四行互不相同) + const aliased = rowsOf>(await db.query('SELECT DISTINCT g AS d FROM t ORDER BY d')); + expect(aliased).toEqual([{ d: 'a' }, { d: 'b' }]); + const bare = rowsOf>(await db.query('SELECT DISTINCT g FROM t ORDER BY g')); + expect(bare).toEqual([{ g: 'a' }, { g: 'b' }]); + }); + + it('A27 DISTINCT 多列仍按输出列去重', async () => { + const rows = rowsOf>(await db.query('SELECT DISTINCT g, n FROM t ORDER BY g, n')); + expect(rows).toHaveLength(4); + }); + + it('A27 DISTINCT + ORDER BY 输出列(先去重再排序)', async () => { + const rows = rowsOf>(await db.query('SELECT DISTINCT g FROM t ORDER BY g DESC')); + expect(rows).toEqual([{ g: 'b' }, { g: 'a' }]); + }); + + // ------------------------------------------------------------------- + // A30: INSERT 值个数与列个数不匹配 → 报错(此前静默丢弃多余值) + // ------------------------------------------------------------------- + it('A30 显式列名时值多于列 → PARSE_ERROR(解析期即可判定)', async () => { + // 修复前:`INSERT INTO t (id, g) VALUES ('9','z','LOST')` 报成功、'LOST' 消失。 + // 有了显式列名后 arity 无需 schema 即可判定,因此解析期就拦下 + //(v0.8.0 A16 的解析期校验;executor 侧对"未显式给列名"的情形兜底)。 + await expect( + db.query("INSERT INTO t (id, g) VALUES ('9', 'z', 'LOST')"), + ).rejects.toMatchObject({ code: 'PARSE_ERROR' }); + expect(rowsOf(await db.query("SELECT id FROM t WHERE id = '9'"))).toHaveLength(0); + }); + + it('A30 显式列名时值少于列 → PARSE_ERROR(缺列必须显式写出)', async () => { + // `INSERT INTO t (id, g) VALUES ('9')` 是列/值个数不匹配的写法: + // 用户想写的是 `INSERT INTO t (id) VALUES ('9')`。静默补 NULL 会让 + // 拼错列清单的语句"看起来成功",因此同样报错。 + await expect(db.query("INSERT INTO t (id, g) VALUES ('9')")).rejects.toMatchObject({ + code: 'PARSE_ERROR', + }); + }); + + it('A30 未显式列名时值多于列 → VALIDATION_ERROR(需要 schema 才能判定)', async () => { + // 不带列清单时个数要对照 schema 才能判断,由 executor 在拿到 schema 后校验 + await expect(db.query("INSERT INTO t VALUES ('9', 'z', 1, 'LOST')")).rejects.toMatchObject({ + code: 'VALIDATION_ERROR', + }); + expect(rowsOf(await db.query("SELECT id FROM t WHERE id = '9'"))).toHaveLength(0); + }); + + it('A30 值少于列仍合法(缺列走 default / NULL)', async () => { + await db.query("INSERT INTO t (id, g) VALUES ('9', 'z')"); + const rows = rowsOf>(await db.query("SELECT id, g, n FROM t WHERE id = '9'")); + expect(rows).toEqual([{ id: '9', g: 'z', n: null }]); + }); + + // ------------------------------------------------------------------- + // A36: 派生表别名引用 + // ------------------------------------------------------------------- + it('A36 派生表别名引用与裸列引用结果一致', async () => { + // 修复前:`SELECT d.id FROM (SELECT ...) AS d` 返回 [](d.id 未剥离前缀), + // 而同义的 `SELECT id FROM (...) AS d` 正确 + const aliased = rowsOf>( + await db.query("SELECT d.id FROM (SELECT id, g FROM t) AS d WHERE d.g = 'a' ORDER BY d.id"), + ); + expect(aliased).toEqual([{ id: '1' }, { id: '2' }]); + const bare = rowsOf>( + await db.query("SELECT id FROM (SELECT id, g FROM t) AS d WHERE g = 'a' ORDER BY id"), + ); + expect(aliased).toEqual(bare); + }); + + it('A36 派生表别名用于聚合与排序', async () => { + const rows = rowsOf>( + await db.query('SELECT COUNT(d.id) AS c FROM (SELECT id FROM t) AS d'), + ); + expect(rows).toEqual([{ c: 4 }]); + }); + + // ------------------------------------------------------------------- + // A35: JOIN 的 NULL 键 + // ------------------------------------------------------------------- + it('A35 JOIN 的 NULL 键不成立,且与右表有无索引无关', async () => { + await db.defineTable('l', { id: { type: 'string', primaryKey: true }, k: { type: 'string' } }); + await db.defineTable('ri', { id: { type: 'string', primaryKey: true }, k: { type: 'string', index: true } }); + await db.defineTable('rn', { id: { type: 'string', primaryKey: true }, k: { type: 'string' } }); + await db.query("INSERT INTO l VALUES ('l1','x'),('l2',NULL)"); + await db.query("INSERT INTO ri VALUES ('r1','x'),('r2',NULL)"); + await db.query("INSERT INTO rn VALUES ('n1','x'),('n2',NULL)"); + + const indexed = rowsOf>( + await db.query('SELECT l.id AS lid, ri.id AS rid FROM l JOIN ri ON l.k = ri.k'), + ); + const unindexed = rowsOf>( + await db.query('SELECT l.id AS lid, rn.id AS rid FROM l JOIN rn ON l.k = rn.k'), + ); + // NULL = NULL 是 UNKNOWN → 两个 NULL 行都不得匹配 + expect(indexed).toEqual([{ lid: 'l1', rid: 'r1' }]); + expect(unindexed).toEqual([{ lid: 'l1', rid: 'n1' }]); + // 关键不变量:结果与"右表该列有没有索引"无关 + expect(indexed.map((r) => r.lid)).toEqual(unindexed.map((r) => r.lid)); + }); + + it('A35 LEFT JOIN 保留未匹配行(NULL 键行保留、右表列补 NULL)', 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',NULL)"); + await db.query("INSERT INTO r VALUES ('r1','x')"); + + const rows = rowsOf>( + await db.query('SELECT l.id AS lid, r.id AS rid FROM l LEFT JOIN r ON l.k = r.k ORDER BY lid'), + ); + expect(rows).toEqual([{ lid: 'l1', rid: 'r1' }, { lid: 'l2', rid: null }]); + }); + }); +}); + +// --------------------------------------------------------------------------- +// maxRowsPerQuery 与 NaN 落盘(与引擎无关,用 memory 验证即可) +// --------------------------------------------------------------------------- + +describe('[v0.8.0] A29 写路径的行数上限保护', () => { + it('INSERT ... SELECT 超过 maxRowsPerQuery → 显式报错(不静默截断)', async () => { + const db = await MetonaSqlark.create({ name: 'a29-limit', mode: 'memory', maxRowsPerQuery: 2 }); + await db.defineTable('src', { id: { type: 'string', primaryKey: true } }); + await db.defineTable('dst', { id: { type: 'string', primaryKey: true } }); + // 注意:每条 INSERT 都受上限约束,因此分 4 条语句各写 1 行来堆积 4 行源数据 + for (const id of ['1', '2', '3', '4']) { + await db.query(`INSERT INTO src VALUES ('${id}')`); + } + expect(rowsOf<{ c: number }>(await db.query('SELECT COUNT(*) AS c FROM src'))).toEqual([{ c: 4 }]); + + // 修复前:行源被 maxRowsPerQuery 静默截断为 2 行 → 只写入 2 行并报成功。 + // 现在:行源不截断,写路径显式报错,dst 保持空。 + await expect(db.query('INSERT INTO dst SELECT id FROM src')).rejects.toMatchObject({ + code: 'QUERY_ERROR', + }); + expect(rowsOf(await db.query('SELECT id FROM dst'))).toHaveLength(0); + await db.close(); + }); + + it('INSERT ... SELECT 未超上限时正常写入全部行源', async () => { + const db = await MetonaSqlark.create({ name: 'a29-within', mode: 'memory', maxRowsPerQuery: 2 }); + await db.defineTable('src', { id: { type: 'string', primaryKey: true } }); + await db.defineTable('dst', { id: { type: 'string', primaryKey: true } }); + await db.query("INSERT INTO src VALUES ('1'),('2')"); + await db.query('INSERT INTO dst SELECT id FROM src'); + expect(rowsOf(await db.query('SELECT id FROM dst ORDER BY id'))).toEqual([{ id: '1' }, { id: '2' }]); + await db.close(); + }); + + it('INSERT ... VALUES 同样受上限保护', async () => { + const db = await MetonaSqlark.create({ name: 'a29-limit-values', mode: 'memory', maxRowsPerQuery: 2 }); + await db.defineTable('t', { id: { type: 'string', primaryKey: true } }); + await expect(db.query("INSERT INTO t VALUES ('1'),('2'),('3')")).rejects.toMatchObject({ + code: 'QUERY_ERROR', + }); + expect(rowsOf(await db.query('SELECT id FROM t'))).toHaveLength(0); + await db.close(); + }); +}); + +describe('[v0.8.0] NaN / Infinity 拒绝落盘(B-1 规范化契约)', () => { + const ENGINES2 = ENGINES; + + it.each(ENGINES2)('%s: NaN 写入被拒绝(否则重启后变 null)', async (label, mode, extra) => { + const db = await MetonaSqlark.create({ name: `nan-${label}`, mode, ...extra }); + await db.defineTable('t', { + id: { type: 'string', primaryKey: true }, + v: { type: 'number' }, + }); + await expect(db.table('t').insert({ id: '1', v: Number.NaN } as never)).rejects.toMatchObject({ + code: 'VALIDATION_ERROR', + }); + await expect(db.table('t').insert({ id: '2', v: Number.POSITIVE_INFINITY } as never)).rejects.toMatchObject({ + code: 'VALIDATION_ERROR', + }); + // 正常数值不受影响 + await db.table('t').insert({ id: '3', v: 1.5 } as never); + expect(rowsOf<{ v: number }>(await db.query('SELECT v FROM t'))).toEqual([{ v: 1.5 }]); + await db.close(); + }); +}); diff --git a/tests/v080-unified-validation.test.ts b/tests/v080-unified-validation.test.ts index 514def6..7dbebd8 100644 --- a/tests/v080-unified-validation.test.ts +++ b/tests/v080-unified-validation.test.ts @@ -150,11 +150,15 @@ describe('[v0.8.0] B-1 校验器单元契约(compileValidator)', () => { flag: { type: 'boolean', default: false }, }); - it('validateRow 填充 default、跳过 undefined、只保留 schema 列', () => { + it('validateRow 填充 default,未提供且无 default 的列补 NULL', () => { const validator = compileValidator(schema); const row = validator.validateRow({ id: '1', tag: 'a' }); - expect(row).toEqual({ id: '1', tag: 'a', flag: false }); - expect('name' in row).toBe(false); + // v0.8.0:未提供且无 default 的列**显式补 null**。此前该键被整个删掉, + // 于是 `SELECT id, tag, name FROM t` 对刚插入的行抛 COLUMN_NOT_FOUND: name, + // 而 `SELECT * FROM t` 又能返回 —— 同一行"有没有 name 列"两种结论。 + expect(row).toEqual({ id: '1', tag: 'a', flag: false, name: null, n: null }); + // 行必须包含**全部** schema 列(投影/排序/三值比较的统一前提) + expect(Object.keys(row).sort()).toEqual(['flag', 'id', 'n', 'name', 'tag']); }); it('validateRow 拒绝未知列并一次列出全部', () => { @@ -174,6 +178,8 @@ describe('[v0.8.0] B-1 校验器单元契约(compileValidator)', () => { id: '1', tag: 'a', flag: false, + name: null, + n: null, }); });