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:
+3
-1
@@ -140,6 +140,8 @@ export interface OrderBy {
|
||||
column: string;
|
||||
/** 排序方向 */
|
||||
direction: SortDirection;
|
||||
/** v0.4.0: NULL 值排序位置(first 排最前 / last 排最后,默认同引擎行为) */
|
||||
nulls?: 'first' | 'last';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -212,4 +214,4 @@ export class DatabaseError extends Error {
|
||||
// 版本
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const VERSION = '0.3.2';
|
||||
export const VERSION = '0.4.1';
|
||||
|
||||
+85
@@ -202,6 +202,91 @@ export class MetonaSqlark {
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- 流式查询(v0.4.0) ----
|
||||
|
||||
/**
|
||||
* 流式查询:逐行回调,不一次性物化全部结果(大表友好)。
|
||||
* 支持简单 SELECT(WHERE/LIMIT/OFFSET/列投影);
|
||||
* JOIN/GROUP BY/UNION/聚合/ORDER BY 自动回退为物化查询后逐行回调。
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* let total = 0;
|
||||
* await db.queryStream('SELECT * FROM logs WHERE level = \'error\'', (row) => {
|
||||
* total++;
|
||||
* processRow(row);
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
async queryStream<T extends Record<string, unknown> = Record<string, unknown>>(
|
||||
sql: string,
|
||||
onRow: (row: T) => void,
|
||||
): Promise<number> {
|
||||
this.ensureReady();
|
||||
const stmt = parseAll(sql)[0];
|
||||
if (!stmt || stmt.type !== 'SELECT') {
|
||||
throw new DatabaseError('queryStream only supports SELECT statements', 'NOT_SUPPORTED');
|
||||
}
|
||||
const select = stmt as import('./query/ast').SelectStatement;
|
||||
|
||||
// 不可流式场景:JOIN / GROUP BY / HAVING / DISTINCT / 聚合 / UNION / 关联子查询 / ORDER BY
|
||||
const aggregate = select.columns.some((c) => /^(COUNT|SUM|AVG|MIN|MAX)\(/i.test(c));
|
||||
const streamable = !select.joins && !select.groupBy && !select.having && !select.distinct
|
||||
&& !aggregate && !(select.orderBy && select.orderBy.length > 0)
|
||||
&& !(select.where && select.where['$exists'] !== undefined);
|
||||
|
||||
if (streamable && typeof this.engine.findStream === 'function') {
|
||||
// 用户回调为 async(返回 Promise)时引擎同步扫描无法 await → 回退物化
|
||||
const isAsync = (onRow as { constructor?: { name?: string } }).constructor?.name === 'AsyncFunction';
|
||||
if (!isAsync) {
|
||||
const where = this.normalizeWhereForStream(select);
|
||||
const plainCols = select.columns.filter((c) => !/\s+AS\s+\w+$/i.test(c));
|
||||
return this.engine.findStream(select.from, {
|
||||
table: select.from,
|
||||
columns: plainCols.length > 0 && plainCols[0] !== '*' ? plainCols : ['*'],
|
||||
where: where && Object.keys(where).length > 0 ? where : undefined,
|
||||
limit: select.limit,
|
||||
offset: select.offset,
|
||||
}, onRow as (row: Record<string, unknown>) => void);
|
||||
}
|
||||
}
|
||||
|
||||
// 回退:物化后逐行回调
|
||||
const result = await this.query(sql);
|
||||
if (Array.isArray(result)) {
|
||||
for (const row of result as T[]) {
|
||||
await onRow(row);
|
||||
}
|
||||
return result.length;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** 流式查询用:剥离主表别名前缀(复用 query 路径的规范化逻辑) */
|
||||
private normalizeWhereForStream(select: import('./query/ast').SelectStatement): import('./constants').WhereCondition | undefined {
|
||||
const aliases = [select.alias ?? select.from].filter(Boolean);
|
||||
const strip = (col: string): string => {
|
||||
for (const a of aliases) {
|
||||
if (col.startsWith(`${a}.`)) return col.slice(a.length + 1);
|
||||
}
|
||||
return col;
|
||||
};
|
||||
const walk = (w: import('./constants').WhereCondition): import('./constants').WhereCondition => {
|
||||
const out: import('./constants').WhereCondition = {};
|
||||
for (const [k, v] of Object.entries(w)) {
|
||||
if (k === '$and' || k === '$or') {
|
||||
out[k] = (v as import('./constants').WhereCondition[]).map(walk);
|
||||
} else if (k === '$not' && typeof v === 'object' && v !== null) {
|
||||
out.$not = walk(v as import('./constants').WhereCondition);
|
||||
} else {
|
||||
out[strip(k)] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
return walk(select.where ?? {});
|
||||
}
|
||||
|
||||
// ---- 事务 ----
|
||||
|
||||
/** 执行事务 */
|
||||
|
||||
+384
-28
@@ -2,7 +2,7 @@
|
||||
* AriaEngine — 自研页面式存储引擎主类
|
||||
* @module engine/aria/index
|
||||
*
|
||||
* v0.2.5: WAL 同步修复 + MVCC 接入 + 版本统一 + 生产加固
|
||||
* v0.4.1: 外键级联 + ALTER TABLE 重写 + clearAll 重置 + 崩溃恢复加固
|
||||
*/
|
||||
|
||||
import type { IStorageEngine } from '../interface';
|
||||
@@ -161,10 +161,22 @@ export class AriaEngine implements IStorageEngine {
|
||||
// 第二遍:仅应用 txnId==0(非事务)或已提交事务的数据
|
||||
for (const r of allRecords) {
|
||||
if (r.txnId === 0 || committedTxns.has(r.txnId)) {
|
||||
this.applyWALRecord(r);
|
||||
if (r.type === WALRecordType.DROP_TABLE) {
|
||||
// v0.3.3: DROP_TABLE 回放(异步:需预加载 SSTable 后清除残留数据)
|
||||
await this.applyDropTableRecovery(r.tableName);
|
||||
} else {
|
||||
this.applyWALRecord(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// v0.3.3: 恢复完成后将回放数据落盘并截断 WAL,
|
||||
// 避免每次重启重复回放 + WAL 无限膨胀
|
||||
if (allRecords.length > 0) {
|
||||
await this.lsm.flush();
|
||||
await this.wal.checkpoint();
|
||||
}
|
||||
|
||||
// 8. Checkpoint Manager(接入 WAL 大小阈值)
|
||||
this.checkpointManager = new CheckpointManager(
|
||||
this.lsm,
|
||||
@@ -187,6 +199,29 @@ export class AriaEngine implements IStorageEngine {
|
||||
this.opened = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.4.1: 重置数据库 — 清空全部数据与表结构(演示页刷新/重新初始化用)。
|
||||
* 清空存储后端、LSM、WAL、MVCC 与二级索引,后续可继续使用本实例。
|
||||
*/
|
||||
async clearAll(): Promise<void> {
|
||||
this.ensureOpen();
|
||||
// 清空存储后端(页面文件 / WAL 记录 / schema 记录 / 元数据)
|
||||
await this.backend.clear();
|
||||
this.schemas.clear();
|
||||
this.tablePKs.clear();
|
||||
this.secondaryIndexes.clear();
|
||||
this.lsm.clear();
|
||||
this.mvcc = new MVCCManager();
|
||||
this.currentTxnId = null;
|
||||
this.txnSnapshot = null;
|
||||
this.savepoints.clear();
|
||||
this.opCounter = 0;
|
||||
// 持久化空 schema(防止旧 schema 记录残留)
|
||||
await this.persistSchemas();
|
||||
// 重置 WAL 状态(backend.clear 已清记录,同步内存计数)
|
||||
await this.wal.checkpoint();
|
||||
}
|
||||
|
||||
isOpen(): boolean { return this.opened; }
|
||||
|
||||
// =======================================================================
|
||||
@@ -203,8 +238,9 @@ export class AriaEngine implements IStorageEngine {
|
||||
this.tablePKs.set(schema.name, this.getPK(schema));
|
||||
|
||||
// 为索引列创建二级索引 LSM(每个索引使用独立命名空间的 SSTableStore,避免 id/meta 冲突)
|
||||
// v0.3.3: 主键列不建冗余二级索引(主 LSM 本身就是 PK 索引,范围查询走前缀扫描)
|
||||
for (const [colName, colDef] of Object.entries(schema.columns)) {
|
||||
if (colDef.index || colDef.unique || colDef.primaryKey) {
|
||||
if (colDef.index || colDef.unique) {
|
||||
const idxKey = `${schema.name}:idx:${colName}`;
|
||||
if (!this.secondaryIndexes.has(idxKey)) {
|
||||
const idxLsm = new LSM({
|
||||
@@ -344,24 +380,8 @@ export class AriaEngine implements IStorageEngine {
|
||||
rows = await this.getAllRows(tableName);
|
||||
}
|
||||
|
||||
// Merge transaction snapshot writes (uncommitted data visible within txn)
|
||||
if (this.currentTxnId && this.txnSnapshot) {
|
||||
const pkCol = this.tablePKs.get(tableName)!;
|
||||
const prefix = `${tableName}:`;
|
||||
for (const [key, value] of this.txnSnapshot) {
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
const pk = key.slice(prefix.length);
|
||||
const del = (value as unknown as Record<string, unknown>).__txn_deleted;
|
||||
const idx = rows.findIndex((r) => r[pkCol] === pk);
|
||||
if (del) {
|
||||
if (idx >= 0) rows.splice(idx, 1);
|
||||
} else {
|
||||
const row = { ...value, [pkCol]: pk };
|
||||
if (idx >= 0) rows[idx] = row;
|
||||
else rows.push(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
// v0.3.3: 事务内合并未提交快照(统一在 mergeTxnSnapshot 处理)
|
||||
rows = this.mergeTxnSnapshot(tableName, rows);
|
||||
|
||||
// WHERE filter
|
||||
if (query.where && Object.keys(query.where).length > 0) {
|
||||
@@ -447,12 +467,16 @@ export class AriaEngine implements IStorageEngine {
|
||||
let count = 0;
|
||||
// v0.3.1: 批量 WAL 写入(组提交)
|
||||
const walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[] = [];
|
||||
// v0.4.1: 外键级联(环路保护)
|
||||
const visited = new Set<string>();
|
||||
|
||||
for (const row of rows) {
|
||||
const pkCol = this.tablePKs.get(tableName)!;
|
||||
const key = `${tableName}:${row[pkCol]}`;
|
||||
|
||||
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
|
||||
// v0.4.1: 外键规则(RESTRICT 抛错 / CASCADE 递归删 / SET NULL 置空)
|
||||
count += await this.applyForeignKeyRules(tableName, String(row[pkCol]), walRecords, visited);
|
||||
if (this.currentTxnId && this.txnSnapshot) {
|
||||
// Buffer delete in snapshot + MVCC tombstone
|
||||
this.txnSnapshot.set(key, { __txn_deleted: true } as unknown as Record<string, unknown>);
|
||||
@@ -482,6 +506,154 @@ export class AriaEngine implements IStorageEngine {
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.4.1: 外键级联规则 — 对齐 MemoryEngine.cascadeDelete 行为。
|
||||
* 删除 tableName 主键为 pkValue 的行前,检查引用它的所有表:
|
||||
* - RESTRICT: 存在引用行 → 抛 FOREIGN_KEY_VIOLATION
|
||||
* - CASCADE: 递归删除引用行(含索引/WAL)
|
||||
* - SET NULL: 引用行外键列置 null(含索引/WAL)
|
||||
* @returns 级联影响的行数(CASCADE 删除行数 + SET NULL 更新行数)
|
||||
*/
|
||||
private async applyForeignKeyRules(
|
||||
tableName: string,
|
||||
pkValue: string,
|
||||
walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[],
|
||||
visited: Set<string>,
|
||||
): Promise<number> {
|
||||
let total = 0;
|
||||
const visitKey = `${tableName}:${pkValue}`;
|
||||
if (visited.has(visitKey)) return 0;
|
||||
visited.add(visitKey);
|
||||
|
||||
for (const [refTableName, refSchema] of this.schemas) {
|
||||
if (refTableName === tableName) continue;
|
||||
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
|
||||
if (!colDef.references || !colDef.onDelete) continue;
|
||||
const [refTable] = colDef.references.split('.');
|
||||
if (refTable !== tableName) continue;
|
||||
|
||||
const refRows = await this.getAllRows(refTableName);
|
||||
const matched = refRows.filter((r) => String(r[colName]) === pkValue);
|
||||
|
||||
if (colDef.onDelete === 'RESTRICT' && matched.length > 0) {
|
||||
throw new DatabaseError(
|
||||
`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" has dependent rows`,
|
||||
'FOREIGN_KEY_VIOLATION',
|
||||
);
|
||||
}
|
||||
|
||||
if (colDef.onDelete === 'CASCADE') {
|
||||
const refPkCol = this.tablePKs.get(refTableName)!;
|
||||
for (const refRow of matched) {
|
||||
const refPk = String(refRow[refPkCol]);
|
||||
// 递归级联(先处理更深层引用)
|
||||
total += await this.applyForeignKeyRules(refTableName, refPk, walRecords, visited);
|
||||
// 删除引用行
|
||||
const refKey = `${refTableName}:${refPk}`;
|
||||
if (this.currentTxnId && this.txnSnapshot) {
|
||||
this.txnSnapshot.set(refKey, { __txn_deleted: true } as unknown as Record<string, unknown>);
|
||||
this.mvcc.deleteVersion(refTableName, refPk, this.currentTxnId);
|
||||
} else {
|
||||
this.lsm.delete(refKey);
|
||||
}
|
||||
this.updateSecondaryIndexes(refTableName, refPk, null, refRow);
|
||||
walRecords.push({
|
||||
type: WALRecordType.DELETE,
|
||||
txnId: this.currentTxnId ?? 0,
|
||||
tableName: refTableName,
|
||||
key: refPk,
|
||||
});
|
||||
total++;
|
||||
}
|
||||
} else if (colDef.onDelete === 'SET NULL') {
|
||||
const refPkCol = this.tablePKs.get(refTableName)!;
|
||||
for (const refRow of matched) {
|
||||
const refPk = String(refRow[refPkCol]);
|
||||
const updated = { ...refRow, [colName]: null };
|
||||
const refKey = `${refTableName}:${refPk}`;
|
||||
if (this.currentTxnId && this.txnSnapshot) {
|
||||
this.txnSnapshot.set(refKey, updated);
|
||||
this.mvcc.writeVersion(refTableName, refPk, updated, this.currentTxnId);
|
||||
} else {
|
||||
this.lsm.put(refKey, updated);
|
||||
}
|
||||
this.updateSecondaryIndexes(refTableName, refPk, updated, refRow);
|
||||
walRecords.push({
|
||||
type: WALRecordType.UPDATE,
|
||||
txnId: this.currentTxnId ?? 0,
|
||||
tableName: refTableName,
|
||||
key: refPk,
|
||||
data: updated,
|
||||
});
|
||||
// 对齐 Memory 语义:SET NULL 不影响返回的删除行数
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.4.0: 流式查询 — 逐行回调,不物化结果数组。
|
||||
* 全表路径走 LSM rangeScanLazy 惰性扫描;索引等值/范围路径复用 tryIndexLookup。
|
||||
* 事务中回退物化(快照合并需要全量行集)。
|
||||
*/
|
||||
async findStream(
|
||||
tableName: string,
|
||||
query: QueryPlan,
|
||||
onRow: (row: Record<string, unknown>) => void,
|
||||
): Promise<number> {
|
||||
this.ensureOpen();
|
||||
this.ensureTable(tableName);
|
||||
|
||||
const hasWhere = !!(query.where && Object.keys(query.where).length > 0);
|
||||
const project = query.columns && query.columns.length > 0 && query.columns[0] !== '*'
|
||||
? (row: Record<string, unknown>) => projectColumns(row, query.columns!)
|
||||
: null;
|
||||
const limit = query.limit ?? Infinity;
|
||||
const offset = query.offset ?? 0;
|
||||
const pkCol = this.tablePKs.get(tableName)!;
|
||||
const prefix = `${tableName}:`;
|
||||
let count = 0;
|
||||
let skipped = 0;
|
||||
|
||||
const emit = (row: Record<string, unknown>): boolean => {
|
||||
if (hasWhere && !matchWhere(row, query.where!)) return true;
|
||||
if (skipped < offset) { skipped++; return true; }
|
||||
onRow(project ? project(row) : row);
|
||||
count++;
|
||||
return count < limit;
|
||||
};
|
||||
|
||||
if (this.currentTxnId && this.txnSnapshot) {
|
||||
// 事务中:物化后逐行回调(快照合并需要全量行集)
|
||||
const rows = await this.find(tableName, { ...query, orderBy: undefined, limit: undefined, offset: undefined });
|
||||
for (const row of rows) {
|
||||
onRow(project ? project(row) : row);
|
||||
}
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
// 索引路径:等值/范围查找(结果行已过滤,直接回调)
|
||||
const fastPath = await this.tryIndexLookup(tableName, query);
|
||||
if (fastPath !== null) {
|
||||
for (const row of fastPath) {
|
||||
if (!emit(row)) break;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// 全表惰性扫描(含 WHERE 过滤,不物化)
|
||||
await this.lsm.prefetchRange(prefix, `${prefix}\uffff`);
|
||||
this.lsm.rangeScanLazy(prefix, `${prefix}\uffff`, (key, value) => {
|
||||
if (count >= limit) return;
|
||||
const row = { ...value };
|
||||
row[pkCol] = key.slice(prefix.length);
|
||||
emit(row);
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
async count(tableName: string, query?: QueryPlan): Promise<number> {
|
||||
this.ensureOpen();
|
||||
this.ensureTable(tableName);
|
||||
@@ -495,13 +667,93 @@ export class AriaEngine implements IStorageEngine {
|
||||
this.ensureOpen();
|
||||
this.ensureTable(tableName);
|
||||
const rows = await this.getAllRows(tableName);
|
||||
// v0.3.3: 事务内清空走快照(删除标记),提交时生效;并写入 WAL
|
||||
const walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[] = [];
|
||||
for (const row of rows) {
|
||||
const pkCol = this.tablePKs.get(tableName)!;
|
||||
this.lsm.delete(`${tableName}:${row[pkCol]}`);
|
||||
const key = `${tableName}:${row[pkCol]}`;
|
||||
if (this.currentTxnId && this.txnSnapshot) {
|
||||
this.txnSnapshot.set(key, { __txn_deleted: true } as unknown as Record<string, unknown>);
|
||||
this.mvcc.deleteVersion(tableName, String(row[pkCol]), this.currentTxnId);
|
||||
} else {
|
||||
this.lsm.delete(key);
|
||||
}
|
||||
walRecords.push({
|
||||
type: WALRecordType.DELETE,
|
||||
txnId: this.currentTxnId ?? 0,
|
||||
tableName,
|
||||
key: String(row[pkCol]),
|
||||
});
|
||||
// 移除二级索引
|
||||
this.updateSecondaryIndexes(tableName, String(row[pkCol]), null, row);
|
||||
}
|
||||
await this.wal.appendBatch(walRecords);
|
||||
this.opCounter += rows.length;
|
||||
await this.checkpointManager.tick();
|
||||
this.tryGC();
|
||||
}
|
||||
|
||||
// ---- 动态索引(v0.3.0) ----
|
||||
// ---- ALTER TABLE(v0.4.1) ----
|
||||
|
||||
/**
|
||||
* v0.4.1: ALTER TABLE — 结构变更真正生效于存储:
|
||||
* - ADD: 持久化 schema(persistSchemas),行无需修改
|
||||
* - DROP: 持久化 schema + 遍历主 LSM 重写所有行(移除该列键)+ WAL UPDATE 记录
|
||||
* (通用路径 getTableSchema 返回副本,Executor 的引用修改对 Aria 无效)
|
||||
*/
|
||||
async alterTable(
|
||||
tableName: string,
|
||||
action: 'ADD' | 'DROP',
|
||||
column: import('../../constants').ColumnDef & { name: string },
|
||||
): Promise<void> {
|
||||
this.ensureOpen();
|
||||
this.ensureTable(tableName);
|
||||
const schema = this.schemas.get(tableName)!;
|
||||
|
||||
if (action === 'ADD') {
|
||||
if (schema.columns[column.name]) {
|
||||
throw new DatabaseError(`Column "${column.name}" already exists in table "${tableName}"`, 'COLUMN_EXISTS');
|
||||
}
|
||||
schema.columns[column.name] = column;
|
||||
await this.persistSchemas();
|
||||
return;
|
||||
}
|
||||
|
||||
// DROP
|
||||
if (!schema.columns[column.name]) {
|
||||
throw new DatabaseError(`Column "${column.name}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND');
|
||||
}
|
||||
delete schema.columns[column.name];
|
||||
await this.persistSchemas();
|
||||
|
||||
// 重写主 LSM:移除所有行的该列键(find 副本无法就地删除,必须重写存储)
|
||||
const pkCol = this.tablePKs.get(tableName)!;
|
||||
const prefix = `${tableName}:`;
|
||||
const endKey = `${prefix}\uffff`;
|
||||
await this.lsm.prefetchRange(prefix, endKey);
|
||||
const entries = this.lsm.rangeScan(prefix, endKey);
|
||||
const walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[] = [];
|
||||
for (const [key, value] of entries) {
|
||||
if (!(column.name in value)) continue;
|
||||
const updated = { ...value };
|
||||
delete updated[column.name];
|
||||
this.lsm.put(key, updated);
|
||||
// 二级索引列被删时同步清理索引
|
||||
const pk = key.slice(prefix.length);
|
||||
this.updateSecondaryIndexes(tableName, pk, updated, value);
|
||||
walRecords.push({
|
||||
type: WALRecordType.UPDATE,
|
||||
txnId: this.currentTxnId ?? 0,
|
||||
tableName,
|
||||
key: pk,
|
||||
data: updated,
|
||||
});
|
||||
}
|
||||
await this.wal.appendBatch(walRecords);
|
||||
this.opCounter += walRecords.length;
|
||||
await this.checkpointManager.tick();
|
||||
this.trimAllCaches();
|
||||
}
|
||||
|
||||
async createIndex(tableName: string, column: string, unique?: boolean): Promise<void> {
|
||||
this.ensureOpen();
|
||||
@@ -551,6 +803,10 @@ export class AriaEngine implements IStorageEngine {
|
||||
if (colDef.primaryKey) {
|
||||
throw new DatabaseError(`Cannot drop primary key index on column "${column}"`, 'NOT_SUPPORTED');
|
||||
}
|
||||
// v0.4.1: DROP 不存在的索引应报错(此前静默成功)
|
||||
if (!colDef.index && !colDef.unique && !this.secondaryIndexes.has(`${tableName}:idx:${column}`)) {
|
||||
throw new DatabaseError(`Index on column "${column}" does not exist in table "${tableName}"`, 'INDEX_NOT_FOUND');
|
||||
}
|
||||
colDef.index = false;
|
||||
colDef.unique = false;
|
||||
|
||||
@@ -610,6 +866,15 @@ export class AriaEngine implements IStorageEngine {
|
||||
async rollbackTransaction(): Promise<void> {
|
||||
if (!this.currentTxnId) throw new DatabaseError('No active transaction', 'TX_NONE');
|
||||
|
||||
// v0.3.3: 记录事务涉及的表(用于回滚后重建索引,消除索引残留)
|
||||
const affectedTables = new Set<string>();
|
||||
if (this.txnSnapshot) {
|
||||
for (const key of this.txnSnapshot.keys()) {
|
||||
const idx = key.indexOf(':');
|
||||
if (idx > 0) affectedTables.add(key.slice(0, idx));
|
||||
}
|
||||
}
|
||||
|
||||
this.mvcc.rollbackTransaction(this.currentTxnId);
|
||||
this.txnSnapshot = null;
|
||||
|
||||
@@ -621,6 +886,13 @@ export class AriaEngine implements IStorageEngine {
|
||||
});
|
||||
|
||||
this.currentTxnId = null;
|
||||
|
||||
// v0.3.3: 事务内直接写入了二级索引 LSM,回滚后全量重建受影响表的索引
|
||||
for (const tableName of affectedTables) {
|
||||
if (this.schemas.has(tableName)) {
|
||||
await this.reindexTable(tableName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Savepoint 嵌套事务 ----
|
||||
@@ -642,6 +914,9 @@ export class AriaEngine implements IStorageEngine {
|
||||
if (!sp) throw new DatabaseError(`Savepoint "${name}" not found`, 'SAVEPOINT_NOT_FOUND');
|
||||
// 恢复到 savepoint 时的快照
|
||||
this.txnSnapshot = sp.snapshot ? new Map(sp.snapshot) : null;
|
||||
// v0.3.3: 清理该事务在 MVCC 版本链中的全部记录(快照已含正确数据,
|
||||
// 版本链仅作 undo 记录,清空后 commit 时 LSM 写入与快照保持一致)
|
||||
this.mvcc.discardVersions(this.currentTxnId!);
|
||||
// 清除此 savepoint 之后的所有 savepoint
|
||||
let found = false;
|
||||
for (const [k] of this.savepoints) {
|
||||
@@ -676,11 +951,37 @@ export class AriaEngine implements IStorageEngine {
|
||||
// 预加载范围内涉及的 SSTable,避免 rangeScan 时缓存未命中静默丢数据
|
||||
await this.lsm.prefetchRange(prefix, `${prefix}\uffff`);
|
||||
const entries = this.lsm.rangeScan(prefix, `${prefix}\uffff`);
|
||||
return entries.map(([key, value]) => {
|
||||
const rows = entries.map(([key, value]) => {
|
||||
const row = { ...value };
|
||||
row[pkCol] = key.slice(prefix.length);
|
||||
return row;
|
||||
});
|
||||
// v0.3.3: 事务内合并未提交快照(update/delete/count/clear 也能看到本事务的写入)
|
||||
return this.mergeTxnSnapshot(tableName, rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.3.3: 将事务未提交快照的变更合并到行列表(新增/更新/删除标记)。
|
||||
* 幂等操作:行已是最新时不重复修改。
|
||||
*/
|
||||
private mergeTxnSnapshot(tableName: string, rows: Record<string, unknown>[]): Record<string, unknown>[] {
|
||||
if (!this.currentTxnId || !this.txnSnapshot) return rows;
|
||||
const pkCol = this.tablePKs.get(tableName)!;
|
||||
const prefix = `${tableName}:`;
|
||||
for (const [key, value] of this.txnSnapshot) {
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
const pk = key.slice(prefix.length);
|
||||
const del = (value as unknown as Record<string, unknown>).__txn_deleted;
|
||||
const idx = rows.findIndex((r) => r[pkCol] === pk);
|
||||
if (del) {
|
||||
if (idx >= 0) rows.splice(idx, 1);
|
||||
} else {
|
||||
const row = { ...value, [pkCol]: pk };
|
||||
if (idx >= 0) rows[idx] = row;
|
||||
else rows.push(row);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private getPK(schema: TableSchema): string {
|
||||
@@ -872,11 +1173,31 @@ export class AriaEngine implements IStorageEngine {
|
||||
case WALRecordType.COMMIT:
|
||||
case WALRecordType.ROLLBACK:
|
||||
case WALRecordType.BEGIN:
|
||||
case WALRecordType.DROP_TABLE:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.3.3: DROP_TABLE 恢复 — 删除 schema 并清除主 LSM 中该表的所有残留数据。
|
||||
*
|
||||
* 此前 DROP_TABLE 在恢复时被忽略,而 CREATE_TABLE 回放会重建 schema,
|
||||
* 导致崩溃后"已删除的表和数据复活"(实证 P0 bug)。
|
||||
*/
|
||||
private async applyDropTableRecovery(tableName: string): Promise<void> {
|
||||
if (!tableName) return;
|
||||
this.schemas.delete(tableName);
|
||||
this.tablePKs.delete(tableName);
|
||||
|
||||
// 清除主 LSM 中该表前缀的所有数据(含 SSTable 中的旧数据)
|
||||
const prefix = `${tableName}:`;
|
||||
const endKey = `${prefix}\uffff`;
|
||||
await this.lsm.prefetchRange(prefix, endKey);
|
||||
const entries = this.lsm.rangeScan(prefix, endKey);
|
||||
for (const [key] of entries) {
|
||||
this.lsm.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// 二级索引
|
||||
// =======================================================================
|
||||
@@ -891,7 +1212,8 @@ export class AriaEngine implements IStorageEngine {
|
||||
if (!schema) return;
|
||||
|
||||
for (const [colName, colDef] of Object.entries(schema.columns)) {
|
||||
if (!colDef.index && !colDef.unique && !colDef.primaryKey) continue;
|
||||
// v0.3.3: 主键列不建冗余二级索引(主 LSM 即 PK 索引)
|
||||
if (!colDef.index && !colDef.unique) continue;
|
||||
const idxKey = `${tableName}:idx:${colName}`;
|
||||
const idxLsm = this.secondaryIndexes.get(idxKey);
|
||||
if (!idxLsm) continue;
|
||||
@@ -947,6 +1269,32 @@ export class AriaEngine implements IStorageEngine {
|
||||
const value = this.lsm.get(key);
|
||||
return value ? [{ ...value, [pkCol]: cond.$eq }] : [];
|
||||
}
|
||||
// v0.3.3: PK $in → 主 LSM 多次精确查找(替代冗余 PK 二级索引)
|
||||
if ('$in' in cond && Array.isArray(cond.$in)) {
|
||||
const keys = cond.$in.map((v) => `${tableName}:${v}`);
|
||||
await this.lsm.prefetchKeys(keys);
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
const seen = new Set<string>(); // v0.4.1: IN 子查询可能含重复值,按 pk 去重
|
||||
for (const v of cond.$in) {
|
||||
const pk = String(v);
|
||||
if (seen.has(pk)) continue;
|
||||
const value = this.lsm.get(`${tableName}:${pk}`);
|
||||
if (value) { seen.add(pk); rows.push({ ...value, [pkCol]: pk }); }
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
// v0.3.3: PK 范围查询 → 主 LSM 前缀扫描 + 条件过滤(修复字符串算术 bug)
|
||||
if ('$gt' in cond || '$gte' in cond || '$lt' in cond || '$lte' in cond) {
|
||||
const prefix = `${tableName}:`;
|
||||
await this.lsm.prefetchRange(prefix, `${prefix}\uffff`);
|
||||
const entries = this.lsm.rangeScan(prefix, `${prefix}\uffff`);
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
for (const [key, value] of entries) {
|
||||
const candidate = { ...value, [pkCol]: key.slice(prefix.length) };
|
||||
if (matchWhere(candidate, { [pkCol]: condition })) rows.push(candidate);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
// 二级索引查找
|
||||
@@ -966,9 +1314,16 @@ export class AriaEngine implements IStorageEngine {
|
||||
// $in → 多次精确查找
|
||||
if ('$in' in c && Array.isArray(c.$in)) {
|
||||
const results: Record<string, unknown>[] = [];
|
||||
const seenPks = new Set<string>(); // v0.4.1: IN 值可能重复,按 pk 去重
|
||||
for (const val of c.$in) {
|
||||
const rows = await this.indexScanToRows(tableName, pkCol, idxLsm, String(val), String(val));
|
||||
results.push(...rows);
|
||||
for (const row of rows) {
|
||||
const pk = String(row[pkCol]);
|
||||
if (!seenPks.has(pk)) {
|
||||
seenPks.add(pk);
|
||||
results.push(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
@@ -1091,7 +1446,8 @@ export class AriaEngine implements IStorageEngine {
|
||||
let rebuiltCount = 0;
|
||||
|
||||
for (const [colName, colDef] of Object.entries(schema.columns)) {
|
||||
if (!colDef.index && !colDef.unique && !colDef.primaryKey) continue;
|
||||
// v0.3.3: 主键列不建冗余二级索引(主 LSM 即 PK 索引)
|
||||
if (!colDef.index && !colDef.unique) continue;
|
||||
const idxKey = `${tableName}:idx:${colName}`;
|
||||
const idxLsm = this.secondaryIndexes.get(idxKey);
|
||||
if (!idxLsm) continue;
|
||||
|
||||
@@ -183,6 +183,21 @@ export class MVCCManager {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.3.3: 丢弃指定事务的所有版本记录,但保留事务登记(Savepoint 回滚用)。
|
||||
* 快照数据由调用方(引擎 txnSnapshot)负责恢复。
|
||||
*/
|
||||
discardVersions(txnId: number): void {
|
||||
for (const [tableKey, versions] of this.versionStore) {
|
||||
const filtered = versions.filter((v) => v.txnId !== txnId);
|
||||
if (filtered.length === 0) {
|
||||
this.versionStore.delete(tableKey);
|
||||
} else {
|
||||
this.versionStore.set(tableKey, filtered);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过旧版本(GC)。
|
||||
* 保留每个 key 的最新 N 个已提交版本。
|
||||
|
||||
@@ -48,9 +48,14 @@ export class CheckpointManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** 估算 WAL 大小 */
|
||||
/** 估算 WAL 大小(优先真实字节数,回退到缓冲计数估算) */
|
||||
private getWALEstimatedSize(): number {
|
||||
const count = typeof this.wal.getBufferedCount === 'function' ? this.wal.getBufferedCount() : 0;
|
||||
const wal = this.wal as unknown as { getBufferedBytes?: () => number; getBufferedCount?: () => number };
|
||||
if (typeof wal.getBufferedBytes === 'function') {
|
||||
const bytes = wal.getBufferedBytes();
|
||||
if (bytes > 0) return bytes;
|
||||
}
|
||||
const count = typeof wal.getBufferedCount === 'function' ? wal.getBufferedCount() : 0;
|
||||
return count * 200;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,8 @@ export class WAL {
|
||||
private enabled: boolean;
|
||||
private buffer: Uint8Array[] = [];
|
||||
private syncMode: 'full' | 'batch' | 'none';
|
||||
/** v0.3.3: 未 checkpoint 的 WAL 累计字节数(full/batch/none 通用) */
|
||||
private bufferedBytes = 0;
|
||||
|
||||
constructor(store: WALStore, enabled: boolean = true, syncMode: 'full' | 'batch' | 'none' = 'batch') {
|
||||
this.store = store;
|
||||
@@ -74,12 +76,14 @@ export class WAL {
|
||||
if (this.syncMode === 'full') {
|
||||
try {
|
||||
await this.store.append(bytes);
|
||||
this.bufferedBytes += bytes.byteLength;
|
||||
} catch {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[AriaEngine WAL] Failed to append record');
|
||||
}
|
||||
} else if (this.syncMode === 'batch') {
|
||||
this.buffer.push(bytes);
|
||||
this.bufferedBytes += bytes.byteLength;
|
||||
}
|
||||
// 'none' mode: 不写 WAL
|
||||
}
|
||||
@@ -98,12 +102,14 @@ export class WAL {
|
||||
if (this.syncMode === 'full') {
|
||||
try {
|
||||
await this.store.append(combined);
|
||||
this.bufferedBytes += combined.byteLength;
|
||||
} catch {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[AriaEngine WAL] Failed to append batch record');
|
||||
}
|
||||
} else if (this.syncMode === 'batch') {
|
||||
this.buffer.push(combined);
|
||||
this.bufferedBytes += combined.byteLength;
|
||||
}
|
||||
// 'none' mode: 不写 WAL
|
||||
}
|
||||
@@ -165,6 +171,7 @@ export class WAL {
|
||||
await this.flush();
|
||||
await this.store.truncate();
|
||||
this.lsn = 0;
|
||||
this.bufferedBytes = 0;
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
@@ -183,6 +190,11 @@ export class WAL {
|
||||
return this.buffer.length;
|
||||
}
|
||||
|
||||
/** v0.3.3: 未 checkpoint 的 WAL 累计字节数(full/batch/none 通用) */
|
||||
getBufferedBytes(): number {
|
||||
return this.bufferedBytes;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 编解码
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -163,6 +163,20 @@ export class IndexedDBEngine implements IStorageEngine {
|
||||
return this.idbFind(tableName, query);
|
||||
}
|
||||
|
||||
/** v0.4.0: 流式查询 — IDB 批量读入后逐行回调(保持接口一致性) */
|
||||
async findStream(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number> {
|
||||
if (this.txActive) {
|
||||
return this.memoryCache.findStream(tableName, query, onRow);
|
||||
}
|
||||
const rows = await this.idbFind(tableName, { ...query, orderBy: undefined, limit: undefined, offset: undefined });
|
||||
let count = 0;
|
||||
for (const row of rows) {
|
||||
onRow(row);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
|
||||
const count = await this.memoryCache.update(tableName, query, updates);
|
||||
if (this.txActive) return count;
|
||||
|
||||
@@ -43,6 +43,9 @@ export interface IStorageEngine {
|
||||
/** 查询行 */
|
||||
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
||||
|
||||
/** v0.4.0: 流式查询 — 逐行回调扫描(有 where/limit/projection,无 orderBy 语义;有 orderBy 时实现可回退物化) */
|
||||
findStream?(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number>;
|
||||
|
||||
/** 更新行,返回影响行数 */
|
||||
update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number>;
|
||||
|
||||
@@ -55,6 +58,9 @@ export interface IStorageEngine {
|
||||
/** 清空表数据(保留结构) */
|
||||
clear(tableName: string): Promise<void>;
|
||||
|
||||
/** v0.4.1: ALTER TABLE(可选)— 引擎级结构变更(Aria 需重写存储行,其余引擎走 Executor 通用路径) */
|
||||
alterTable?(tableName: string, action: 'ADD' | 'DROP', column: import('../constants').ColumnDef & { name: string }): Promise<void>;
|
||||
|
||||
// ---- 动态索引(可选,v0.3.0) ----
|
||||
|
||||
/** 创建二级索引(CREATE INDEX) */
|
||||
|
||||
+74
-9
@@ -97,6 +97,29 @@ export class MemoryEngine implements IStorageEngine {
|
||||
return results;
|
||||
}
|
||||
|
||||
/** v0.4.0: 流式查询 — 逐行回调(单次迭代,不物化结果数组) */
|
||||
async findStream(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number> {
|
||||
this.ensureTable(tableName);
|
||||
const table = this.tables.get(tableName)!;
|
||||
const hasWhere = !!(query.where && Object.keys(query.where).length > 0);
|
||||
const limit = query.limit ?? Infinity;
|
||||
const offset = query.offset ?? 0;
|
||||
const project = query.columns && query.columns.length > 0 && query.columns[0] !== '*'
|
||||
? (row: Record<string, unknown>) => projectColumns(row, query.columns!)
|
||||
: null;
|
||||
|
||||
let count = 0;
|
||||
let skipped = 0;
|
||||
for (const row of table.values()) {
|
||||
if (hasWhere && !matchWhere(row, query.where!)) continue;
|
||||
if (skipped < offset) { skipped++; continue; }
|
||||
onRow(project ? project(row) : row);
|
||||
count++;
|
||||
if (count >= limit) break;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
|
||||
this.ensureTable(tableName);
|
||||
const schema = this.schemas.get(tableName)!;
|
||||
@@ -104,9 +127,13 @@ export class MemoryEngine implements IStorageEngine {
|
||||
let count = 0;
|
||||
for (const [pk, row] of table) {
|
||||
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
|
||||
// v0.3.3: 先移除旧值索引条目(修复 update 后唯一约束被绕过、按新值查索引丢行)
|
||||
this.removeIndexEntries(tableName, row, pk);
|
||||
const updated = { ...row, ...updates };
|
||||
this.validateRow(schema, updated);
|
||||
this.checkUniqueness(schema, updated);
|
||||
table.set(pk, updated);
|
||||
this.updateIndexes(tableName, updated, pk);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
@@ -119,6 +146,8 @@ export class MemoryEngine implements IStorageEngine {
|
||||
const toDelete: string[] = [];
|
||||
for (const [pk, row] of table) {
|
||||
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
|
||||
// v0.3.3: 删除行前清理其索引条目(修复删除后索引残留)
|
||||
this.removeIndexEntries(tableName, row, pk);
|
||||
toDelete.push(pk);
|
||||
}
|
||||
}
|
||||
@@ -177,6 +206,10 @@ export class MemoryEngine implements IStorageEngine {
|
||||
const schema = this.schemas.get(tableName)!;
|
||||
const colDef = schema.columns[column];
|
||||
if (!colDef) throw new DatabaseError(`Column "${column}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND');
|
||||
// v0.4.1: DROP 不存在的索引应报错(此前静默成功)
|
||||
if (!colDef.index && !colDef.unique) {
|
||||
throw new DatabaseError(`Index on column "${column}" does not exist in table "${tableName}"`, 'INDEX_NOT_FOUND');
|
||||
}
|
||||
colDef.index = false;
|
||||
colDef.unique = false;
|
||||
const tableIndexes = this.indexes.get(tableName);
|
||||
@@ -288,17 +321,24 @@ export class MemoryEngine implements IStorageEngine {
|
||||
const tableIndexes = this.indexes.get(tableName);
|
||||
if (!tableIndexes || !query.where) return Array.from(table.values());
|
||||
for (const [col, condition] of Object.entries(query.where)) {
|
||||
// v0.4.1: 支持 { $eq: value } 形式(SQL 解析器生成的等值条件)走索引
|
||||
let targetValue: unknown;
|
||||
if (typeof condition !== 'object' || condition === null) {
|
||||
const colIndex = tableIndexes.get(col);
|
||||
if (colIndex) {
|
||||
const pks = colIndex.get(condition);
|
||||
if (pks) {
|
||||
const result: Record<string, unknown>[] = [];
|
||||
for (const pk of pks) { const r = table.get(pk); if (r) result.push(r); }
|
||||
return result;
|
||||
}
|
||||
return [];
|
||||
targetValue = condition;
|
||||
} else if ('$eq' in (condition as Record<string, unknown>) && Object.keys(condition as Record<string, unknown>).length === 1) {
|
||||
targetValue = (condition as Record<string, unknown>).$eq;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
const colIndex = tableIndexes.get(col);
|
||||
if (colIndex) {
|
||||
const pks = colIndex.get(targetValue);
|
||||
if (pks) {
|
||||
const result: Record<string, unknown>[] = [];
|
||||
for (const pk of pks) { const r = table.get(pk); if (r) result.push(r); }
|
||||
return result;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return Array.from(table.values());
|
||||
@@ -317,6 +357,22 @@ export class MemoryEngine implements IStorageEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/** v0.3.3: 从所有索引中移除一行的条目(update/delete 前调用,修复索引过期/残留) */
|
||||
private removeIndexEntries(tableName: string, row: Record<string, unknown>, pk: string): void {
|
||||
const tableIndexes = this.indexes.get(tableName);
|
||||
if (!tableIndexes) return;
|
||||
for (const [colName, colIndex] of tableIndexes) {
|
||||
const value = row[colName];
|
||||
if (value !== undefined && value !== null) {
|
||||
const pks = colIndex.get(value);
|
||||
if (pks) {
|
||||
pks.delete(pk);
|
||||
if (pks.size === 0) colIndex.delete(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 外键级联 ----
|
||||
|
||||
/**
|
||||
@@ -359,6 +415,8 @@ export class MemoryEngine implements IStorageEngine {
|
||||
for (const refPk of toDelete) {
|
||||
const refRow = refTableData.get(refPk);
|
||||
if (refRow) {
|
||||
// v0.3.3: 级联删除前清理索引条目
|
||||
this.removeIndexEntries(refTableName, refRow, refPk);
|
||||
totalCascade += await this.cascadeDelete(refTableName, refPk, refRow);
|
||||
}
|
||||
refTableData.delete(refPk);
|
||||
@@ -368,6 +426,13 @@ export class MemoryEngine implements IStorageEngine {
|
||||
for (const refPk of toDelete) {
|
||||
const refRow = refTableData.get(refPk);
|
||||
if (refRow) {
|
||||
// v0.3.3: 外键列置空后同步更新索引
|
||||
if (refRow[colName] !== undefined && refRow[colName] !== null) {
|
||||
const pks = this.indexes.get(refTableName)?.get(colName);
|
||||
if (pks) {
|
||||
pks.get(refRow[colName])?.delete(refPk);
|
||||
}
|
||||
}
|
||||
refRow[colName] = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,11 @@ export class OPFSEngine implements IStorageEngine {
|
||||
return this.memoryCache.find(tableName, query);
|
||||
}
|
||||
|
||||
/** v0.4.0: 流式查询(委托内存缓存) */
|
||||
async findStream(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number> {
|
||||
return this.memoryCache.findStream(tableName, query, onRow);
|
||||
}
|
||||
|
||||
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
|
||||
const count = await this.memoryCache.update(tableName, query, updates);
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
|
||||
@@ -125,6 +125,11 @@ export class HybridEngine implements IStorageEngine {
|
||||
return this.memoryEngine.find(tableName, query);
|
||||
}
|
||||
|
||||
/** v0.4.0: 流式查询(内存引擎逐行回调) */
|
||||
async findStream(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number> {
|
||||
return this.memoryEngine.findStream(tableName, query, onRow);
|
||||
}
|
||||
|
||||
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
|
||||
const count = await this.memoryEngine.update(tableName, query, updates);
|
||||
// write-through: 同步更新磁盘
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* metona-sqlark — 入口文件
|
||||
* @module metona-sqlark
|
||||
* @version 0.2.5
|
||||
* @version 0.4.1
|
||||
*
|
||||
* 前端关系型数据库,内存与磁盘双模式。
|
||||
* 支持 Query Builder 链式 API 和 SQL 字符串查询。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+15
-5
@@ -195,18 +195,28 @@ export class Lexer {
|
||||
this.readChar(); // 跳过开始引号
|
||||
let value = '';
|
||||
|
||||
while (this.ch !== quote && this.ch !== '') {
|
||||
// 处理转义
|
||||
while (this.ch !== '') {
|
||||
if (this.ch === quote) {
|
||||
// v0.3.3: 支持 SQL 标准 '' 转义(两个连续引号 = 一个引号)
|
||||
if (this.peekChar() === quote) {
|
||||
value += quote;
|
||||
this.readChar(); // 跳过第二个引号
|
||||
this.readChar();
|
||||
continue;
|
||||
}
|
||||
break; // 结束引号(由 nextToken 的 readChar 跳过)
|
||||
}
|
||||
// 反斜杠转义(兼容旧语法)
|
||||
if (this.ch === '\\' && this.peekChar() === quote) {
|
||||
this.readChar();
|
||||
value += quote;
|
||||
} else {
|
||||
value += this.ch;
|
||||
this.readChar();
|
||||
continue;
|
||||
}
|
||||
value += this.ch;
|
||||
this.readChar();
|
||||
}
|
||||
|
||||
// 跳过结束引号(在 readChar 之后才会调用,所以这里不需要处理)
|
||||
return {
|
||||
type: TokenType.STRING,
|
||||
value,
|
||||
|
||||
+106
-20
@@ -212,18 +212,37 @@ export class Parser {
|
||||
columns.push(...this.parseColumnList());
|
||||
}
|
||||
|
||||
// FROM
|
||||
this.expect(TokenType.FROM);
|
||||
const tableName = this.expectIdentifier('table name');
|
||||
|
||||
// 表别名(可选)
|
||||
// FROM(v0.4.0 可选:SELECT 1 / SELECT 'lit' 无表查询)
|
||||
let fromSubquery: SelectStatement | SelectUnionStatement | undefined;
|
||||
let tableName = '';
|
||||
let alias: string | undefined;
|
||||
if (this.curTokenIs(TokenType.AS)) {
|
||||
this.nextToken();
|
||||
alias = this.expectIdentifier('alias');
|
||||
} else if (this.curToken.type === TokenType.IDENTIFIER && !this._isReservedAfterFrom()) {
|
||||
alias = this.curToken.value;
|
||||
if (this.curTokenIs(TokenType.FROM)) {
|
||||
this.nextToken();
|
||||
|
||||
// v0.4.0: FROM (SELECT ...) AS alias 派生表
|
||||
if (this.curTokenIs(TokenType.LPAREN)) {
|
||||
this.nextToken();
|
||||
fromSubquery = this.parseSelect();
|
||||
this.expect(TokenType.RPAREN);
|
||||
if (this.curTokenIs(TokenType.AS)) {
|
||||
this.nextToken();
|
||||
alias = this.expectIdentifier('alias');
|
||||
} else if (this.curToken.type === TokenType.IDENTIFIER && !this._isReservedAfterFrom()) {
|
||||
alias = this.curToken.value;
|
||||
this.nextToken();
|
||||
}
|
||||
} else {
|
||||
tableName = this.expectIdentifier('table name');
|
||||
|
||||
// 表别名(可选)
|
||||
if (this.curTokenIs(TokenType.AS)) {
|
||||
this.nextToken();
|
||||
alias = this.expectIdentifier('alias');
|
||||
} else if (this.curToken.type === TokenType.IDENTIFIER && !this._isReservedAfterFrom()) {
|
||||
alias = this.curToken.value;
|
||||
this.nextToken();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stmt: SelectStatement = {
|
||||
@@ -234,6 +253,9 @@ export class Parser {
|
||||
alias,
|
||||
where: {},
|
||||
};
|
||||
if (fromSubquery) {
|
||||
stmt.fromSubquery = fromSubquery;
|
||||
}
|
||||
|
||||
// JOIN 子句(可选,支持多个)
|
||||
const joins = this.parseJoinClauses();
|
||||
@@ -824,6 +846,17 @@ export class Parser {
|
||||
return result;
|
||||
}
|
||||
|
||||
// v0.4.1: 裸布尔列条件(WHERE done / CASE WHEN done THEN)— 列后直接是终止符时视为真值判断
|
||||
if (
|
||||
this.curTokenIs(TokenType.AND) || this.curTokenIs(TokenType.OR) ||
|
||||
this.curTokenIs(TokenType.RPAREN) || this.curTokenIs(TokenType.EOF) ||
|
||||
(this.curToken.type === TokenType.IDENTIFIER && ['THEN', 'END', 'ELSE', 'NULLS', 'LIMIT', 'OFFSET', 'ORDER', 'GROUP', 'HAVING', 'UNION', 'WHERE'].includes(this.curToken.value.toUpperCase()))
|
||||
) {
|
||||
const result: WhereCondition = {};
|
||||
result[column] = { $eq: true };
|
||||
return result;
|
||||
}
|
||||
|
||||
// 比较运算符
|
||||
const op = this.parseComparisonOp();
|
||||
|
||||
@@ -897,15 +930,30 @@ export class Parser {
|
||||
|
||||
private parseColumnList(): string[] {
|
||||
const cols: string[] = [];
|
||||
cols.push(this.parseColumnRef());
|
||||
cols.push(this.parseColumnWithAlias());
|
||||
while (this.curTokenIs(TokenType.COMMA)) {
|
||||
this.nextToken();
|
||||
cols.push(this.parseColumnRef());
|
||||
cols.push(this.parseColumnWithAlias());
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
|
||||
/** 解析列引用:支持 'col'、'table.col'、'COUNT(*)'/'SUM(col)'、数字常量列(SELECT 1)和 CASE WHEN 表达式(v0.3.1) */
|
||||
/** v0.3.3: 解析列(支持 `col AS alias` 显式别名与 `col alias` 隐式别名) */
|
||||
private parseColumnWithAlias(): string {
|
||||
let col = this.parseColumnRef();
|
||||
if (this.curTokenIs(TokenType.AS)) {
|
||||
this.nextToken();
|
||||
const alias = this.expectIdentifier('alias');
|
||||
col = `${col} AS ${alias}`;
|
||||
} else if (this.curToken.type === TokenType.IDENTIFIER && !this._isReservedAfterFrom() && !this._isJoinKeyword()) {
|
||||
const alias = this.curToken.value;
|
||||
this.nextToken();
|
||||
col = `${col} AS ${alias}`;
|
||||
}
|
||||
return col;
|
||||
}
|
||||
|
||||
/** 解析列引用:支持 'col'、'table.col'、'COUNT(*)'/'SUM(col)'、数字常量列(SELECT 1)、字符串常量列(SELECT 'x',v0.4.0)和 CASE WHEN 表达式(v0.3.1) */
|
||||
private parseColumnRef(): string {
|
||||
// CASE WHEN 表达式(v0.3.1)
|
||||
if (this.curTokenIs(TokenType.CASE)) {
|
||||
@@ -919,6 +967,13 @@ export class Parser {
|
||||
return value;
|
||||
}
|
||||
|
||||
// v0.4.0: 字符串常量列:SELECT 'value' FROM t
|
||||
if (this.curTokenIs(TokenType.STRING)) {
|
||||
const value = this.curToken.value;
|
||||
this.nextToken();
|
||||
return `'${value}'`;
|
||||
}
|
||||
|
||||
// 聚合函数?
|
||||
if (
|
||||
this.curTokenIs(TokenType.COUNT) ||
|
||||
@@ -973,11 +1028,19 @@ export class Parser {
|
||||
return text;
|
||||
}
|
||||
|
||||
/** 解析聚合函数调用: COUNT(*), SUM(col), AVG(col), MIN(col), MAX(col) */ private parseAggregateCall(): string {
|
||||
/** 解析聚合函数调用: COUNT(*), SUM(col), AVG(col), MIN(col), MAX(col),v0.4.0 支持 COUNT(DISTINCT col) */
|
||||
private parseAggregateCall(): string {
|
||||
const func = this.curToken.value.toUpperCase();
|
||||
this.nextToken();
|
||||
this.expect(TokenType.LPAREN);
|
||||
|
||||
// v0.4.0: COUNT(DISTINCT col) 等去重聚合
|
||||
let distinct = false;
|
||||
if (this.curTokenIs(TokenType.DISTINCT)) {
|
||||
distinct = true;
|
||||
this.nextToken();
|
||||
}
|
||||
|
||||
let arg: string;
|
||||
if (this.curTokenIs(TokenType.STAR)) {
|
||||
arg = '*';
|
||||
@@ -998,10 +1061,11 @@ export class Parser {
|
||||
this.nextToken();
|
||||
}
|
||||
|
||||
const inner = distinct ? `DISTINCT ${arg}` : arg;
|
||||
if (alias) {
|
||||
return `${func}(${arg}) AS ${alias}`;
|
||||
return `${func}(${inner}) AS ${alias}`;
|
||||
}
|
||||
return `${func}(${arg})`;
|
||||
return `${func}(${inner})`;
|
||||
}
|
||||
|
||||
private _isAggregateAlias(): boolean {
|
||||
@@ -1010,10 +1074,10 @@ export class Parser {
|
||||
|
||||
private parseIdentifierList(): string[] {
|
||||
const ids: string[] = [];
|
||||
ids.push(this.expectIdentifier('identifier'));
|
||||
ids.push(this.parseIdentifierWithDot());
|
||||
while (this.curTokenIs(TokenType.COMMA)) {
|
||||
this.nextToken();
|
||||
ids.push(this.expectIdentifier('identifier'));
|
||||
ids.push(this.parseIdentifierWithDot());
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
@@ -1039,7 +1103,7 @@ export class Parser {
|
||||
}
|
||||
|
||||
private parseOrderBy(): OrderBy {
|
||||
const column = this.expectIdentifier('column name');
|
||||
const column = this.parseIdentifierWithDot();
|
||||
let direction: SortDirection = 'asc';
|
||||
if (this.curTokenIs(TokenType.ASC)) {
|
||||
this.nextToken();
|
||||
@@ -1047,7 +1111,29 @@ export class Parser {
|
||||
direction = 'desc';
|
||||
this.nextToken();
|
||||
}
|
||||
return { column, direction };
|
||||
// v0.4.0: NULLS FIRST / NULLS LAST
|
||||
let nulls: 'first' | 'last' | undefined;
|
||||
if (this.curTokenIs(TokenType.IDENTIFIER) && this.curToken.value.toUpperCase() === 'NULLS') {
|
||||
this.nextToken();
|
||||
if (this.curTokenIs(TokenType.IDENTIFIER) && this.curToken.value.toUpperCase() === 'FIRST') {
|
||||
nulls = 'first';
|
||||
this.nextToken();
|
||||
} else if (this.curTokenIs(TokenType.IDENTIFIER) && this.curToken.value.toUpperCase() === 'LAST') {
|
||||
nulls = 'last';
|
||||
this.nextToken();
|
||||
}
|
||||
}
|
||||
return { column, direction, ...(nulls ? { nulls } : {}) };
|
||||
}
|
||||
|
||||
/** v0.4.0: 标识符(支持 'table.column' 带表前缀引用,用于 ORDER BY / GROUP BY) */
|
||||
private parseIdentifierWithDot(): string {
|
||||
const first = this.expectIdentifier('identifier');
|
||||
if (this.curTokenIs(TokenType.DOT)) {
|
||||
this.nextToken();
|
||||
return `${first}.${this.expectIdentifier('identifier')}`;
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
/** 解析字面量值 */
|
||||
|
||||
@@ -59,6 +59,31 @@ export class Table<T = Record<string, unknown>> {
|
||||
return new SelectQueryBuilder(this.engine, this.name, columns, this.executor);
|
||||
}
|
||||
|
||||
/** v0.4.0: 流式查询 — 逐行回调,不物化全部结果 */
|
||||
async stream(
|
||||
onRow: (row: T & Record<string, unknown>) => void,
|
||||
query: { where?: Record<string, unknown>; limit?: number; offset?: number; columns?: string[] } = {},
|
||||
): Promise<number> {
|
||||
if (typeof this.engine.findStream !== 'function') {
|
||||
const rows = await this.engine.find(this.name, {
|
||||
table: this.name,
|
||||
where: query.where,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
columns: query.columns,
|
||||
});
|
||||
for (const row of rows) onRow(row as T & Record<string, unknown>);
|
||||
return rows.length;
|
||||
}
|
||||
return this.engine.findStream(this.name, {
|
||||
table: this.name,
|
||||
where: query.where,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
columns: query.columns,
|
||||
}, onRow as (row: Record<string, unknown>) => void);
|
||||
}
|
||||
|
||||
// ---- 更新 ----
|
||||
|
||||
update(updates: Partial<T> & Record<string, unknown>): UpdateQueryBuilder {
|
||||
|
||||
Reference in New Issue
Block a user