fix(A5/A6/A7/A16/A18/A24/A28): 查询层语义根治 —— LIMIT 双重应用、未知列静默、聚合崩溃与空集语义
A6 LIMIT/OFFSET 被应用两次(丢行)
compileSelect 无条件下推 limit/offset,引擎切一次,executeSelect 末尾又切一次。
实测 4 行表:LIMIT 2 OFFSET 1 只返回 1 行;LIMIT 10 OFFSET 3 返回空。
JOIN/派生表路径因不走 plan 反而正确,同一 executor 内自相矛盾。
现引入 analyzeSelect() 统一判定 limitPushdownSafe,两条路径互斥且只应用一次。
实测 10 种查询形态(含 DISTINCT/GROUP BY/别名/深 OFFSET/LIMIT 0)全部正确。
A7 queryStream 与 query 结果不一致(四类静默分歧)
core.ts 自己重写了一套能否走引擎快路径/如何投影的规则,与 executor 各写一份:
SELECT id AS x FROM t query [{x}],stream [{id,v}](全列+原列名)
SELECT t.id FROM t query [{id}],stream [{}](空对象)
LIMIT 2 OFFSET 1 query 1 行,stream 2 行
LIMIT 0 query 0 行,stream 1 行(Aria 又是 0 行)
且流式路径不触发 beforeQuery/afterQuery、不受 maxRowsPerQuery 约束。
现由 executor.analyzeSelect() 做单一事实来源;不可流式一律回退物化路径;
列引用剥离主表别名前缀;LIMIT 0 短路;回调返回 Promise 时显式报错
(此前用 constructor.name === 'AsyncFunction' 判定,普通函数返回 Promise 会静默丢弃)。
实测 16 种查询形态 × 4 引擎 = 64 组,query 与 queryStream 逐值相等。
A5 MIN/MAX 栈溢出(崩溃)
Math.min(...arr) 展开实参:20 万行同组直接 RangeError。改为单次遍历归约 reduceNumeric。
A24 空集聚合语义
SUM/AVG/MIN/MAX 对空集返回 0(使 和为 0 与 无数据 不可区分),改为 SQL 标准的 NULL;
COUNT 仍返回 0。
A18 未知列静默产出空对象行
SELECT bogus FROM t 返回 [{},{},...](行数对、内容空、无报错);SELECT NAME(列名 name)
同样静默空。新增 assertProjectionColumnsExist:投影前校验列存在性,
未知列抛 COLUMN_NOT_FOUND;同一后缀命中多个表别名时抛歧义错误。空结果集不误报。
A16 INSERT 列/值个数不校验(静默丢弃/写半行)
显式列名时在解析期校验每行值个数与列数一致:
INSERT INTO t (id,name) VALUES ('4','z',9) 此前静默丢弃 9,现报错。
A28 UPDATE SET __proto__ 静默吞掉
sets['__proto__'] 触发原型 setter,sets 变空对象,既不写入也不被未知列预检看到,
表现为返回成功但什么都没发生。parser 的列名映射统一改为 Object.create(null)。
附带修复(A18 验证时发现):
SELECT 1 AS one FROM t 返回 [{}] —— parseColumnRef 的数字分支提前 return 吞掉别名;
裸 SELECT 1 同样产出空对象 —— 投影未处理匿名常量列。现按 SQLite 语义以表达式原文为键。
This commit is contained in:
+266
-19
@@ -14,8 +14,39 @@ import type {
|
||||
import { DatabaseError } from '../constants';
|
||||
import { compileStatement } from './compiler';
|
||||
import { createSchema, astColumnToColumnDef } from '../table/schema';
|
||||
import { matchWhere, applyOrderBy, projectColumns } from './where-matcher';
|
||||
import { matchWhere, applyOrderBy, projectColumns, containsUnresolvedSubqueries } from './where-matcher';
|
||||
import { parseWhereCondition } from '../sql/parser';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SELECT 执行形态(v0.8.0)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 一条 SELECT 的执行形态判定结果。
|
||||
*
|
||||
* 由 {@link QueryExecutor.analyzeSelect} 统一产出,供 executor 自身与
|
||||
* `core.queryStream` 共享 —— 避免"两条入口各写一套规则"导致的语义漂移。
|
||||
*/
|
||||
export interface SelectExecutionShape {
|
||||
/** 有 GROUP BY */
|
||||
hasGroupBy: boolean;
|
||||
/** 无 GROUP BY 但 SELECT 列表含聚合函数 */
|
||||
hasAggregate: boolean;
|
||||
/** 含 JOIN */
|
||||
isJoinQuery: boolean;
|
||||
/** 需要原始行(SELECT 列或 WHERE 含 CASE 表达式) */
|
||||
needsRawRows: boolean;
|
||||
/** ORDER BY 引用了 SELECT 别名(投影后才存在) */
|
||||
orderByAlias: boolean;
|
||||
/** SELECT 列含 `col AS alias` */
|
||||
hasSelectAlias: boolean;
|
||||
/** LIMIT/OFFSET 可安全下推给引擎(否则由 executor 末尾应用一次) */
|
||||
limitPushdownSafe: boolean;
|
||||
/** 引擎层投影与 executor 投影语义等价(列均为裸列引用) */
|
||||
engineEquivalentProjection: boolean;
|
||||
/** 可直连引擎 findStream 做真流式(无任何改变行集合/行序/行形状的阶段) */
|
||||
streamable: boolean;
|
||||
}
|
||||
import type { WhereCondition } from '../constants';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -55,6 +86,59 @@ interface CaseExpression {
|
||||
alias: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.8.0: 解析 `expr AS alias` 中的 `expr` 取值来源。
|
||||
*
|
||||
* 此前只处理"字符串常量"与"列引用"两种情况,数字/布尔/NULL 常量会走
|
||||
* `row[source]` → undefined,于是 `SELECT 1 AS one FROM t` 返回 `[{}]`
|
||||
* (键在、值为 undefined,JSON 序列化后整个键消失),
|
||||
* 而这正是 EXISTS 子查询里最常见的写法(`SELECT 1 FROM ...`)。
|
||||
*/
|
||||
function resolveAliasSource(source: string, row: Record<string, unknown>): unknown {
|
||||
const text = source.trim();
|
||||
// 字符串常量(含 SQL 标准 '' 转义还原)
|
||||
const strLit = text.match(/^'(.*)'$/s);
|
||||
if (strLit) return strLit[1].replace(/''/g, "'");
|
||||
// 数字常量(含负号与小数)
|
||||
if (/^-?\d+(\.\d+)?$/.test(text)) return Number(text);
|
||||
if (/^TRUE$/i.test(text)) return true;
|
||||
if (/^FALSE$/i.test(text)) return false;
|
||||
if (/^NULL$/i.test(text)) return null;
|
||||
return row[text];
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.8.0: 数值型聚合的单次遍历实现。
|
||||
*
|
||||
* 为什么替换 `Math.min(...arr)` / `Math.max(...arr)`:
|
||||
* 展开实参会把整个数组压进调用栈,20 万行同组即 RangeError(栈溢出);
|
||||
* 而这是普通查询就能触发的崩溃,不是边界场景。
|
||||
*
|
||||
* 空集语义(SQL 标准):SUM/AVG/MIN/MAX 对**空集或全 NULL** 返回 NULL。
|
||||
* 此前统一返回 0,使 `SUM(x) = 0` 与"没有数据"不可区分。
|
||||
* 注意 COUNT 不在此列 —— COUNT 对空集返回 0(由调用方处理)。
|
||||
*/
|
||||
function reduceNumeric(values: number[], op: 'SUM' | 'AVG' | 'MIN' | 'MAX'): number | null {
|
||||
if (values.length === 0) return null;
|
||||
let acc = op === 'SUM' || op === 'AVG' ? 0 : values[0];
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const v = values[i];
|
||||
switch (op) {
|
||||
case 'SUM':
|
||||
case 'AVG':
|
||||
acc += v;
|
||||
break;
|
||||
case 'MIN':
|
||||
if (i > 0 && v < acc) acc = v;
|
||||
break;
|
||||
case 'MAX':
|
||||
if (i > 0 && v > acc) acc = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return op === 'AVG' ? acc / values.length : acc;
|
||||
}
|
||||
|
||||
/** 解析 "CASE WHEN c1 THEN v1 WHEN c2 THEN v2 ELSE v3 END [AS alias]" */
|
||||
function parseCaseExpression(expr: string): CaseExpression | null {
|
||||
const m = expr.match(/^\s*CASE\s+([\s\S]*?)\s+END\s*(?:AS\s+(\w+))?\s*$/i);
|
||||
@@ -279,21 +363,74 @@ export class QueryExecutor {
|
||||
// SELECT
|
||||
// ===================================================================
|
||||
|
||||
private async executeSelect(stmt: SelectStatement): Promise<Record<string, unknown>[]> {
|
||||
/**
|
||||
* v0.8.0: SELECT 语句的**执行形态分析**(单一事实来源)。
|
||||
*
|
||||
* 为什么把它独立出来:core.queryStream 此前在 core.ts 里**自己重新推导**了一遍
|
||||
* "这条 SELECT 能不能走引擎快路径、列投影怎么算",与 executor 的规则各写一份,
|
||||
* 于是两者漂移出四类静默不一致(实测):
|
||||
* SELECT id AS x FROM t query=[{x}] stream=[{id,v}](全列 + 原列名)
|
||||
* SELECT t.id FROM t query=[{id}] stream=[{}](空对象)
|
||||
* ... LIMIT 2 OFFSET 1 query=1 行 stream=2 行(引擎与 executor 各切一次)
|
||||
* ... LIMIT 0 query=[] stream=1 行
|
||||
*
|
||||
* 现在由 executor 提供唯一判定,core 只消费结论,不再复制规则。
|
||||
*/
|
||||
analyzeSelect(stmt: SelectStatement): SelectExecutionShape {
|
||||
const hasGroupBy = !!(stmt.groupBy && stmt.groupBy.length > 0);
|
||||
const hasAggregate = !hasGroupBy && this._hasAggregateColumn(stmt.columns);
|
||||
let rows: Record<string, unknown>[];
|
||||
|
||||
const isJoinQuery = !!(stmt.joins && stmt.joins.length > 0);
|
||||
// CASE WHEN 表达式需要原始列求值(SELECT 列或 WHERE 条件中的 CASE):
|
||||
// 引擎层取全行,投影统一在 executor 端完成
|
||||
const needsRawRows = this.hasCaseColumn(stmt.columns) ||
|
||||
(!!stmt.where && this.whereHasCase(stmt.where));
|
||||
// v0.3.3: ORDER BY 引用 SELECT 别名 → 引擎层不排序/不截断,投影后再排序
|
||||
const orderByAlias = this.orderByUsesSelectAlias(stmt);
|
||||
// v0.3.3: SELECT 列含 `col AS alias` → 引擎层投影会丢失源列,统一取原始行由 executor 投影
|
||||
const hasSelectAlias = stmt.columns.some((c) => /\s+AS\s+\w+$/i.test(c));
|
||||
|
||||
// LIMIT/OFFSET 下推安全性:只有"引擎返回的行 == LIMIT 应当作用其上的行"时才可下推。
|
||||
// 此前 compileSelect 无条件下推、executor 末尾又切一次 → LIMIT 被应用两遍
|
||||
// (4 行表上 LIMIT 2 OFFSET 1 只返回 1 行)。
|
||||
const limitPushdownSafe = !hasGroupBy && !hasAggregate && !isJoinQuery
|
||||
&& !stmt.fromSubquery && !stmt.distinct
|
||||
&& !(stmt.having && Object.keys(stmt.having).length > 0)
|
||||
&& !orderByAlias && !needsRawRows && !hasSelectAlias;
|
||||
|
||||
// 引擎层投影是否与 executor 语义等价:仅当全部列都是裸 * 或简单列引用
|
||||
//(可带 table. 前缀)时成立。出现 AS 别名/常量/CASE/聚合就交给 executor 投影。
|
||||
let engineEquivalentProjection = stmt.columns.length > 0;
|
||||
for (const c of stmt.columns) {
|
||||
if (c === '*') continue;
|
||||
if (/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(c)) continue;
|
||||
engineEquivalentProjection = false;
|
||||
break;
|
||||
}
|
||||
|
||||
// 真流式(引擎 findStream 直连)的充分条件:所有会改变行集合/行序/行形状的
|
||||
// 阶段都不存在,且 WHERE 已解析(无子查询、无 $col 关联引用)。
|
||||
const streamable = engineEquivalentProjection
|
||||
&& !hasGroupBy && !hasAggregate && !isJoinQuery && !stmt.fromSubquery
|
||||
&& !stmt.distinct && !(stmt.having && Object.keys(stmt.having).length > 0)
|
||||
&& !(stmt.orderBy && stmt.orderBy.length > 0)
|
||||
&& !needsRawRows && !orderByAlias && !hasSelectAlias
|
||||
&& !this.hasCorrelatedRefs(stmt.where)
|
||||
&& !containsUnresolvedSubqueries(stmt.where);
|
||||
|
||||
return {
|
||||
hasGroupBy,
|
||||
hasAggregate,
|
||||
isJoinQuery,
|
||||
needsRawRows,
|
||||
orderByAlias,
|
||||
hasSelectAlias,
|
||||
limitPushdownSafe,
|
||||
engineEquivalentProjection,
|
||||
streamable,
|
||||
};
|
||||
}
|
||||
|
||||
private async executeSelect(stmt: SelectStatement): Promise<Record<string, unknown>[]> {
|
||||
const shape = this.analyzeSelect(stmt);
|
||||
const { hasGroupBy, hasAggregate, isJoinQuery, needsRawRows, orderByAlias, hasSelectAlias, limitPushdownSafe } = shape;
|
||||
let rows: Record<string, unknown>[];
|
||||
|
||||
if (stmt.fromSubquery) {
|
||||
// v0.4.0: FROM (SELECT ...) 派生表 — 子查询结果作为行源
|
||||
const subRows = await this.executeSelectPart(stmt.fromSubquery);
|
||||
@@ -350,6 +487,11 @@ export class QueryExecutor {
|
||||
}
|
||||
const plan = compileStatement(hasGroupBy || hasAggregate ? { ...stmt, columns: ['*'] } : stmt);
|
||||
if (needsRawRows || hasSelectAlias) plan.columns = ['*'];
|
||||
if (!limitPushdownSafe) {
|
||||
// 不安全:不把 LIMIT/OFFSET 交给引擎,由末尾统一应用(只应用一次)
|
||||
plan.limit = undefined;
|
||||
plan.offset = undefined;
|
||||
}
|
||||
if (orderByAlias) { plan.orderBy = undefined; plan.limit = undefined; plan.offset = undefined; }
|
||||
rows = await this.engine.find(plan.table, plan);
|
||||
}
|
||||
@@ -377,6 +519,18 @@ 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 根治:投影前校验列引用存在性(此前未知列静默产出 {} 行)。
|
||||
//
|
||||
// 实测缺陷:`SELECT bogus FROM t`(4 行表)返回 `[{},{},{},{}]`,
|
||||
// `SELECT NAME FROM t`(列名是 name)同样返回 `[{},{}]` —— 行数正确、内容全空、
|
||||
// 无任何报错;`GROUP BY bogus` 会把全表并成一组,`ORDER BY bogus` 顺序随机。
|
||||
// SQLite/MySQL 三处都报 "no such column"。
|
||||
//
|
||||
// 校验时机选在 JOIN/子查询合并完成后(此时是最终行形态),且仅在**未发生聚合**时
|
||||
// 进行 —— 聚合/分组会把行替换为计算键,普通列本就不存在(那属于另一类语义问题)。
|
||||
if (!hasAggregate) {
|
||||
this.assertProjectionColumnsExist(rows, stmt.columns, stmt);
|
||||
}
|
||||
// v0.7.3: `SELECT *, col AS alias` —— 此前 columns[0]==='*' 直接不投影,
|
||||
// 别名列/常量列丢失。仅当 '*' 是唯一列时跳过投影(projectRow 对裸 '*'
|
||||
// 合并原行全部列,其余表达式覆盖/追加)
|
||||
@@ -388,9 +542,15 @@ export class QueryExecutor {
|
||||
if (orderByAlias && stmt.orderBy && stmt.orderBy.length > 0) {
|
||||
rows = applyOrderBy(rows, stmt.orderBy);
|
||||
}
|
||||
const offset = stmt.offset ?? 0;
|
||||
const limit = stmt.limit ?? rows.length;
|
||||
rows = rows.slice(offset, offset + limit);
|
||||
// v0.8.0: LIMIT/OFFSET 的应用点,两条路径互斥且**只执行一次**:
|
||||
// - limitPushdownSafe === true → 引擎已按同一 offset/limit 完成切片,此处不再切片;
|
||||
// - limitPushdownSafe === false → 引擎拿不到 limit/offset,此处是唯一应用点。
|
||||
// 此前两条路径都切了一次,导致 4 行表上 `LIMIT 2 OFFSET 1` 只返回 1 行(应 2,3)。
|
||||
if (!limitPushdownSafe) {
|
||||
const offset = stmt.offset ?? 0;
|
||||
const limit = stmt.limit ?? rows.length;
|
||||
rows = rows.slice(offset, offset + limit);
|
||||
}
|
||||
|
||||
// 全局行数上限保护
|
||||
if (this.maxRowsPerQuery > 0 && rows.length > this.maxRowsPerQuery) {
|
||||
@@ -652,7 +812,13 @@ export class QueryExecutor {
|
||||
return result;
|
||||
}
|
||||
|
||||
private computeAggregate(func: string, rows: Record<string, unknown>[], col: string): number {
|
||||
/**
|
||||
* 计算单个聚合值。
|
||||
*
|
||||
* v0.8.0: 返回类型放宽为 unknown —— SUM/AVG/MIN/MAX 对空集返回 null(SQL 标准),
|
||||
* COUNT 仍返回 number。最小/最大改为单次遍历(不再展开实参,消除栈溢出)。
|
||||
*/
|
||||
private computeAggregate(func: string, rows: Record<string, unknown>[], col: 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) 等去重聚合
|
||||
@@ -670,12 +836,18 @@ export class QueryExecutor {
|
||||
return rawValues.length;
|
||||
}
|
||||
const nums = rawValues.map(Number);
|
||||
// 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 distinctNums.reduce((a: number, b) => a + b, 0);
|
||||
case 'AVG': return distinctNums.length === 0 ? 0 : distinctNums.reduce((a: number, b) => a + b, 0) / distinctNums.length;
|
||||
case 'MIN': return distinctNums.length === 0 ? 0 : Math.min(...distinctNums);
|
||||
case 'MAX': return distinctNums.length === 0 ? 0 : Math.max(...distinctNums);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1003,6 +1175,76 @@ export class QueryExecutor {
|
||||
|
||||
getEngine(): IStorageEngine { return this.engine; }
|
||||
|
||||
/**
|
||||
* v0.8.0: 校验 SELECT 列表中的**裸列引用**在结果行里确实存在,否则抛 COLUMN_NOT_FOUND。
|
||||
*
|
||||
* 为什么必须做:`SELECT bogus FROM t` 此前返回 `[{},{},...]`(行数对、内容空、无报错),
|
||||
* 这是"静默错误结果"里最难被发现的一类 —— 调用方拿到的是结构正确但全空的表格。
|
||||
*
|
||||
* 判定规则(与 projectRow 的分类保持一致):
|
||||
* - `*` 跳过;
|
||||
* - 字符串/数字常量列跳过;
|
||||
* - CASE 表达式跳过(其内部列引用由 evaluateCase 处理);
|
||||
* - `expr AS alias`:字符串/数字常量跳过,否则取 `expr` 作为被引用列;
|
||||
* - 其余视为裸列引用。
|
||||
* 存在性检查允许两种形态:精确匹配,或**唯一**以 `.<col>` 结尾(JOIN 行以 `alias.col` 为键)。
|
||||
* 若同一个后缀出现在多个表别名下则视为歧义,同样报错(符合"未限定列名歧义应报错"的语义)。
|
||||
*
|
||||
* 结果集为空时无法判定,此时跳过(空表 + 未知列不会误报)。
|
||||
*/
|
||||
private assertProjectionColumnsExist(
|
||||
rows: Record<string, unknown>[],
|
||||
columns: string[],
|
||||
stmt: SelectStatement,
|
||||
): void {
|
||||
if (rows.length === 0 || columns.length === 0) return;
|
||||
|
||||
const available = new Set<string>();
|
||||
for (const row of rows) {
|
||||
for (const key of Object.keys(row)) available.add(key);
|
||||
}
|
||||
|
||||
for (const raw of columns) {
|
||||
const col = raw.trim();
|
||||
if (col === '*') continue;
|
||||
if (parseCaseExpression(col)) continue;
|
||||
|
||||
let reference = col;
|
||||
const aliasMatch = col.match(/^(.+?)\s+AS\s+\w+$/i);
|
||||
if (aliasMatch) reference = aliasMatch[1].trim();
|
||||
|
||||
// 常量列(字符串 / 数字 / 布尔 / NULL)
|
||||
if (/^'.*'$/s.test(reference)) continue;
|
||||
if (/^-?\d+(\.\d+)?$/.test(reference)) continue;
|
||||
if (/^(TRUE|FALSE|NULL)$/i.test(reference)) continue;
|
||||
// 聚合表达式(在 hasAggregate 分支已跳过,这里再兜一层防御)
|
||||
if (/^(COUNT|SUM|AVG|MIN|MAX)\s*\(/i.test(reference)) continue;
|
||||
|
||||
if (available.has(reference)) continue;
|
||||
|
||||
const suffixMatches: string[] = [];
|
||||
for (const key of available) {
|
||||
if (key.endsWith(`.${reference}`)) suffixMatches.push(key);
|
||||
}
|
||||
if (suffixMatches.length === 1) continue;
|
||||
|
||||
if (suffixMatches.length > 1) {
|
||||
const owners = suffixMatches.map((k) => k.slice(0, k.length - reference.length - 1)).sort();
|
||||
throw new DatabaseError(
|
||||
`Ambiguous column "${reference}" in SELECT list: present in ${owners.join(', ')}`,
|
||||
'COLUMN_NOT_FOUND',
|
||||
{ column: reference, tables: owners, from: stmt.from },
|
||||
);
|
||||
}
|
||||
|
||||
throw new DatabaseError(
|
||||
`Unknown column "${reference}" in SELECT list`,
|
||||
'COLUMN_NOT_FOUND',
|
||||
{ column: reference, from: stmt.from, available: [...available].slice(0, 32) },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列投影(v0.3.1):普通列走 projectColumns,CASE WHEN 表达式逐行求值;
|
||||
* v0.3.3: 支持 `col AS alias` 列别名
|
||||
@@ -1038,6 +1280,13 @@ export class QueryExecutor {
|
||||
constCols.push({ key: col, value });
|
||||
continue;
|
||||
}
|
||||
// v0.8.0: 匿名常量列(数字/布尔/NULL)—— `SELECT 1 FROM t` 此前投影成 {}
|
||||
// (键 '1' 在、值为 undefined,JSON 序列化后键消失)。SQLite/MySQL 用
|
||||
// 表达式原文作列名,这里保持一致。
|
||||
if (/^-?\d+(\.\d+)?$/.test(col) || /^(TRUE|FALSE|NULL)$/i.test(col)) {
|
||||
constCols.push({ key: col, value: resolveAliasSource(col, row) });
|
||||
continue;
|
||||
}
|
||||
plain.push(col);
|
||||
}
|
||||
// v0.7.3: hasStar 时以原行全部列为基(projectColumns 仅投影 plain 列,不含 * 的其余列)
|
||||
@@ -1048,9 +1297,7 @@ export class QueryExecutor {
|
||||
if (source === '*') {
|
||||
Object.assign(projected, row);
|
||||
} else {
|
||||
const lit = source.match(/^'(.*)'$/s);
|
||||
// v0.7.3: 同 constCols —— SQL 标准 '' 转义还原
|
||||
projected[alias] = lit ? lit[1].replace(/''/g, "'") : row[source];
|
||||
projected[alias] = resolveAliasSource(source, row);
|
||||
}
|
||||
}
|
||||
for (const { key, value } of constCols) {
|
||||
|
||||
Reference in New Issue
Block a user