release: v0.4.1 — Aria 级联/ALTER/clearAll + 流式查询/派生表 + 正确性加固
CI / test (20.x) (push) Successful in 10m4s
CI / test (22.x) (push) Successful in 10m8s
CI / test (24.x) (push) Successful in 9m55s
CI / test (18.x) (push) Successful in 10m9s

新增:
- 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:
thzxx
2026-08-08 13:36:34 +08:00
parent 82ad8e93b9
commit a1e4f5071c
38 changed files with 13493 additions and 8756 deletions
+3 -1
View File
@@ -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
View File
@@ -202,6 +202,91 @@ export class MetonaSqlark {
return result;
}
// ---- 流式查询(v0.4.0 ----
/**
* 流式查询:逐行回调,不一次性物化全部结果(大表友好)。
* 支持简单 SELECTWHERE/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
View File
@@ -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 TABLEv0.4.1 ----
/**
* v0.4.1: ALTER TABLE — 结构变更真正生效于存储:
* - ADD: 持久化 schemapersistSchemas),行无需修改
* - 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;
+15
View File
@@ -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 个已提交版本。
+7 -2
View File
@@ -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;
}
+12
View File
@@ -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;
}
// -----------------------------------------------------------------------
// 编解码
// -----------------------------------------------------------------------
+14
View File
@@ -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;
+6
View File
@@ -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
View File
@@ -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;
}
}
+5
View File
@@ -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 });
+5
View File
@@ -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
View File
@@ -1,7 +1,7 @@
/**
* metona-sqlark — 入口文件
* @module metona-sqlark
* @version 0.2.5
* @version 0.4.1
*
* 前端关系型数据库,内存与磁盘双模式。
* 支持 Query Builder 链式 API 和 SQL 字符串查询。
+2
View File
@@ -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
View File
@@ -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) {
// 非 JOINWHERE 在 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: 引擎级 alterTableAria 需重写存储行 + 持久化 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):普通列走 projectColumnsCASE WHEN 表达式逐行求值
* 列投影(v0.3.1):普通列走 projectColumnsCASE 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);
}
+9 -1
View File
@@ -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
View File
@@ -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
View File
@@ -212,18 +212,37 @@ export class Parser {
columns.push(...this.parseColumnList());
}
// FROM
this.expect(TokenType.FROM);
const tableName = this.expectIdentifier('table name');
// 表别名(可选)
// FROMv0.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;
}
/** 解析字面量值 */
+25
View File
@@ -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 {