release: v0.4.1 — Aria 级联/ALTER/clearAll + 流式查询/派生表 + 正确性加固
新增: - AriaEngine 外键级联(CASCADE/SET NULL/RESTRICT)+ clearAll() 重置 API - 引擎级 alterTable:Aria DROP COLUMN 重写存储行 + schema 持久化 - 流式查询 queryStream / findStream(LSM 惰性扫描不物化) - FROM 派生表 / 多列 ON 哈希连接 / COUNT(DISTINCT) / NULLS FIRST/LAST - 普通列别名 + ORDER BY 别名 + 无表查询 + 字符串常量列 - 演示页引擎切换器(Memory/Aria)+ 预设自动重置 修复: - Aria WAL DROP_TABLE 崩溃恢复(删表复活)+ 恢复后 WAL 截断 - Memory update/delete 索引维护(unique 约束绕过) - 关联 EXISTS 绑定失效 / HAVING 标量子查询 / INSERT SELECT 位置错位 - 裸布尔列条件(WHERE done / CASE WHEN done) - Aria $in 重复行 / JOIN 主表 WHERE 下推 / DROP INDEX 报错 - ORDER BY/GROUP BY/SELECT 表前缀列 + SQL '' 标准转义 质量:894 测试 · 47 套件 · 81.5% 覆盖率
This commit is contained in:
@@ -168,6 +168,8 @@ export interface SelectStatement {
|
||||
columns: ColumnRef[];
|
||||
distinct?: boolean;
|
||||
from: string;
|
||||
/** v0.4.0: FROM (SELECT ...) 派生表(存在时 from 为占位,行源取此子查询结果) */
|
||||
fromSubquery?: SelectStatement | SelectUnionStatement;
|
||||
/** 主表别名 */
|
||||
alias?: string;
|
||||
/** JOIN 子句列表 */
|
||||
|
||||
+243
-60
@@ -218,8 +218,26 @@ export class QueryExecutor {
|
||||
// 引擎层取全行,投影统一在 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));
|
||||
|
||||
if (isJoinQuery) {
|
||||
if (stmt.fromSubquery) {
|
||||
// v0.4.0: FROM (SELECT ...) 派生表 — 子查询结果作为行源
|
||||
const subRows = await this.executeSelectPart(stmt.fromSubquery);
|
||||
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));
|
||||
}
|
||||
} else if (!stmt.from && !isJoinQuery) {
|
||||
// v0.4.0: 无表查询(SELECT 1 / SELECT 'lit')— 单行空上下文,常量列投影
|
||||
rows = [{}];
|
||||
} else if (isJoinQuery) {
|
||||
// JOIN 路径:行带表别名前缀(如 'd.id'),WHERE 保持原名不剥离
|
||||
rows = await this.executeJoinSelect(stmt);
|
||||
} else {
|
||||
@@ -227,11 +245,31 @@ export class QueryExecutor {
|
||||
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)
|
||||
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);
|
||||
});
|
||||
|
||||
// WHERE 含关联子查询($col 引用外层行)→ 逐行绑定上下文求值
|
||||
if (stmt.where && this.hasCorrelatedRefs(stmt.where)) {
|
||||
const plan = compileStatement(hasGroupBy || hasAggregate ? { ...stmt, columns: ['*'] } : stmt);
|
||||
if (needsRawRows) plan.columns = ['*'];
|
||||
// v0.4.0 修复: 关联子查询需要完整外层行(SELECT 列可能不含被 $col 引用的列,如 EXISTS 绑定的主键)
|
||||
plan.columns = ['*'];
|
||||
if (orderByAlias) { plan.orderBy = undefined; plan.limit = undefined; plan.offset = undefined; }
|
||||
rows = await this.engine.find(plan.table, { ...plan, where: this.stripCorrelatedExists(stmt.where) });
|
||||
rows = await this.filterCorrelated(rows, stmt.where);
|
||||
} else {
|
||||
@@ -240,7 +278,8 @@ export class QueryExecutor {
|
||||
stmt.where = await this.resolveSubqueries(stmt.where);
|
||||
}
|
||||
const plan = compileStatement(hasGroupBy || hasAggregate ? { ...stmt, columns: ['*'] } : stmt);
|
||||
if (needsRawRows) plan.columns = ['*'];
|
||||
if (needsRawRows || hasSelectAlias) plan.columns = ['*'];
|
||||
if (orderByAlias) { plan.orderBy = undefined; plan.limit = undefined; plan.offset = undefined; }
|
||||
rows = await this.engine.find(plan.table, plan);
|
||||
}
|
||||
}
|
||||
@@ -253,15 +292,30 @@ export class QueryExecutor {
|
||||
if (hasGroupBy) rows = this.executeGroupBy(rows, stmt);
|
||||
if (stmt.distinct) 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);
|
||||
// v0.4.0: HAVING 引用聚合表达式键(如 SUM(o.amount))时归一为别名键(如 spent)
|
||||
const aliasMap = (stmt as unknown as { _aggAliasMap?: Map<string, string> })._aggAliasMap;
|
||||
if (aliasMap && aliasMap.size > 0) {
|
||||
const normalized: WhereCondition = {};
|
||||
for (const [k, v] of Object.entries(stmt.having)) {
|
||||
normalized[aliasMap.get(k) ?? k] = v;
|
||||
}
|
||||
stmt.having = normalized;
|
||||
}
|
||||
rows = rows.filter((row) => matchWhere(row, stmt.having!));
|
||||
}
|
||||
if (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);
|
||||
if (!hasGroupBy && !hasAggregate && stmt.columns.length > 0 && stmt.columns[0] !== '*') {
|
||||
rows = rows.map((row) => this.projectRow(row, stmt.columns));
|
||||
}
|
||||
// v0.3.3: ORDER BY 别名 → 投影后才存在,需在投影后重新排序
|
||||
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);
|
||||
|
||||
// 全局行数上限保护
|
||||
if (this.maxRowsPerQuery > 0 && rows.length > this.maxRowsPerQuery) {
|
||||
@@ -273,10 +327,20 @@ export class QueryExecutor {
|
||||
|
||||
// ---- JOIN ----
|
||||
|
||||
private async executeJoinSelect(stmt: SelectStatement): Promise<Record<string, unknown>[]> {
|
||||
private async executeJoinSelect(stmt: SelectStatement, preloadedMain?: Record<string, unknown>[]): Promise<Record<string, unknown>[]> {
|
||||
const mainAlias = stmt.alias ?? stmt.from;
|
||||
const mainRows = (await this.engine.find(stmt.from, { table: stmt.from }))
|
||||
.map((row) => this.prefixRow(row, mainAlias));
|
||||
// v0.4.0: 派生表行源已预加载(行带别名前缀)
|
||||
let mainRows: Record<string, unknown>[];
|
||||
if (preloadedMain) {
|
||||
mainRows = preloadedMain;
|
||||
} else {
|
||||
// v0.4.1: WHERE 中主表前缀等值条件下推到引擎(走二级索引,如 WHERE o.user_id = '1')
|
||||
const { pushable } = this.extractPushableWhere(stmt.where ?? {}, mainAlias);
|
||||
mainRows = (await this.engine.find(stmt.from, {
|
||||
table: stmt.from,
|
||||
where: Object.keys(pushable).length > 0 ? pushable : undefined,
|
||||
})).map((row) => this.prefixRow(row, mainAlias));
|
||||
}
|
||||
let resultRows = mainRows;
|
||||
|
||||
for (const join of stmt.joins!) {
|
||||
@@ -312,7 +376,28 @@ export class QueryExecutor {
|
||||
}
|
||||
|
||||
/**
|
||||
* 哈希连接(v0.3.2):ON 为单一等值条件且右表列为索引/主键时,
|
||||
* v0.4.1: 提取可下推的 WHERE 条件 — 主表别名前缀的普通条件(如 o.user_id = '1')。
|
||||
* 下推到引擎可走二级索引;$col/$subquery/$and/$or/$not 等复杂条件保守不下推。
|
||||
*/
|
||||
private extractPushableWhere(where: WhereCondition, mainAlias: string): { pushable: WhereCondition } {
|
||||
const pushable: WhereCondition = {};
|
||||
if (!mainAlias) return { pushable };
|
||||
const prefix = `${mainAlias}.`;
|
||||
for (const [key, value] of Object.entries(where)) {
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
const v = value as Record<string, unknown> | null;
|
||||
if (typeof v === 'object' && v !== null &&
|
||||
('$col' in v || '$subquery' in v || '$and' in v || '$or' in v || '$not' in v)) {
|
||||
continue;
|
||||
}
|
||||
pushable[key.slice(prefix.length)] = value;
|
||||
}
|
||||
return { pushable };
|
||||
}
|
||||
|
||||
/**
|
||||
* 哈希连接(v0.3.2 单等值 / v0.4.0 多列等值):
|
||||
* ON 为等值条件(单列或多列)且右表任一列为索引/主键时,
|
||||
* 收集左表连接值 → 一次 $in 查询右表 → 哈希映射匹配。
|
||||
* 替代嵌套循环,大表 INNER/LEFT JOIN 复杂度 O(N + M)。
|
||||
* 不适用时返回 null(回退嵌套循环)。
|
||||
@@ -325,52 +410,64 @@ export class QueryExecutor {
|
||||
): Promise<Record<string, unknown>[] | null> {
|
||||
if (join.type === 'CROSS' || join.type === 'RIGHT') return null;
|
||||
|
||||
// 提取单一等值条件:{ colA: { $eq: { $col: colB } } } 或 { colA: { $col: colB } }
|
||||
const keys = Object.keys(join.on);
|
||||
if (keys.length !== 1) return null;
|
||||
const keyCol = keys[0];
|
||||
const cond = join.on[keyCol] as Record<string, unknown> | null | undefined;
|
||||
// 解析 ON 为 (leftCol, rightCol) 等值对列表(v0.4.0 支持多列,含顶层 $and 展开)
|
||||
const pairs: { leftCol: string; rightCol: string }[] = [];
|
||||
const collectPairs = (on: WhereCondition): boolean => {
|
||||
for (const [keyCol, cond] of Object.entries(on)) {
|
||||
if (keyCol === '$and') {
|
||||
if (!(cond as WhereCondition[]).every(collectPairs)) return false;
|
||||
continue;
|
||||
}
|
||||
if (keyCol === '$or' || keyCol === '$not') return false; // 非等值逻辑不适用
|
||||
let refCol: string | null = null;
|
||||
if (typeof cond === 'object' && cond !== null) {
|
||||
const c = cond as Record<string, unknown>;
|
||||
if ('$eq' in c && typeof c.$eq === 'object' && c.$eq !== null && '$col' in (c.$eq as Record<string, unknown>)) {
|
||||
refCol = String((c.$eq as Record<string, unknown>).$col);
|
||||
} else if ('$col' in c && Object.keys(c).length === 1) {
|
||||
refCol = String(c.$col);
|
||||
}
|
||||
}
|
||||
if (!refCol) return false; // 非等值条件不适用哈希连接
|
||||
|
||||
let refCol: string | null = null;
|
||||
if (typeof cond === 'object' && cond !== null) {
|
||||
if ('$eq' in cond && typeof cond.$eq === 'object' && cond.$eq !== null && '$col' in (cond.$eq as Record<string, unknown>)) {
|
||||
refCol = String((cond.$eq as Record<string, unknown>).$col);
|
||||
} else if ('$col' in cond && Object.keys(cond).length === 1) {
|
||||
refCol = String(cond.$col);
|
||||
const keyIsLeft = mainAlias ? keyCol.startsWith(`${mainAlias}.`) : false;
|
||||
pairs.push({
|
||||
leftCol: keyIsLeft ? keyCol : refCol,
|
||||
rightCol: keyIsLeft ? refCol : keyCol,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!refCol) return null;
|
||||
return true;
|
||||
};
|
||||
if (!collectPairs(join.on)) return null;
|
||||
if (pairs.length === 0) return null;
|
||||
|
||||
// 方向判定:键/值哪个属于左表(mainAlias 前缀)?
|
||||
// ON 键形如 'o.user_id'(右表)→ 值 $col 'u.id'(左表);或反向
|
||||
const keyIsLeft = mainAlias ? keyCol.startsWith(`${mainAlias}.`) : false;
|
||||
const leftCol = keyIsLeft ? keyCol : refCol;
|
||||
const rightCol = keyIsLeft ? refCol : keyCol;
|
||||
|
||||
// 右表列必须是主键/索引列(确保 $in 走索引)
|
||||
// 右表列必须是主键/索引列(确保 $in 走索引)——任一列即可
|
||||
const schema = await this.engine.getTableSchema(join.table);
|
||||
if (!schema) return null;
|
||||
const bareRightCol = rightCol.split('.').pop()!;
|
||||
const colDef = schema.columns[bareRightCol];
|
||||
if (!colDef || (!colDef.primaryKey && !colDef.index && !colDef.unique)) return null;
|
||||
const probePair = pairs.find((p) => {
|
||||
const bare = p.rightCol.split('.').pop()!;
|
||||
const colDef = schema.columns[bare];
|
||||
return colDef && (colDef.primaryKey || colDef.index || colDef.unique);
|
||||
});
|
||||
if (!probePair) return null;
|
||||
|
||||
// 收集左表连接值(去重)
|
||||
const values = Array.from(new Set(leftRows.map((r) => r[leftCol]).filter((v) => v !== undefined && v !== null)));
|
||||
// 收集左表连接值(去重)——用探测列的值缩小候选集
|
||||
const probeRightBare = probePair.rightCol.split('.').pop()!;
|
||||
const values = Array.from(new Set(leftRows.map((r) => r[probePair.leftCol]).filter((v) => v !== undefined && v !== null)));
|
||||
if (values.length === 0) return null;
|
||||
|
||||
// 一次 $in 查询右表
|
||||
// 一次 $in 查询右表(缩小候选集)
|
||||
const rightRows = await this.engine.find(join.table, {
|
||||
table: join.table,
|
||||
where: { [bareRightCol]: { $in: values } },
|
||||
where: { [probeRightBare]: { $in: values } },
|
||||
});
|
||||
|
||||
// 构建哈希映射:右列值 → 行列表
|
||||
const hash = new Map<unknown, Record<string, unknown>[]>();
|
||||
// 构建复合键哈希映射:右表多列值 → 行列表
|
||||
const hash = new Map<string, Record<string, unknown>[]>();
|
||||
for (const rr of rightRows) {
|
||||
const v = rr[bareRightCol];
|
||||
if (v === undefined || v === null) continue;
|
||||
if (!hash.has(v)) hash.set(v, []);
|
||||
hash.get(v)!.push(rr);
|
||||
const key = pairs.map((p) => String(rr[p.rightCol.split('.').pop()!] ?? '\0')).join('\x1f');
|
||||
if (!hash.has(key)) hash.set(key, []);
|
||||
hash.get(key)!.push(rr);
|
||||
}
|
||||
|
||||
const nullRight: Record<string, unknown> = {};
|
||||
@@ -378,8 +475,8 @@ export class QueryExecutor {
|
||||
|
||||
const result: Record<string, unknown>[] = [];
|
||||
for (const l of leftRows) {
|
||||
const lv = l[leftCol];
|
||||
const matches = hash.get(lv);
|
||||
const key = pairs.map((p) => String(l[p.leftCol] ?? '\0')).join('\x1f');
|
||||
const matches = hash.get(key);
|
||||
if (matches && matches.length > 0) {
|
||||
for (const r of matches) {
|
||||
result.push({ ...l, ...this.prefixRow(r, joinAlias) });
|
||||
@@ -449,6 +546,8 @@ export class QueryExecutor {
|
||||
groups.get(key)!.push(row);
|
||||
}
|
||||
const result: Record<string, unknown>[] = [];
|
||||
// v0.4.0: 聚合表达式键 → 输出键 映射(HAVING SUM(...) 引用表达式时归一为别名键)
|
||||
const aliasMap = new Map<string, string>();
|
||||
for (const groupRows of groups.values()) {
|
||||
const aggregated: Record<string, unknown> = {};
|
||||
for (const col of stmt.groupBy!) aggregated[col] = groupRows[0][col];
|
||||
@@ -457,7 +556,11 @@ export class QueryExecutor {
|
||||
const m = colExpr.match(/^(COUNT|SUM|AVG|MIN|MAX)\((.+?)\)(?:\s+AS\s+(\w+))?$/i);
|
||||
if (m) {
|
||||
const [, func, arg, alias] = m;
|
||||
aggregated[alias || colExpr] = this.computeAggregate(func.toUpperCase(), groupRows, arg.trim());
|
||||
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)) {
|
||||
// v0.3.2: 非聚合的 CASE WHEN 列取组内第一行求值
|
||||
const expr = parseCaseExpression(colExpr);
|
||||
@@ -468,22 +571,34 @@ export class QueryExecutor {
|
||||
}
|
||||
result.push(aggregated);
|
||||
}
|
||||
(stmt as unknown as { _aggAliasMap?: Map<string, string> })._aggAliasMap = aliasMap;
|
||||
return result;
|
||||
}
|
||||
|
||||
private computeAggregate(func: string, rows: Record<string, unknown>[], col: string): number {
|
||||
// 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;
|
||||
const nums = rows
|
||||
.map((r) => (caseExpr ? evaluateCase(caseExpr, r) : r[col]))
|
||||
.filter((v) => v !== null && v !== undefined)
|
||||
.map(Number);
|
||||
// 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 rawValues = rows
|
||||
.map((r) => (caseExpr ? evaluateCase(caseExpr, r) : r[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 distinctNums = distinctArg ? Array.from(new Set(nums)) : nums;
|
||||
switch (func) {
|
||||
case 'COUNT': return col === '*' ? rows.length : nums.length;
|
||||
case 'SUM': return nums.reduce((a: number, b) => a + b, 0);
|
||||
case 'AVG': return nums.length === 0 ? 0 : nums.reduce((a: number, b) => a + b, 0) / nums.length;
|
||||
case 'MIN': return nums.length === 0 ? 0 : Math.min(...nums);
|
||||
case 'MAX': return nums.length === 0 ? 0 : Math.max(...nums);
|
||||
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);
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
@@ -512,11 +627,26 @@ export class QueryExecutor {
|
||||
// INSERT INTO ... SELECT ...(v0.3.0)
|
||||
if (stmt.select) {
|
||||
const selectRows = await this.executeSelectPart(stmt.select);
|
||||
// v0.4.0 修复:源列顺序不能依赖行键(validateRow 会跳过 undefined 导致行键缺失/乱序)。
|
||||
// 以 SELECT 列列表 / 源表 schema 列顺序为准,按位置对齐目标列,缺列不填。
|
||||
let srcCols: string[] = [];
|
||||
const sel = stmt.select;
|
||||
if (sel.type === 'SELECT') {
|
||||
if (sel.columns && sel.columns.length > 0 && sel.columns[0] !== '*') {
|
||||
srcCols = sel.columns.map((c) => c.split('.').pop()!);
|
||||
} else if (sel.from) {
|
||||
const srcSchema = await this.engine.getTableSchema(sel.from);
|
||||
srcCols = srcSchema ? Object.keys(srcSchema.columns) : [];
|
||||
}
|
||||
}
|
||||
if (srcCols.length === 0 && selectRows.length > 0) {
|
||||
srcCols = Object.keys(selectRows[0]);
|
||||
}
|
||||
const rows: Record<string, unknown>[] = selectRows.map((row) => {
|
||||
const mapped: Record<string, unknown> = {};
|
||||
const values = Object.values(row);
|
||||
for (let i = 0; i < colNames.length; i++) {
|
||||
if (i < values.length) mapped[colNames[i]] = values[i];
|
||||
const src = i < srcCols.length ? srcCols[i] : null;
|
||||
if (src && src in row) mapped[colNames[i]] = row[src];
|
||||
}
|
||||
return mapped;
|
||||
});
|
||||
@@ -566,6 +696,11 @@ export class QueryExecutor {
|
||||
const schema = await this.engine.getTableSchema(stmt.name);
|
||||
if (!schema) return;
|
||||
|
||||
// v0.4.1: 引擎级 alterTable(Aria 需重写存储行 + 持久化 schema;其余引擎走通用引用路径)
|
||||
if (typeof this.engine.alterTable === 'function') {
|
||||
return this.engine.alterTable(stmt.name, stmt.action, { ...astColumnToColumnDef(stmt.column), name: stmt.column.name });
|
||||
}
|
||||
|
||||
if (stmt.action === 'ADD') {
|
||||
if (schema.columns[stmt.column.name]) {
|
||||
throw new DatabaseError(`Column "${stmt.column.name}" already exists in table "${stmt.name}"`, 'COLUMN_EXISTS');
|
||||
@@ -646,6 +781,25 @@ export class QueryExecutor {
|
||||
return columns.some((col) => /^\s*CASE\b/i.test(col));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
const aliases = new Set<string>();
|
||||
for (const col of stmt.columns) {
|
||||
const m = col.match(/\s+AS\s+(\w+)$/i);
|
||||
if (m) aliases.add(m[1]);
|
||||
else if (/^\s*CASE\b/i.test(col)) {
|
||||
const expr = parseCaseExpression(col);
|
||||
if (expr?.alias) aliases.add(expr.alias);
|
||||
}
|
||||
}
|
||||
if (aliases.size === 0) return false;
|
||||
return stmt.orderBy.some((o) => aliases.has(o.column));
|
||||
}
|
||||
|
||||
/** WHERE 是否包含 CASE WHEN 表达式键 */
|
||||
private whereHasCase(where: WhereCondition): boolean {
|
||||
for (const [key, value] of Object.entries(where)) {
|
||||
@@ -665,18 +819,47 @@ export class QueryExecutor {
|
||||
getEngine(): IStorageEngine { return this.engine; }
|
||||
|
||||
/**
|
||||
* 列投影(v0.3.1):普通列走 projectColumns,CASE WHEN 表达式逐行求值
|
||||
* 列投影(v0.3.1):普通列走 projectColumns,CASE WHEN 表达式逐行求值;
|
||||
* v0.3.3: 支持 `col AS alias` 列别名
|
||||
*/
|
||||
private projectRow(row: Record<string, unknown>, columns: string[]): Record<string, unknown> {
|
||||
const plain: string[] = [];
|
||||
const aliasCols: { alias: string; source: string }[] = [];
|
||||
const caseCols: { alias: string; expr: CaseExpression }[] = [];
|
||||
const constCols: { key: string; value: unknown }[] = [];
|
||||
for (const col of columns) {
|
||||
if (col === '*') continue;
|
||||
const expr = parseCaseExpression(col);
|
||||
if (expr) caseCols.push({ alias: expr.alias ?? col, expr });
|
||||
else plain.push(col);
|
||||
if (expr) {
|
||||
caseCols.push({ alias: expr.alias ?? col, expr });
|
||||
continue;
|
||||
}
|
||||
const m = col.match(/^(.+?)\s+AS\s+(\w+)$/i);
|
||||
if (m) {
|
||||
aliasCols.push({ alias: m[2], source: m[1].trim() });
|
||||
continue;
|
||||
}
|
||||
// v0.4.0: 字符串常量列 SELECT 'lit' → 常量输出
|
||||
const lit = col.match(/^'(.*)'$/s);
|
||||
if (lit) {
|
||||
const value = lit[1].replace(/\\'/g, "'");
|
||||
constCols.push({ key: col, value });
|
||||
continue;
|
||||
}
|
||||
plain.push(col);
|
||||
}
|
||||
const projected = plain.length > 0 ? projectColumns(row, plain) : {};
|
||||
for (const { alias, source } of aliasCols) {
|
||||
if (source === '*') {
|
||||
Object.assign(projected, row);
|
||||
} else {
|
||||
const lit = source.match(/^'(.*)'$/s);
|
||||
projected[alias] = lit ? lit[1].replace(/\\'/g, "'") : row[source];
|
||||
}
|
||||
}
|
||||
for (const { key, value } of constCols) {
|
||||
projected[key] = value;
|
||||
}
|
||||
for (const { alias, expr } of caseCols) {
|
||||
projected[alias] = evaluateCase(expr, row);
|
||||
}
|
||||
|
||||
@@ -149,7 +149,15 @@ function matchOperator(value: unknown, op: string, operand: unknown): boolean {
|
||||
|
||||
export function applyOrderBy(rows: Record<string, unknown>[], orderBy: OrderBy[]): Record<string, unknown>[] {
|
||||
return [...rows].sort((a, b) => {
|
||||
for (const { column, direction } of orderBy) {
|
||||
for (const { column, direction, nulls } of orderBy) {
|
||||
const aNull = a[column] === null || a[column] === undefined;
|
||||
const bNull = b[column] === null || b[column] === undefined;
|
||||
// v0.4.0: NULLS FIRST/LAST 时 NULL 位置固定,不受升降序反转
|
||||
if (nulls && (aNull || bNull)) {
|
||||
if (aNull && bNull) continue;
|
||||
const cmp = nulls === 'first' ? (aNull ? -1 : 1) : (aNull ? 1 : -1);
|
||||
return cmp;
|
||||
}
|
||||
const cmp = compare(a[column], b[column]);
|
||||
if (cmp !== 0) return direction === 'desc' ? -cmp : cmp;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user