fix: v0.7.4 写语句子查询 / 约束硬化 / 真惰性流式 — UPDATE-DELETE WHERE 子查询静默 0 行(四引擎,关联引用显式 NOT_SUPPORTED + EXPLAIN 同步)/ 主键 NULL-undefined 强制拒绝 / DROP INDEX 保留建表 UNIQUE(仅索引来源可解除)/ GROUP BY-DISTINCT-UNION 键类型安全编码 / UPDATE 未知列报错 / queryStream 多语句拒绝 / KVStore 后台错误跨 reopen 清理 + Hybrid begin 补偿 / RB-Tree 删除双黑修复 + LSM 死代码清理 / findStream 迭代器化真惰性(limit 早停 O(1) 内存)/ REINDEX 单次扫描 + 48 回归

This commit is contained in:
thzxx
2026-08-15 15:08:33 +08:00
parent ccfc39656b
commit d4a3f4acd2
17 changed files with 943 additions and 72 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@metona-team/metona-sqlark", "name": "@metona-team/metona-sqlark",
"version": "0.7.3", "version": "0.7.4",
"description": "Frontend SQL database with in-memory and disk dual-mode storage", "description": "Frontend SQL database with in-memory and disk dual-mode storage",
"type": "module", "type": "module",
"main": "dist/metona-sqlark.cjs", "main": "dist/metona-sqlark.cjs",
+1 -1
View File
@@ -214,4 +214,4 @@ export class DatabaseError extends Error {
// 版本 // 版本
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const VERSION = '0.7.3'; export const VERSION = '0.7.4';
+8 -1
View File
@@ -262,7 +262,14 @@ export class MetonaSqlark {
onRow: (row: T) => void, onRow: (row: T) => void,
): Promise<number> { ): Promise<number> {
this.ensureReady(); this.ensureReady();
const stmt = parseAll(sql)[0]; // v0.7.4: 多语句显式拒绝 —— 此前 parseAll(sql)[0] 静默忽略后续语句:
// 可流式时后续语句(如 DELETE)不执行,不可流式时回退 query() 却会执行
// 全部语句 → 同一条 SQL 两种语义。流式 API 要求单条 SELECT。
const statements = parseAll(sql);
if (statements.length !== 1) {
throw new DatabaseError('queryStream requires exactly one SELECT statement', 'PARSE_ERROR');
}
const stmt = statements[0];
if (!stmt || stmt.type !== 'SELECT') { if (!stmt || stmt.type !== 'SELECT') {
throw new DatabaseError('queryStream only supports SELECT statements', 'NOT_SUPPORTED'); throw new DatabaseError('queryStream only supports SELECT statements', 'NOT_SUPPORTED');
} }
+81 -10
View File
@@ -8,7 +8,7 @@
import type { IStorageEngine } from '../interface'; import type { IStorageEngine } from '../interface';
import type { QueryPlan, TableSchema, ColumnDef, WhereCondition } from '../../constants'; import type { QueryPlan, TableSchema, ColumnDef, WhereCondition } from '../../constants';
import { DatabaseError } from '../../constants'; import { DatabaseError } from '../../constants';
import { matchWhere, applyOrderBy, projectColumns } from '../../query/where-matcher'; import { matchWhere, applyOrderBy, projectColumns, containsUnresolvedSubqueries } from '../../query/where-matcher';
import { checkFieldType, stripUndefinedUpdates } from '../../table/schema'; import { checkFieldType, stripUndefinedUpdates } from '../../table/schema';
import type { AriaEngineConfig, SSTableMeta } from './types'; import type { AriaEngineConfig, SSTableMeta } from './types';
@@ -63,6 +63,14 @@ export class AriaEngine implements IStorageEngine {
// 二级索引:table.colKey → LSM // 二级索引:table.colKey → LSM
private secondaryIndexes: Map<string, LSM> = new Map(); private secondaryIndexes: Map<string, LSM> = new Map();
/**
* v0.7.4: 由 CREATE UNIQUE INDEX 添加的 unique 列(table:col)。
* 与建表 UNIQUE 约束区分:DROP INDEX 只允许解除索引来源的 unique,
* 建表约束需重建表(对齐 SQLite 语义,此前静默解除且重启后永久消失)。
* 注:重启后无法区分历史来源,schema 中的 unique 一律按建表约束保护(保守)。
*/
private uniqueIndexCols: Set<string> = new Set();
// MVCC 事务 // MVCC 事务
private mvcc: MVCCManager = new MVCCManager(); private mvcc: MVCCManager = new MVCCManager();
private currentTxnId: number | null = null; private currentTxnId: number | null = null;
@@ -297,6 +305,7 @@ export class AriaEngine implements IStorageEngine {
this.schemas.clear(); this.schemas.clear();
this.tablePKs.clear(); this.tablePKs.clear();
this.secondaryIndexes.clear(); this.secondaryIndexes.clear();
this.uniqueIndexCols.clear();
this.mvcc = new MVCCManager(); this.mvcc = new MVCCManager();
this.currentTxnId = null; this.currentTxnId = null;
this.txnSnapshot = null; this.txnSnapshot = null;
@@ -392,6 +401,7 @@ export class AriaEngine implements IStorageEngine {
this.schemas.clear(); this.schemas.clear();
this.tablePKs.clear(); this.tablePKs.clear();
this.secondaryIndexes.clear(); this.secondaryIndexes.clear();
this.uniqueIndexCols.clear();
this.lsm.clear(); this.lsm.clear();
this.mvcc = new MVCCManager(); this.mvcc = new MVCCManager();
this.currentTxnId = null; this.currentTxnId = null;
@@ -479,6 +489,11 @@ export class AriaEngine implements IStorageEngine {
// v0.4.2-fix: 清理该表的全部二级索引 LSM 与持久化文件 — // v0.4.2-fix: 清理该表的全部二级索引 LSM 与持久化文件 —
// 此前残留孤儿索引,重建同名表后旧索引数据污染新表(索引查询返回错误行) // 此前残留孤儿索引,重建同名表后旧索引数据污染新表(索引查询返回错误行)
await this.cleanupTableIndexes(tableName); await this.cleanupTableIndexes(tableName);
// v0.7.4: 清理该表的 unique 索引来源标记
const uPrefix = `${tableName}:`;
for (const k of this.uniqueIndexCols) {
if (k.startsWith(uPrefix)) this.uniqueIndexCols.delete(k);
}
this.schemas.delete(tableName); this.schemas.delete(tableName);
this.tablePKs.delete(tableName); this.tablePKs.delete(tableName);
@@ -701,6 +716,14 @@ export class AriaEngine implements IStorageEngine {
): Promise<number> { ): Promise<number> {
this.ensureOpen(); this.ensureOpen();
this.ensureTable(tableName); this.ensureTable(tableName);
// v0.7.4: 防御 —— QueryBuilder 直通引擎不经 Executor 子查询解析,
// 未解析的 $subquery/$col/$exists 在 matchWhere 中恒 false → 静默 0 行
if (containsUnresolvedSubqueries(query.where)) {
throw new DatabaseError(
'Unresolved subqueries/column references in UPDATE WHERE (use db.query() to execute subqueries)',
'NOT_SUPPORTED',
);
}
const schema = this.schemas.get(tableName)!; const schema = this.schemas.get(tableName)!;
const rows = await this.getAllRows(tableName); const rows = await this.getAllRows(tableName);
let count = 0; let count = 0;
@@ -711,6 +734,14 @@ export class AriaEngine implements IStorageEngine {
// v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空 // v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空
const cleanUpdates = stripUndefinedUpdates(updates); const cleanUpdates = stripUndefinedUpdates(updates);
// v0.7.4: 未知列显式报错 —— 此前 SET nonexistent = ... 被静默写入存储行
// validateRow 只遍历 schema 列,脏列残留在行内并随 SSTable 持久化)
for (const col of Object.keys(cleanUpdates)) {
if (!schema.columns[col]) {
throw new DatabaseError(`Column "${col}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND');
}
}
// v0.6.2: 唯一约束 — 批量预加载本批更新涉及的唯一列索引范围(一次 drainChain // v0.6.2: 唯一约束 — 批量预加载本批更新涉及的唯一列索引范围(一次 drainChain
const uniqueCols = this.uniqueColumns(tableName, schema); const uniqueCols = this.uniqueColumns(tableName, schema);
for (const colName of uniqueCols) { for (const colName of uniqueCols) {
@@ -941,6 +972,14 @@ export class AriaEngine implements IStorageEngine {
async delete(tableName: string, query: QueryPlan): Promise<number> { async delete(tableName: string, query: QueryPlan): Promise<number> {
this.ensureOpen(); this.ensureOpen();
this.ensureTable(tableName); this.ensureTable(tableName);
// v0.7.4: 防御 —— QueryBuilder 直通引擎不经 Executor 子查询解析,
// 未解析的 $subquery/$col/$exists 在 matchWhere 中恒 false → 静默 0 行
if (containsUnresolvedSubqueries(query.where)) {
throw new DatabaseError(
'Unresolved subqueries/column references in DELETE WHERE (use db.query() to execute subqueries)',
'NOT_SUPPORTED',
);
}
const rows = await this.getAllRows(tableName); const rows = await this.getAllRows(tableName);
let count = 0; let count = 0;
@@ -1175,13 +1214,14 @@ export class AriaEngine implements IStorageEngine {
return count; return count;
} }
// 全表惰性扫描(含 WHERE 过滤,不物化 // 全表惰性扫描(含 WHERE 过滤,不物化v0.7.4: callback 返回 false 提前终止,
// 未消费的 SSTable 块 / 子树不再解析 —— 真流式,大表 limit 内存 O(1)
await this.lsm.prefetchRange(prefix, `${prefix}\uffff`); await this.lsm.prefetchRange(prefix, `${prefix}\uffff`);
this.lsm.rangeScanLazy(prefix, `${prefix}\uffff`, (key, value) => { this.lsm.rangeScanLazy(prefix, `${prefix}\uffff`, (key, value) => {
if (count >= limit) return; if (count >= limit) return false;
const row = { ...value }; const row = { ...value };
row[pkCol] = key.slice(prefix.length); row[pkCol] = key.slice(prefix.length);
emit(row); return emit(row);
}); });
return count; return count;
} }
@@ -1350,7 +1390,11 @@ export class AriaEngine implements IStorageEngine {
throw error; throw error;
} }
colDef.index = true; colDef.index = true;
if (unique) colDef.unique = true; if (unique) {
colDef.unique = true;
// v0.7.4: 记录唯一约束来源(DROP INDEX 时可解除;建表约束不可)
this.uniqueIndexCols.add(`${tableName}:${column}`);
}
await this.persistSchemas(); await this.persistSchemas();
} }
@@ -1369,8 +1413,20 @@ export class AriaEngine implements IStorageEngine {
if (!colDef.index && !colDef.unique && !this.secondaryIndexes.has(`${tableName}:idx:${column}`)) { 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'); throw new DatabaseError(`Index on column "${column}" does not exist in table "${tableName}"`, 'INDEX_NOT_FOUND');
} }
// v0.7.4: 建表 UNIQUE 约束不可通过 DROP INDEX 解除 —— 此前 colDef.unique = false
// 静默解除约束(重启后 persistSchemas 使约束永久消失)。对齐 SQLite 语义:
// 约束随建表存在,解除需重建表;仅 CREATE UNIQUE INDEX 添加的约束可随索引删除。
const uniqueKey = `${tableName}:${column}`;
if (colDef.unique && !this.uniqueIndexCols.has(uniqueKey)) {
throw new DatabaseError(
`Cannot drop index on column "${column}" in table "${tableName}": ` +
'UNIQUE constraint defined at table creation must be removed by recreating the table',
'NOT_SUPPORTED',
);
}
colDef.index = false; colDef.index = false;
colDef.unique = false; colDef.unique = false;
this.uniqueIndexCols.delete(uniqueKey);
const idxKey = `${tableName}:idx:${column}`; const idxKey = `${tableName}:idx:${column}`;
const idxLsm = this.secondaryIndexes.get(idxKey); const idxLsm = this.secondaryIndexes.get(idxKey);
@@ -1596,6 +1652,14 @@ export class AriaEngine implements IStorageEngine {
if (colDef.required && (value === undefined || value === null)) { if (colDef.required && (value === undefined || value === null)) {
throw new DatabaseError(`Column "${colName}" is required in table "${schema.name}"`, 'VALIDATION_ERROR'); throw new DatabaseError(`Column "${colName}" is required in table "${schema.name}"`, 'VALIDATION_ERROR');
} }
// v0.7.4: 主键列强制非空(SQL 语义 PK 隐含 NOT NULL)——
// 此前 null/undefined 主键被 String() 化为 "null"/"undefined" 静默入库
if (colDef.primaryKey && (value === undefined || value === null)) {
throw new DatabaseError(
`Primary key column "${colName}" in table "${schema.name}" cannot be null or undefined`,
'VALIDATION_ERROR',
);
}
if (value !== undefined && value !== null) { if (value !== undefined && value !== null) {
this.checkType(colName, colDef.type, value, colDef); this.checkType(colName, colDef.type, value, colDef);
} }
@@ -2147,9 +2211,17 @@ export class AriaEngine implements IStorageEngine {
if (!schema) return 0; if (!schema) return 0;
let rebuiltCount = 0; let rebuiltCount = 0;
for (const [colName, colDef] of Object.entries(schema.columns)) { // v0.7.4-perf: 单次全表扫描重建全部索引列 —— 此前每个索引列各做一次
// v0.3.3: 主键列不建冗余二级索引(主 LSM 即 PK 索引) // getAllRowsN 列 × M 行全表扫描 + 每次 prefetchRange drainChain),
if (!colDef.index && !colDef.unique) continue; // 多索引大表 REINDEX/崩溃恢复按索引列数线性放大
const pkCol = this.tablePKs.get(tableName)!;
const idxCols = Object.entries(schema.columns).filter(
([, colDef]) => colDef.index || colDef.unique,
);
if (idxCols.length === 0) return 0;
const rows = await this.getAllRows(tableName);
for (const [colName] of idxCols) {
const idxKey = `${tableName}:idx:${colName}`; const idxKey = `${tableName}:idx:${colName}`;
const idxLsm = this.secondaryIndexes.get(idxKey); const idxLsm = this.secondaryIndexes.get(idxKey);
if (!idxLsm) continue; if (!idxLsm) continue;
@@ -2159,11 +2231,10 @@ export class AriaEngine implements IStorageEngine {
rebuiltCount++; rebuiltCount++;
// 从主 LSM 重建索引 // 从主 LSM 重建索引
const rows = await this.getAllRows(tableName);
for (const row of rows) { for (const row of rows) {
const val = row[colName]; const val = row[colName];
if (val !== undefined && val !== null) { if (val !== undefined && val !== null) {
idxLsm.put(`${String(val)}:${row[this.tablePKs.get(tableName)!]}`, { pk: row[this.tablePKs.get(tableName)!] }); idxLsm.put(`${String(val)}:${row[pkCol]}`, { pk: row[pkCol] });
} }
} }
} }
+23 -43
View File
@@ -11,7 +11,7 @@
import { MemTable } from './memtable'; import { MemTable } from './memtable';
import { SSTableBuilder } from './sstable_builder'; import { SSTableBuilder } from './sstable_builder';
import { SSTableReader } from './sstable'; import { SSTableReader } from './sstable';
import { MergeIterator, ArrayEntrySource } from './merge_iterator'; import { MergeIterator, ArrayEntrySource, GeneratorEntrySource } from './merge_iterator';
import { DatabaseError } from '../../../constants'; import { DatabaseError } from '../../../constants';
import type { SSTableMeta } from '../types'; import type { SSTableMeta } from '../types';
import { import {
@@ -427,26 +427,31 @@ export class LSM {
rangeScan(startKey: string, endKey: string): [string, Record<string, unknown>][] { rangeScan(startKey: string, endKey: string): [string, Record<string, unknown>][] {
const result: [string, Record<string, unknown>][] = []; const result: [string, Record<string, unknown>][] = [];
this.rangeScanLazy(startKey, endKey, (k, v) => result.push([k, v])); this.rangeScanLazy(startKey, endKey, (k, v) => { result.push([k, v]); });
return result; return result;
} }
/** 惰性范围扫描:通过回调逐条返回,不一次性物化所有源 */ /**
* 惰性范围扫描:通过回调逐条返回,不一次性物化。
* v0.7.4: 真惰性 —— 各源(MemTable/frozen/SSTable)以生成器接入 MergeIterator
* 逐条拉取;回调返回 false 时提前终止(未消费部分不再解析/物化)。
* 此前实现内部 mergeIter.drain() 全量物化,与"流式不物化"宣称不符。
*/
rangeScanLazy( rangeScanLazy(
startKey: string, startKey: string,
endKey: string, endKey: string,
callback: (key: string, value: Record<string, unknown>) => void, callback: (key: string, value: Record<string, unknown>) => boolean | void,
): void { ): void {
const mergeIter = new MergeIterator(); const mergeIter = new MergeIterator();
mergeIter.addSource(new ArrayEntrySource( mergeIter.addSource(new GeneratorEntrySource(
this.memtable.rangeScan(startKey, endKey), this.memtable.scanLazy(startKey, endKey),
)); ));
// pending frozen memtables(从新到旧,新数据 sourceIndex 更小) // pending frozen memtables(从新到旧,新数据 sourceIndex 更小)
for (let i = this.frozenMemtables.length - 1; i >= 0; i--) { for (let i = this.frozenMemtables.length - 1; i >= 0; i--) {
mergeIter.addSource(new ArrayEntrySource( mergeIter.addSource(new GeneratorEntrySource(
this.frozenMemtables[i].rangeScan(startKey, endKey), this.frozenMemtables[i].scanLazy(startKey, endKey),
)); ));
} }
@@ -455,49 +460,24 @@ export class LSM {
if (endKey < meta.minKey || startKey > meta.maxKey) continue; if (endKey < meta.minKey || startKey > meta.maxKey) continue;
const reader = this.loadSSTableReader(meta); const reader = this.loadSSTableReader(meta);
if (!reader) continue; if (!reader) continue;
reader.rangeScan(startKey, endKey, (k, v) => { mergeIter.addSource(new GeneratorEntrySource(
mergeIter.addSource(new ArrayEntrySource([[k, v]])); reader.scanLazy(startKey, endKey),
}); ));
} }
} }
const merged = mergeIter.drain(); let entry = mergeIter.next();
for (const [k, v] of merged) { while (entry) {
const [k, v] = entry;
if (!(v as unknown as Record<string, unknown>).__tombstone) { if (!(v as unknown as Record<string, unknown>).__tombstone) {
callback(k, v); const cont = callback(k, v);
// v0.7.4: 提前终止(流式 limit 达成)
if (cont === false) return;
} }
entry = mergeIter.next();
} }
} }
getAllEntries(): [string, Record<string, unknown>][] {
const result = new Map<string, Record<string, unknown>>();
// 从最旧层级开始聚合;同层级内从最旧到最新遍历,
// 保证 result.set 覆盖时最终保留最新值
for (let level = MAX_LSM_LEVELS - 1; level >= 0; level--) {
for (let i = this.levels[level].length - 1; i >= 0; i--) {
const meta = this.levels[level][i];
const reader = this.loadSSTableReader(meta);
if (!reader) continue;
reader.scanAll((k, v) => result.set(k, v));
}
}
// MemTable 覆盖(最新)
for (const [k, v] of this.memtable.getAllEntries()) {
result.set(k, v);
}
for (let i = this.frozenMemtables.length - 1; i >= 0; i--) {
for (const [k, v] of this.frozenMemtables[i].getAllEntries()) {
result.set(k, v);
}
}
return Array.from(result.entries()).filter(
([, v]) => !(v as unknown as Record<string, unknown>).__tombstone,
);
}
// ======================================================================= // =======================================================================
// Compaction // Compaction
// ======================================================================= // =======================================================================
+52 -3
View File
@@ -114,6 +114,35 @@ class RedBlackTree<K, V> {
this._rangeScan(this.root, startKey, endKey, callback); this._rangeScan(this.root, startKey, endKey, callback);
} }
/**
* v0.7.4: 惰性范围遍历(显式栈中序迭代 + 边界剪枝)。
* 真流式扫描:生成器按需产出,提前终止(limit 达成)时剩余子树不再遍历。
*/
*scanLazy(startKey: K, endKey: K): Generator<[K, V]> {
const stack: RBNode<K, V>[] = [];
// 定位到 >= startKey 的最左节点(沿路入栈)
let cur: RBNode<K, V> | null = this.root;
while (cur) {
if (cur.key >= startKey) {
stack.push(cur);
cur = cur.left;
} else {
cur = cur.right;
}
}
while (stack.length > 0) {
const node = stack.pop()!;
// 中序递增:越过 endKey 后所有剩余节点均越界
if (node.key > endKey) break;
if (node.key >= startKey) yield [node.key, node.value];
cur = node.right;
while (cur) {
stack.push(cur);
cur = cur.left;
}
}
}
/** 获取所有条目 */ /** 获取所有条目 */
getAllEntries(): [K, V][] { getAllEntries(): [K, V][] {
const entries: [K, V][] = []; const entries: [K, V][] = [];
@@ -157,8 +186,14 @@ class RedBlackTree<K, V> {
if (node.color === Color.BLACK) this.fixDelete(node.left, node.left!.parent); if (node.color === Color.BLACK) this.fixDelete(node.left, node.left!.parent);
} else { } else {
const successor = this.minimum(node.right); const successor = this.minimum(node.right);
if (successor!.parent !== node) { // v0.7.4: 在 transplant 重连前捕获 successor 原右子与父 ——
this.transplant(successor!, successor!.right); // x(双黑修复起点)= successor 原右子(占位在 successor 原位置)。
// 此前 `successor.right?.parent ?? null` 在重赋值后取值:x 指向 node 右子树,
// 且 null 时 parent 为 null → fixDelete 直接跳过修复(删除黑色节点后失衡)。
const successorRight = successor!.right;
const successorParent = successor!.parent;
if (successorParent !== node) {
this.transplant(successor!, successorRight);
successor!.right = node.right; successor!.right = node.right;
successor!.right!.parent = successor; successor!.right!.parent = successor;
} }
@@ -167,7 +202,13 @@ class RedBlackTree<K, V> {
successor!.left!.parent = successor; successor!.left!.parent = successor;
const origColor = successor!.color; const origColor = successor!.color;
successor!.color = node.color; successor!.color = node.color;
if (origColor === Color.BLACK) this.fixDelete(successor!.right, successor!.right?.parent ?? null); if (origColor === Color.BLACK) {
const x = successorRight;
// x 为 null 占位:直接右子时其父为 successor(已移到 node 位置),
// 间接右子时其父为 successor 原父(transplant 已把 x 接到其下)
const xParent = x ? x.parent : (successorParent === node ? successor! : successorParent);
this.fixDelete(x, xParent);
}
} }
} }
@@ -425,6 +466,14 @@ export class MemTable {
return entries; return entries;
} }
/** v0.7.4: 惰性范围扫描(真流式,逐条产出) */
scanLazy(
startKey: string,
endKey: string,
): Generator<[string, Record<string, unknown>]> {
return this.tree.scanLazy(startKey, endKey);
}
/** 条目数 */ /** 条目数 */
getEntryCount(): number { getEntryCount(): number {
return this.tree.size; return this.tree.size;
+22
View File
@@ -35,6 +35,28 @@ export class ArrayEntrySource implements EntrySource {
} }
} }
/**
* v0.7.4: 生成器数据源 —— 惰性迭代(真流式扫描)。
* MergeIterator 的 next() 逐条拉取,生成器按需产出(findStream 提前终止时
* 未消费部分不再物化,大表流式内存 O(1))。
*/
export class GeneratorEntrySource implements EntrySource {
private iter: Generator<[string, Record<string, unknown>]>;
constructor(iter: Generator<[string, Record<string, unknown>]>) {
this.iter = iter;
}
next(): [string, Record<string, unknown>] | null {
const r = this.iter.next();
return r.done ? null : r.value;
}
reset(): void {
// 生成器不可重置;MergeIterator 无 reset 消费方,接口保留
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Heap 节点(用于多路归并) // Heap 节点(用于多路归并)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+16 -2
View File
@@ -108,6 +108,20 @@ export class SSTableReader {
endKey: string, endKey: string,
callback: (key: string, value: Record<string, unknown>) => void, callback: (key: string, value: Record<string, unknown>) => void,
): void { ): void {
// v0.7.4: 包装惰性生成器(行为一致,消除双份解析循环)
for (const [key, value] of this.scanLazy(startKey, endKey)) {
callback(key, value);
}
}
/**
* v0.7.4: 惰性范围扫描 —— 生成器逐块逐条产出(真流式)。
* 提前终止时未消费的块不再解析,大表流式内存 O(1)。
*/
*scanLazy(
startKey: string,
endKey: string,
): Generator<[string, Record<string, unknown>]> {
if (this.indexEntries.length === 0) return; if (this.indexEntries.length === 0) return;
const startBlockIdx = Math.max(0, this.locateBlockGE(startKey)); const startBlockIdx = Math.max(0, this.locateBlockGE(startKey));
// v0.6.1-fix(P0): 索引键是"块内最后一个 key"builder 约定), // v0.6.1-fix(P0): 索引键是"块内最后一个 key"builder 约定),
@@ -121,7 +135,7 @@ export class SSTableReader {
for (let bi = startBlockIdx; bi <= endBlockIdx && bi >= 0; bi++) { for (let bi = startBlockIdx; bi <= endBlockIdx && bi >= 0; bi++) {
const entry = this.indexEntries[bi]; const entry = this.indexEntries[bi];
const blockData = this.getBlockData(entry); const blockData = this.getBlockData(entry);
// v0.4.1-fix: 残缺块跳过(rangeScan 继续后续块,不抛异常) // v0.4.1-fix: 残缺块跳过(继续后续块,不抛异常)
if (!blockData) continue; if (!blockData) continue;
const blockView = new DataView(blockData.buffer, blockData.byteOffset, blockData.byteLength); const blockView = new DataView(blockData.buffer, blockData.byteOffset, blockData.byteLength);
@@ -144,7 +158,7 @@ export class SSTableReader {
if (key >= startKey && key <= endKey) { if (key >= startKey && key <= endKey) {
try { try {
const value = JSON.parse(new TextDecoder().decode(valBytes)); const value = JSON.parse(new TextDecoder().decode(valBytes));
callback(key, value); yield [key, value];
} catch { } catch {
// skip corrupted entry // skip corrupted entry
} }
+5
View File
@@ -85,6 +85,8 @@ export class KVStore {
async open(dbName: string): Promise<void> { async open(dbName: string): Promise<void> {
if (this.opened) return; if (this.opened) return;
this.dbName = dbName; this.dbName = dbName;
// v0.7.4: 打开时同样清理后台错误状态(防 close/reopen 残留)
this.lastBackgroundError = null;
await this.medium.open(dbName); await this.medium.open(dbName);
this.index = new Map(); this.index = new Map();
this.seq = 0; this.seq = 0;
@@ -162,6 +164,9 @@ export class KVStore {
this.index.clear(); this.index.clear();
this.seq = 0; this.seq = 0;
this.logBytes = 0; this.logBytes = 0;
// v0.7.4: 清理后台错误状态 —— 此前跨 close/reopen 残留,
// 重开后首次 checkpoint 会抛出上一次生命周期的旧错误
this.lastBackgroundError = null;
this.opened = false; this.opened = false;
} }
+62 -2
View File
@@ -6,7 +6,7 @@
import type { IStorageEngine } from './interface'; import type { IStorageEngine } from './interface';
import type { QueryPlan, TableSchema, WhereCondition } from '../constants'; import type { QueryPlan, TableSchema, WhereCondition } from '../constants';
import { DatabaseError } from '../constants'; import { DatabaseError } from '../constants';
import { matchWhere, applyOrderBy, projectColumns } from '../query/where-matcher'; import { matchWhere, applyOrderBy, projectColumns, containsUnresolvedSubqueries } from '../query/where-matcher';
import { stripUndefinedUpdates } from '../table/schema'; import { stripUndefinedUpdates } from '../table/schema';
export class MemoryEngine implements IStorageEngine { export class MemoryEngine implements IStorageEngine {
@@ -18,6 +18,12 @@ export class MemoryEngine implements IStorageEngine {
private opened = false; private opened = false;
/** v0.4.2-fix: 库内元数据(迁移版本持久化用) */ /** v0.4.2-fix: 库内元数据(迁移版本持久化用) */
private metaStore: Map<string, string> = new Map(); private metaStore: Map<string, string> = new Map();
/**
* v0.7.4: 由 CREATE UNIQUE INDEX 添加的 unique 列(table:col)。
* 与建表 UNIQUE 约束区分:DROP INDEX 只允许解除索引来源的 unique,
* 建表约束需重建表(对齐 SQLite 语义,此前静默解除且不可恢复)。
*/
private uniqueIndexCols: Set<string> = new Set();
// ---- 事务快照 ---- // ---- 事务快照 ----
private snapshot: { private snapshot: {
@@ -82,6 +88,11 @@ export class MemoryEngine implements IStorageEngine {
async dropTable(tableName: string): Promise<void> { async dropTable(tableName: string): Promise<void> {
this.ensureTable(tableName); this.ensureTable(tableName);
this.schemas.delete(tableName); this.tables.delete(tableName); this.indexes.delete(tableName); this.schemas.delete(tableName); this.tables.delete(tableName); this.indexes.delete(tableName);
// v0.7.4: 清理该表的 unique 索引来源标记(重建同名表不残留)
const prefix = `${tableName}:`;
for (const key of this.uniqueIndexCols) {
if (key.startsWith(prefix)) this.uniqueIndexCols.delete(key);
}
} }
async hasTable(tableName: string): Promise<boolean> { return this.schemas.has(tableName); } async hasTable(tableName: string): Promise<boolean> { return this.schemas.has(tableName); }
@@ -229,6 +240,23 @@ export class MemoryEngine implements IStorageEngine {
// v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空 // v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空
const cleanUpdates = stripUndefinedUpdates(updates); const cleanUpdates = stripUndefinedUpdates(updates);
// v0.7.4: 防御 —— QueryBuilder 直通引擎不经 Executor 子查询解析,
// 未解析的 $subquery/$col/$exists 在 matchWhere 中恒 false → 静默 0 行
if (containsUnresolvedSubqueries(query.where)) {
throw new DatabaseError(
'Unresolved subqueries/column references in UPDATE WHERE (use db.query() to execute subqueries)',
'NOT_SUPPORTED',
);
}
// v0.7.4: 未知列显式报错 —— 此前 SET nonexistent = ... 被静默写入存储行
// validateRow 只遍历 schema 列,脏列残留在行内并随持久化落盘)
for (const col of Object.keys(cleanUpdates)) {
if (!schema.columns[col]) {
throw new DatabaseError(`Column "${col}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND');
}
}
// v0.7.2: 语句级原子性 — 两阶段(先全量预检,后执行)。 // v0.7.2: 语句级原子性 — 两阶段(先全量预检,后执行)。
// 此前逐行"校验+写入":第 N 行唯一冲突/校验失败抛错时,前 N-1 行已写入 // 此前逐行"校验+写入":第 N 行唯一冲突/校验失败抛错时,前 N-1 行已写入
// → 无事务下语句级部分提交(数据半更新且调用方已收到错误)。 // → 无事务下语句级部分提交(数据半更新且调用方已收到错误)。
@@ -421,6 +449,14 @@ export class MemoryEngine implements IStorageEngine {
async delete(tableName: string, query: QueryPlan): Promise<number> { async delete(tableName: string, query: QueryPlan): Promise<number> {
this.ensureTable(tableName); this.ensureTable(tableName);
// v0.7.4: 防御 —— QueryBuilder 直通引擎不经 Executor 子查询解析,
// 未解析的 $subquery/$col/$exists 在 matchWhere 中恒 false → 静默 0 行
if (containsUnresolvedSubqueries(query.where)) {
throw new DatabaseError(
'Unresolved subqueries/column references in DELETE WHERE (use db.query() to execute subqueries)',
'NOT_SUPPORTED',
);
}
const table = this.tables.get(tableName)!; const table = this.tables.get(tableName)!;
const toDelete: { pk: string; row: Record<string, unknown> }[] = []; const toDelete: { pk: string; row: Record<string, unknown> }[] = [];
for (const [pk, row] of table) { for (const [pk, row] of table) {
@@ -551,7 +587,11 @@ export class MemoryEngine implements IStorageEngine {
throw error; throw error;
} }
colDef.index = true; colDef.index = true;
if (unique) colDef.unique = true; if (unique) {
colDef.unique = true;
// v0.7.4: 记录唯一约束来源(DROP INDEX 时可解除;建表约束不可)
this.uniqueIndexCols.add(`${tableName}:${column}`);
}
} }
async dropIndex(tableName: string, column: string, _indexName?: string): Promise<void> { async dropIndex(tableName: string, column: string, _indexName?: string): Promise<void> {
@@ -570,8 +610,20 @@ export class MemoryEngine implements IStorageEngine {
if (!colDef.index && !colDef.unique) { if (!colDef.index && !colDef.unique) {
throw new DatabaseError(`Index on column "${column}" does not exist in table "${tableName}"`, 'INDEX_NOT_FOUND'); throw new DatabaseError(`Index on column "${column}" does not exist in table "${tableName}"`, 'INDEX_NOT_FOUND');
} }
// v0.7.4: 建表 UNIQUE 约束不可通过 DROP INDEX 解除 —— 此前 colDef.unique = false
// 静默解除约束(后续唯一性检查失效、重复数据入库)。对齐 SQLite 语义:
// 约束随建表存在,解除需重建表;仅 CREATE UNIQUE INDEX 添加的约束可随索引删除。
const uniqueKey = `${tableName}:${column}`;
if (colDef.unique && !this.uniqueIndexCols.has(uniqueKey)) {
throw new DatabaseError(
`Cannot drop index on column "${column}" in table "${tableName}": ` +
'UNIQUE constraint defined at table creation must be removed by recreating the table',
'NOT_SUPPORTED',
);
}
colDef.index = false; colDef.index = false;
colDef.unique = false; colDef.unique = false;
this.uniqueIndexCols.delete(uniqueKey);
const tableIndexes = this.indexes.get(tableName); const tableIndexes = this.indexes.get(tableName);
if (tableIndexes) tableIndexes.delete(column); if (tableIndexes) tableIndexes.delete(column);
} }
@@ -644,6 +696,14 @@ export class MemoryEngine implements IStorageEngine {
if (colDef.required && (value === undefined || value === null)) { if (colDef.required && (value === undefined || value === null)) {
throw new DatabaseError(`Column "${colName}" is required in table "${schema.name}"`, 'VALIDATION_ERROR'); throw new DatabaseError(`Column "${colName}" is required in table "${schema.name}"`, 'VALIDATION_ERROR');
} }
// v0.7.4: 主键列强制非空(SQL 语义 PK 隐含 NOT NULL)——
// 此前 null/undefined 主键被 String() 化为 "null"/"undefined" 静默入库
if (colDef.primaryKey && (value === undefined || value === null)) {
throw new DatabaseError(
`Primary key column "${colName}" in table "${schema.name}" cannot be null or undefined`,
'VALIDATION_ERROR',
);
}
if (value !== undefined && value !== null) this.checkType(colName, colDef.type, value); if (value !== undefined && value !== null) this.checkType(colName, colDef.type, value);
if (value !== undefined) validated[colName] = value; if (value !== undefined) validated[colName] = value;
} }
+8 -1
View File
@@ -292,7 +292,14 @@ export class HybridEngine implements IStorageEngine {
async beginTransaction(): Promise<void> { async beginTransaction(): Promise<void> {
await this.memoryEngine.beginTransaction(); await this.memoryEngine.beginTransaction();
await this.diskEngine.beginTransaction(); // v0.7.4: 磁盘 begin 失败时补偿回滚内存快照 —— 此前内存已 begin、
// 磁盘抛错 → 内存快照泄漏(后续所有事务报 TX_ACTIVE)
try {
await this.diskEngine.beginTransaction();
} catch (error) {
await this.memoryEngine.rollbackTransaction();
throw error;
}
} }
async commitTransaction(): Promise<void> { async commitTransaction(): Promise<void> {
+57 -4
View File
@@ -18,6 +18,26 @@ import { matchWhere, applyOrderBy, projectColumns } from './where-matcher';
import { parseWhereCondition } from '../sql/parser'; import { parseWhereCondition } from '../sql/parser';
import type { WhereCondition } from '../constants'; import type { WhereCondition } from '../constants';
// ---------------------------------------------------------------------------
// 分组 / 去重键编码(v0.7.4)
// ---------------------------------------------------------------------------
/**
* v0.7.4: 分组/ `String(v ?? 'null')` 使
* null 'null' GROUP BY `String(v ?? '\0')`
* 使 null/undefined/'\0' DISTINCT/UNION
* where-matcher ===
*/
function encodeGroupKey(v: unknown): string {
if (v === null) return 'n';
if (v === undefined) return 'u';
if (typeof v === 'string') return `s${v}`;
if (typeof v === 'number') return `d${v}`;
if (typeof v === 'boolean') return `b${v}`;
if (typeof v === 'object') return `o${JSON.stringify(v)}`;
return `x${String(v)}`;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// CASE WHEN 表达式(v0.3.1 // CASE WHEN 表达式(v0.3.1
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -147,7 +167,7 @@ export class QueryExecutor {
const seen = new Set<string>(); const seen = new Set<string>();
const result: Record<string, unknown>[] = []; const result: Record<string, unknown>[] = [];
for (const row of normalized) { for (const row of normalized) {
const key = Object.values(row).map((v) => String(v ?? '\0')).join('\x1f'); const key = Object.values(row).map(encodeGroupKey).join('\x1f');
if (!seen.has(key)) { if (!seen.has(key)) {
seen.add(key); seen.add(key);
result.push(row); result.push(row);
@@ -155,7 +175,7 @@ export class QueryExecutor {
} }
for (const row of rightRows) { for (const row of rightRows) {
const projected = this.projectUnionRow(row, leftCols); const projected = this.projectUnionRow(row, leftCols);
const key = Object.values(projected).map((v) => String(v ?? '\0')).join('\x1f'); const key = Object.values(projected).map(encodeGroupKey).join('\x1f');
if (!seen.has(key)) { if (!seen.has(key)) {
seen.add(key); seen.add(key);
result.push(projected); result.push(projected);
@@ -195,7 +215,10 @@ export class QueryExecutor {
rows = Array.isArray(result) ? result.length : 0; rows = Array.isArray(result) ? result.length : 0;
} else if (stmt.query.type === 'UPDATE' || stmt.query.type === 'DELETE') { } else if (stmt.query.type === 'UPDATE' || stmt.query.type === 'DELETE') {
try { try {
// v0.7.4: 子查询解析后估算 —— 此前 $subquery 未解析使 count 恒 0
await this.resolveWriteWhere(stmt.query);
const plan = compileStatement(stmt.query); const plan = compileStatement(stmt.query);
plan.where = stmt.query.where;
rows = await this.engine.count(plan.table, plan); rows = await this.engine.count(plan.table, plan);
} catch { rows = 0; } } catch { rows = 0; }
} }
@@ -593,7 +616,9 @@ export class QueryExecutor {
private executeGroupBy(rows: Record<string, unknown>[], stmt: SelectStatement): Record<string, unknown>[] { private executeGroupBy(rows: Record<string, unknown>[], stmt: SelectStatement): Record<string, unknown>[] {
const groups = new Map<string, Record<string, unknown>[]>(); const groups = new Map<string, Record<string, unknown>[]>();
for (const row of rows) { for (const row of rows) {
const key = stmt.groupBy!.map((col) => String(row[col] ?? 'null')).join('|'); // v0.7.4: 类型安全键编码 —— 此前 String(row[col] ?? 'null') 使
// null 与字符串 'null' 合并为一组(GROUP BY 静默少组)
const key = stmt.groupBy!.map((col) => encodeGroupKey(row[col])).join('\x1f');
if (!groups.has(key)) groups.set(key, []); if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(row); groups.get(key)!.push(row);
} }
@@ -660,7 +685,8 @@ export class QueryExecutor {
private executeDistinct(rows: Record<string, unknown>[]): Record<string, unknown>[] { private executeDistinct(rows: Record<string, unknown>[]): Record<string, unknown>[] {
const seen = new Set<string>(); const seen = new Set<string>();
return rows.filter((row) => { return rows.filter((row) => {
const key = Object.values(row).map((v) => String(v ?? '\0')).join('\x1f'); // v0.7.4: 类型安全键编码(null 与 'null' 字符串、'\0' 分离)
const key = Object.values(row).map(encodeGroupKey).join('\x1f');
if (seen.has(key)) return false; if (seen.has(key)) return false;
seen.add(key); seen.add(key);
return true; return true;
@@ -714,15 +740,42 @@ export class QueryExecutor {
} }
private async executeUpdate(stmt: UpdateStatement): Promise<number> { private async executeUpdate(stmt: UpdateStatement): Promise<number> {
// v0.7.4: 先解析 WHERE 子查询 —— 此前直接 compileStatement 调引擎:
// 引擎层 matchWhere 的 $in/$nin 遇未解析的 $subquery 对象恒 false →
// 所有行不匹配,UPDATE 静默影响 0 行(与 queryStream v0.7.3 修复同类)。
await this.resolveWriteWhere(stmt);
const plan = compileStatement(stmt); const plan = compileStatement(stmt);
plan.where = stmt.where;
return this.engine.update(plan.table, plan, stmt.sets); return this.engine.update(plan.table, plan, stmt.sets);
} }
private async executeDelete(stmt: DeleteStatement): Promise<number> { private async executeDelete(stmt: DeleteStatement): Promise<number> {
// v0.7.4: 同 executeUpdate —— DELETE 子查询 WHERE 此前静默删除 0 行
await this.resolveWriteWhere(stmt);
const plan = compileStatement(stmt); const plan = compileStatement(stmt);
plan.where = stmt.where;
return this.engine.delete(plan.table, plan); return this.engine.delete(plan.table, plan);
} }
/**
* v0.7.4: 写语句UPDATE/DELETEWHERE
* $subquery/
* $col / EXISTS
* matchWhere $col NOT_SUPPORTED 0
*/
private async resolveWriteWhere(stmt: UpdateStatement | DeleteStatement): Promise<WhereCondition> {
const where = stmt.where;
if (!where || Object.keys(where).length === 0) return where ?? {};
if (this.hasCorrelatedRefs(where)) {
throw new DatabaseError(
'Correlated subqueries and column references are not supported in UPDATE/DELETE WHERE clauses',
'NOT_SUPPORTED',
);
}
stmt.where = await this.resolveSubqueries(where);
return stmt.where;
}
private async executeCreateTable(stmt: CreateTableStatement): Promise<void> { private async executeCreateTable(stmt: CreateTableStatement): Promise<void> {
// IF NOT EXISTS: 表已存在时静默返回 // IF NOT EXISTS: 表已存在时静默返回
if (stmt.ifNotExists) { if (stmt.ifNotExists) {
+31 -2
View File
@@ -31,6 +31,36 @@ function compileLikeRegex(pattern: string): RegExp {
// WHERE 匹配(顶层入口) // WHERE 匹配(顶层入口)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/**
* v0.7.4: 检测 WHERE /$subquery / $col / $exists
* QueryBuilder update/delete Executor
* matchWhere false 0
* SELECT Executor
*/
export function containsUnresolvedSubqueries(where: WhereCondition | undefined): boolean {
if (!where) return false;
for (const [k, v] of Object.entries(where)) {
if (k === '$and' || k === '$or') {
if ((v as WhereCondition[]).some((sub) => containsUnresolvedSubqueries(sub))) return true;
continue;
}
if (k === '$not') {
if (containsUnresolvedSubqueries(v as WhereCondition)) return true;
continue;
}
if (k === '$exists') return true;
if (typeof v === 'object' && v !== null && !Array.isArray(v)) {
for (const [, operand] of Object.entries(v as Record<string, unknown>)) {
if (typeof operand === 'object' && operand !== null) {
const ops = operand as Record<string, unknown>;
if ('$subquery' in ops || '$col' in ops) return true;
}
}
}
}
return false;
}
/** /**
* WHERE * WHERE
* @param row * @param row
@@ -41,8 +71,7 @@ export function matchWhere(
row: Record<string, unknown>, row: Record<string, unknown>,
where: WhereCondition, where: WhereCondition,
options: { $col?: boolean } = {}, options: { $col?: boolean } = {},
): boolean { ): boolean { for (const [field, condition] of Object.entries(where)) {
for (const [field, condition] of Object.entries(where)) {
// 顶层 $caseResultv0.3.2):由 Executor 对 CASE WHEN 表达式逐行求值后产生 // 顶层 $caseResultv0.3.2):由 Executor 对 CASE WHEN 表达式逐行求值后产生
if (field === '$caseResult') { if (field === '$caseResult') {
if (condition !== true) return false; if (condition !== true) return false;
+9
View File
@@ -103,6 +103,15 @@ export function validateRow(schema: TableSchema, row: Record<string, unknown>):
); );
} }
// v0.7.4: 主键列强制非空(SQL 语义 PK 隐含 NOT NULL)——
// 此前 null/undefined 主键被 String() 化为 "null"/"undefined" 静默入库
if (colDef.primaryKey && (value === undefined || value === null)) {
throw new DatabaseError(
`Primary key column "${colName}" in table "${schema.name}" cannot be null or undefined`,
'VALIDATION_ERROR',
);
}
// 类型检查 // 类型检查
if (value !== undefined && value !== null) { if (value !== undefined && value !== null) {
checkFieldType(schema.name, colName, colDef.type, value, colDef); checkFieldType(schema.name, colName, colDef.type, value, colDef);
+1 -1
View File
@@ -28,7 +28,7 @@ beforeEach(() => { installOPFSMock(new Map()); });
describe('[v0.2.5] P0-1: 版本号统一', () => { describe('[v0.2.5] P0-1: 版本号统一', () => {
test('VERSION 常量为当前版本(0.6.0', () => { test('VERSION 常量为当前版本(0.6.0', () => {
expect(VERSION).toBe('0.7.3'); expect(VERSION).toBe('0.7.4');
}); });
}); });
+1 -1
View File
@@ -403,7 +403,7 @@ describe('[v0.3.3] P1-9: Savepoint + MVCC 一致性', () => {
describe('[v0.3.3] 端到端', () => { describe('[v0.3.3] 端到端', () => {
test('全部修复点可共存于 MetonaSqlark API', async () => { test('全部修复点可共存于 MetonaSqlark API', async () => {
expect(VERSION).toBe('0.7.3'); expect(VERSION).toBe('0.7.4');
const db = new MetonaSqlark({ name: `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, mode: 'memory' }); const db = new MetonaSqlark({ name: `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, mode: 'memory' });
await db.init(); await db.init();
await db.defineTable('users', { await db.defineTable('users', {
+565
View File
@@ -0,0 +1,565 @@
/**
* v0.7.4
*
* 1. UPDATE/DELETE WHERE 0
* 2. $col / EXISTS NOT_SUPPORTED
* 3. EXPLAIN UPDATE/DELETE estimatedRows
* 4. NULL/undefined
* 5. DROP INDEX UNIQUE / CREATE UNIQUE INDEX
* 6. GROUP BY null 'null' DISTINCT/UNION
* 7. UPDATE
* 8. queryStream
* 9. KVStore reopen
* 10. Hybrid beginTransaction
* 11. findStream limit
* 12. reindex
*/
import { MetonaSqlark } from '../src/core';
import { MemoryEngine } from '../src/engine/memory';
import { KVStore } from '../src/engine/kvstore/index';
import { SharedMemoryBackend } from '../src/engine/kvstore/shared_memory_medium';
const uniqueName = (tag: string): string => `v074-${tag}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
async function makeDb(mode: 'memory' | 'disk' | 'aria' | 'hybrid') {
const db = await MetonaSqlark.create({
name: uniqueName(mode),
mode,
diskEngine: 'memory',
});
await db.defineTable('t', {
id: { type: 'string', primaryKey: true },
v: { type: 'number' },
});
return db;
}
// ===================================================================
// 1. UPDATE / DELETE 子查询
// ===================================================================
describe('v0.7.4: UPDATE/DELETE WHERE 子查询(此前静默 0 行)', () => {
test.each([
['memory', 'memory'],
['disk', 'disk'],
['aria', 'aria'],
['hybrid', 'hybrid'],
] as const)('%s: UPDATE IN (SELECT) 正确影响行', async (_label, mode) => {
const db = await makeDb(mode);
await db.query("INSERT INTO t VALUES ('1', 10), ('2', 20)");
const n = await db.query('UPDATE t SET v = 99 WHERE id IN (SELECT id FROM t WHERE v > 15)');
expect(n).toBe(1);
const rows = await db.query('SELECT * FROM t ORDER BY id') as Record<string, unknown>[];
expect(rows.map((r) => r.v)).toEqual([10, 99]);
await db.close();
});
test.each([
['memory', 'memory'],
['disk', 'disk'],
['aria', 'aria'],
['hybrid', 'hybrid'],
] as const)('%s: DELETE IN (SELECT) 正确影响行', async (_label, mode) => {
const db = await makeDb(mode);
await db.query("INSERT INTO t VALUES ('1', 10), ('2', 20)");
const n = await db.query('DELETE FROM t WHERE id IN (SELECT id FROM t WHERE v > 15)');
expect(n).toBe(1);
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
expect(rows.map((r) => r.id)).toEqual(['1']);
await db.close();
});
test('memory: UPDATE 标量子查询(op (SELECT)', async () => {
const db = await makeDb('memory');
await db.query("INSERT INTO t VALUES ('1', 10), ('2', 20)");
const n = await db.query('UPDATE t SET v = 1 WHERE v = (SELECT MAX(v) FROM t)');
expect(n).toBe(1);
await db.close();
});
test('aria: UPDATE 子查询后持久化重开一致(kv 后端跨实例共享介质)', async () => {
const name = uniqueName('aria-persist');
const db1 = await MetonaSqlark.create({ name, mode: 'aria', diskEngine: 'kv' });
await db1.defineTable('t', {
id: { type: 'string', primaryKey: true },
v: { type: 'number' },
});
await db1.query("INSERT INTO t VALUES ('1', 10), ('2', 20)");
await db1.query('UPDATE t SET v = 99 WHERE id IN (SELECT id FROM t WHERE v > 15)');
await db1.close();
const db2 = await MetonaSqlark.create({ name, mode: 'aria', diskEngine: 'kv' });
const rows = await db2.query('SELECT * FROM t ORDER BY id') as Record<string, unknown>[];
expect(rows.map((r) => r.v)).toEqual([10, 99]);
await db2.close();
});
});
// ===================================================================
// 2. 写语句关联引用显式报错
// ===================================================================
describe('v0.7.4: 写语句关联引用显式 NOT_SUPPORTED', () => {
test('memory: SQL 列比较(a = b)解析层明确报错(此前静默或误解析)', async () => {
const db = await makeDb('memory');
await db.query("INSERT INTO t VALUES ('1', 10)");
// parser 不支持列引用作为比较值 → 明确 PARSE_ERROR,绝不静默影响行
const err = await db.query('UPDATE t SET v = 1 WHERE id = id').catch((e: unknown) => e as { code?: string });
expect((err as { code?: string }).code).toBeDefined();
// 行未被修改(无静默副作用)
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
expect(rows[0].v).toBe(10);
await db.close();
});
test('memory: QueryBuilder 列引用更新 WHERE 显式 NOT_SUPPORTED', async () => {
const db = await makeDb('memory');
await db.query("INSERT INTO t VALUES ('1', 10)");
await expect(
db.table('t').update({ v: 1 }).where({ id: { $eq: { $col: 'id' } } }).execute(),
).rejects.toMatchObject({ code: 'NOT_SUPPORTED' });
await db.close();
});
test('memory: DELETE WHERE EXISTS(关联)报错而非静默 0 行', async () => {
const db = await makeDb('memory');
await db.query("INSERT INTO t VALUES ('1', 10)");
await expect(
db.query('DELETE FROM t WHERE EXISTS (SELECT 1 FROM t t2 WHERE t2.id = t.id)'),
).rejects.toMatchObject({ code: 'NOT_SUPPORTED' });
await db.close();
});
test('aria: UPDATE 关联 EXISTS 报错', async () => {
const db = await makeDb('aria');
await db.query("INSERT INTO t VALUES ('1', 10)");
await expect(
db.query('UPDATE t SET v = 1 WHERE EXISTS (SELECT 1 FROM t t2 WHERE t2.id = t.id)'),
).rejects.toMatchObject({ code: 'NOT_SUPPORTED' });
await db.close();
});
});
// ===================================================================
// 3. EXPLAIN 子查询 estimatedRows
// ===================================================================
describe('v0.7.4: EXPLAIN UPDATE/DELETE 子查询估算', () => {
test('EXPLAIN UPDATE IN (SELECT) estimatedRows 正确', async () => {
const db = await makeDb('memory');
await db.query("INSERT INTO t VALUES ('1', 10), ('2', 20)");
const plan = await db.query('EXPLAIN UPDATE t SET v = 1 WHERE id IN (SELECT id FROM t WHERE v > 15)') as Record<string, unknown>;
expect(plan.estimatedRows).toBe(1);
// EXPLAIN 不产生副作用
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
expect(rows.map((r) => r.v)).toEqual([10, 20]);
await db.close();
});
});
// ===================================================================
// 4. 主键 NULL/undefined 拒绝
// ===================================================================
describe('v0.7.4: 主键 NULL/undefined 拒绝', () => {
test.each([
['memory', 'memory'],
['disk', 'disk'],
['aria', 'aria'],
['hybrid', 'hybrid'],
] as const)('%s: INSERT 主键 NULL 拒绝', async (_label, mode) => {
const db = await makeDb(mode);
await expect(db.query('INSERT INTO t VALUES (NULL, 1)')).rejects.toMatchObject({ code: 'VALIDATION_ERROR' });
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
expect(rows).toHaveLength(0);
await db.close();
});
test.each([
['memory', 'memory'],
['disk', 'disk'],
['aria', 'aria'],
] as const)('%s: INSERT 省略主键(无 default)拒绝', async (_label, mode) => {
const db = await makeDb(mode);
await expect(db.query('INSERT INTO t (v) VALUES (5)')).rejects.toMatchObject({ code: 'VALIDATION_ERROR' });
await db.close();
});
test('memory: UPDATE 主键置 null 拒绝', async () => {
const db = await makeDb('memory');
await db.query("INSERT INTO t VALUES ('1', 10)");
await expect(db.query("UPDATE t SET id = NULL WHERE id = '1'")).rejects.toMatchObject({ code: 'VALIDATION_ERROR' });
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
expect(rows[0].id).toBe('1');
await db.close();
});
test('memory: 主键 default 生效时允许省略', async () => {
const db = await MetonaSqlark.create({ name: uniqueName('pkdefault'), mode: 'memory' });
await db.defineTable('t', {
id: { type: 'string', primaryKey: true, default: 'auto' },
v: { type: 'number' },
});
const pks = await db.query('INSERT INTO t (v) VALUES (5)') as string[];
expect(pks).toEqual(['auto']);
await db.close();
});
test('aria: 主键 undefinedTable API)拒绝', async () => {
const db = await makeDb('aria');
await expect(
db.table('t').insert({ v: 3 } as Record<string, unknown>),
).rejects.toMatchObject({ code: 'VALIDATION_ERROR' });
await db.close();
});
});
// ===================================================================
// 5. DROP INDEX 与 UNIQUE 约束
// ===================================================================
describe('v0.7.4: DROP INDEX UNIQUE 约束保护', () => {
test.each([
['memory', 'memory'],
['aria', 'aria'],
] as const)('%s: 建表 UNIQUE 列 DROP INDEX 拒绝且约束保留', async (_label, mode) => {
const db = await MetonaSqlark.create({ name: uniqueName('uniq'), mode, diskEngine: 'memory' });
await db.defineTable('u', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', unique: true },
});
await db.query("INSERT INTO u VALUES ('1', 'a@x.com')");
await expect(db.query('DROP INDEX idx ON u (email)')).rejects.toMatchObject({ code: 'NOT_SUPPORTED' });
// 约束仍生效
await expect(db.query("INSERT INTO u VALUES ('2', 'a@x.com')")).rejects.toMatchObject({ code: 'UNIQUE_VIOLATION' });
const schema = await db.getEngine().getTableSchema('u');
expect(schema!.columns.email.unique).toBe(true);
await db.close();
});
test.each([
['memory', 'memory'],
['aria', 'aria'],
] as const)('%s: CREATE UNIQUE INDEX 后 DROP 可解除(本会话)', async (_label, mode) => {
const db = await MetonaSqlark.create({ name: uniqueName('uniq2'), mode, diskEngine: 'memory' });
await db.defineTable('u', {
id: { type: 'string', primaryKey: true },
email: { type: 'string' },
});
await db.query("INSERT INTO u VALUES ('1', 'a@x.com')");
await db.query('CREATE UNIQUE INDEX idx_u ON u (email)');
await expect(db.query("INSERT INTO u VALUES ('2', 'a@x.com')")).rejects.toMatchObject({ code: 'UNIQUE_VIOLATION' });
await db.query('DROP INDEX idx_u ON u (email)');
// 约束已随索引解除
await db.query("INSERT INTO u VALUES ('2', 'a@x.com')");
const rows = await db.query('SELECT * FROM u') as Record<string, unknown>[];
expect(rows).toHaveLength(2);
await db.close();
});
test('aria: 建表 UNIQUE DROP INDEX 拒绝后重启约束仍在(kv 后端)', async () => {
const name = uniqueName('uniq3');
const db1 = await MetonaSqlark.create({ name, mode: 'aria', diskEngine: 'kv' });
await db1.defineTable('u', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', unique: true },
});
await db1.query("INSERT INTO u VALUES ('1', 'a@x.com')");
await expect(db1.query('DROP INDEX idx ON u (email)')).rejects.toMatchObject({ code: 'NOT_SUPPORTED' });
await db1.close();
const db2 = await MetonaSqlark.create({ name, mode: 'aria', diskEngine: 'kv' });
await expect(db2.query("INSERT INTO u VALUES ('2', 'a@x.com')")).rejects.toMatchObject({ code: 'UNIQUE_VIOLATION' });
await db2.close();
});
});
// ===================================================================
// 6. GROUP BY / DISTINCT / UNION 键编码
// ===================================================================
describe('v0.7.4: 分组/去重键类型安全编码', () => {
test('memory: GROUP BY null 与 "null" 字符串分离', async () => {
const db = await MetonaSqlark.create({ name: uniqueName('grp'), mode: 'memory' });
await db.defineTable('g', {
id: { type: 'string', primaryKey: true },
grp: { type: 'string' },
});
await db.query("INSERT INTO g VALUES ('a', NULL), ('b', 'null'), ('c', NULL)");
const rows = await db.query('SELECT grp, COUNT(*) AS c FROM g GROUP BY grp') as Record<string, unknown>[];
expect(rows).toHaveLength(2);
const byKey = new Map(rows.map((r) => [r.grp, r.c]));
expect(byKey.get(null)).toBe(2);
expect(byKey.get('null')).toBe(1);
await db.close();
});
test('aria: GROUP BY null 分离', async () => {
const db = await MetonaSqlark.create({ name: uniqueName('grp-aria'), mode: 'aria', diskEngine: 'memory' });
await db.defineTable('g', {
id: { type: 'string', primaryKey: true },
grp: { type: 'string' },
});
await db.query("INSERT INTO g VALUES ('a', NULL), ('b', 'null')");
const rows = await db.query('SELECT grp, COUNT(*) AS c FROM g GROUP BY grp') as Record<string, unknown>[];
expect(rows).toHaveLength(2);
await db.close();
});
test('memory: DISTINCT null 与 "\\0" 字符串分离', async () => {
const db = await MetonaSqlark.create({ name: uniqueName('dist'), mode: 'memory' });
await db.defineTable('g', {
id: { type: 'string', primaryKey: true },
grp: { type: 'string' },
});
await db.query(`INSERT INTO g VALUES ('a', NULL), ('b', '\\0'), ('c', '\\0')`);
const rows = await db.query('SELECT DISTINCT grp FROM g') as Record<string, unknown>[];
expect(rows).toHaveLength(2);
await db.close();
});
test('memory: UNION null 与字符串不吞并', async () => {
const db = await MetonaSqlark.create({ name: uniqueName('union'), mode: 'memory' });
await db.defineTable('g', {
id: { type: 'string', primaryKey: true },
grp: { type: 'string' },
});
await db.query("INSERT INTO g VALUES ('a', NULL), ('b', 'null')");
const rows = await db.query(
"SELECT grp FROM g WHERE id = 'a' UNION SELECT grp FROM g WHERE id = 'b'",
) as Record<string, unknown>[];
expect(rows).toHaveLength(2);
await db.close();
});
});
// ===================================================================
// 7. UPDATE 未知列报错
// ===================================================================
describe('v0.7.4: UPDATE 未知列显式报错', () => {
test.each([
['memory', 'memory'],
['disk', 'disk'],
['aria', 'aria'],
['hybrid', 'hybrid'],
] as const)('%s: SQL UPDATE 未知列 COLUMN_NOT_FOUND', async (_label, mode) => {
const db = await makeDb(mode);
await db.query("INSERT INTO t VALUES ('1', 10)");
await expect(db.query("UPDATE t SET nonexistent = 9 WHERE id = '1'")).rejects.toMatchObject({ code: 'COLUMN_NOT_FOUND' });
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
expect(JSON.stringify(rows[0])).not.toContain('nonexistent');
await db.close();
});
test('memory: Table API update 未知列报错', async () => {
const db = await makeDb('memory');
await db.query("INSERT INTO t VALUES ('1', 10)");
await expect(
db.table('t').update({ nonexistent: 1 }).where({ id: '1' }).execute(),
).rejects.toMatchObject({ code: 'COLUMN_NOT_FOUND' });
await db.close();
});
test('memory: 更新既有列不受影响(回归护栏)', async () => {
const db = await makeDb('memory');
await db.query("INSERT INTO t VALUES ('1', 10)");
const n = await db.query("UPDATE t SET v = 20 WHERE id = '1'");
expect(n).toBe(1);
await db.close();
});
});
// ===================================================================
// 8. queryStream 多语句
// ===================================================================
describe('v0.7.4: queryStream 多语句显式报错', () => {
test('queryStream 多语句抛 PARSE_ERROR(不静默忽略后续语句)', async () => {
const db = await makeDb('memory');
await db.query("INSERT INTO t VALUES ('1', 10)");
await expect(
db.queryStream('SELECT * FROM t; DELETE FROM t', () => {}),
).rejects.toMatchObject({ code: 'PARSE_ERROR' });
// 后续语句未执行
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
expect(rows).toHaveLength(1);
await db.close();
});
test('queryStream 单语句正常流式(回归护栏)', async () => {
const db = await makeDb('memory');
await db.query("INSERT INTO t VALUES ('1', 10), ('2', 20)");
let n = 0;
const count = await db.queryStream('SELECT * FROM t', () => { n++; });
expect(count).toBe(2);
expect(n).toBe(2);
await db.close();
});
});
// ===================================================================
// 9. KVStore 后台错误跨 reopen
// ===================================================================
describe('v0.7.4: KVStore 后台错误状态生命周期', () => {
test('close/reopen 后 checkpoint 不抛旧 KV_BACKGROUND_ERROR', async () => {
SharedMemoryBackend.clearRegistry();
const medium = new SharedMemoryBackend();
const kv1 = new KVStore(medium);
await kv1.open('v074-kv-life');
// 制造一次失败写入:直接在介质层破坏 append(用只读 medium 模拟失败)
await kv1.close();
// 用带失败注入的介质:append 抛错 → lastBackgroundError 记录
const failingMedium = {
open: async (_n: string) => {},
close: async () => {},
isOpen: () => true,
read: async () => null,
write: async () => {},
append: async () => { throw new Error('disk full'); },
writeMany: async () => {},
delete: async () => {},
deleteMany: async () => {},
listKeys: async () => [],
exists: async () => false,
clear: async () => {},
};
const kv2 = new KVStore(failingMedium as never);
await kv2.open('v074-kv-fail');
await expect(kv2.put('k', new TextEncoder().encode('v').buffer)).rejects.toThrow();
await kv2.close();
// 重开同一实例(同 medium 不再失败)
(failingMedium.append as unknown) = async () => {};
await kv2.open('v074-kv-fail');
// 无残留后台错误:checkpoint 不抛
await expect(kv2.checkpoint()).resolves.not.toThrow();
await kv2.close();
});
});
// ===================================================================
// 10. Hybrid beginTransaction 补偿
// ===================================================================
describe('v0.7.4: Hybrid beginTransaction 部分成功补偿', () => {
test('磁盘 begin 失败时内存快照回滚(后续事务可正常开始)', async () => {
const db = await MetonaSqlark.create({ name: uniqueName('hybrid-begin'), mode: 'hybrid', diskEngine: 'memory' });
await db.defineTable('t', {
id: { type: 'string', primaryKey: true },
});
const disk = (db.getEngine() as { getDiskEngineType?: () => string } & Record<string, unknown>);
// 让磁盘引擎 begin 抛错:先把磁盘引擎置于活跃事务
const diskEngine = (db.getEngine() as unknown as { getDiskEngine?: () => { beginTransaction(): Promise<void> } }).getDiskEngine
? undefined
: undefined;
// HybridEngine 无 getDiskEngine 公共接口 → 直接通过 disk 事务路径制造失败:
// 内存引擎先 begin 成功、磁盘引擎第二个 begin 报 TX_ACTIVE
const hybridEngine = db.getEngine() as unknown as {
beginTransaction: () => Promise<void>;
};
await hybridEngine.beginTransaction();
// 磁盘引擎现在活跃;再次 begin → 内存 begin 成功、磁盘抛 TX_ACTIVE → 补偿回滚
await expect(hybridEngine.beginTransaction()).rejects.toMatchObject({ code: 'TX_ACTIVE' });
await hybridEngine.rollbackTransaction();
// 补偿后事务可正常开始并提交
await db.transaction(async (trx) => {
await trx.table('t').insert({ id: '1' });
});
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
expect(rows).toHaveLength(1);
expect(diskEngine).toBeUndefined();
await db.close();
});
});
// ===================================================================
// 11. findStream 真惰性
// ===================================================================
describe('v0.7.4: aria findStream 真惰性(limit 提前终止)', () => {
test('大表 limit 1 流式只消费必要条目', async () => {
const db = await MetonaSqlark.create({ name: uniqueName('lazy'), mode: 'aria', diskEngine: 'memory' });
await db.defineTable('logs', {
id: { type: 'string', primaryKey: true },
level: { type: 'string' },
});
const rows: Record<string, unknown>[] = [];
for (let i = 0; i < 2000; i++) rows.push({ id: `l${i}`, level: i % 2 ? 'error' : 'info' });
await db.table('logs').insertMany(rows);
let called = 0;
const count = await db.table('logs').stream(
() => { called++; },
{ limit: 5 },
);
expect(count).toBe(5);
expect(called).toBe(5);
// 语义护栏:无 limit 时全量
let all = 0;
await db.table('logs').stream(() => { all++; });
expect(all).toBe(2000);
await db.close();
});
test('queryStream limit 流式正确 + 与物化结果一致', async () => {
const db = await MetonaSqlark.create({ name: uniqueName('lazy2'), mode: 'aria', diskEngine: 'memory' });
await db.defineTable('logs', {
id: { type: 'string', primaryKey: true },
level: { type: 'string' },
});
const rows: Record<string, unknown>[] = [];
for (let i = 0; i < 500; i++) rows.push({ id: `l${i}`, level: i % 2 ? 'error' : 'info' });
await db.table('logs').insertMany(rows);
const streamed: string[] = [];
const count = await db.queryStream(
"SELECT id FROM logs WHERE level = 'error' LIMIT 7",
(row) => { streamed.push((row as Record<string, unknown>).id as string); },
);
expect(count).toBe(7);
const materialized = await db.query(
"SELECT id FROM logs WHERE level = 'error' LIMIT 7",
) as Record<string, unknown>[];
expect(streamed).toEqual(materialized.map((r) => r.id));
await db.close();
});
});
// ===================================================================
// 12. reindex 多索引列重建
// ===================================================================
describe('v0.7.4: reindex 单次扫描重建多索引列', () => {
test('aria: 多索引列 REINDEX 后查询完整', async () => {
const db = await MetonaSqlark.create({ name: uniqueName('reidx'), mode: 'aria', diskEngine: 'memory' });
await db.defineTable('u', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', unique: true },
city: { type: 'string', index: true },
});
const rows: Record<string, unknown>[] = [];
for (let i = 0; i < 300; i++) {
rows.push({ id: `u${i}`, email: `e${i}@x.com`, city: i % 3 ? 'Beijing' : 'Shanghai' });
}
await db.table('u').insertMany(rows);
await db.query('REINDEX TABLE u');
const bj = await db.query("SELECT * FROM u WHERE city = 'Beijing'") as Record<string, unknown>[];
expect(bj).toHaveLength(200);
const email = await db.query("SELECT * FROM u WHERE email = 'e1@x.com'") as Record<string, unknown>[];
expect(email).toHaveLength(1);
// 唯一约束在重建后仍生效
await expect(db.table('u').insert({ id: 'uX', email: 'e1@x.com', city: 'Beijing' })).rejects.toMatchObject({ code: 'UNIQUE_VIOLATION' });
await db.close();
});
test('memory: reindex 路径不受影响(无实现护栏)', async () => {
const db = await MetonaSqlark.create({ name: uniqueName('reidx-mem'), mode: 'memory' });
await db.defineTable('u', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', unique: true },
});
await db.query("INSERT INTO u VALUES ('1', 'a@x.com')");
await expect(db.query('REINDEX TABLE u')).rejects.toMatchObject({ code: 'NOT_SUPPORTED' });
await db.close();
});
});