fix: v0.7.3 数据正确性与边界窗口收尾 — INSERT 语句级原子(三引擎+Aria PK 批内重复)/ 索引列 IS NULL 恒空 / delete RESTRICT 破坏索引 / queryStream 子查询静默空结果 / ALTER DROP 索引残留 / UNIQUE INDEX 存量校验 / SELECT * 别名投影 / WAL BEGIN/ROLLBACK 事务边界 / aria $in 与级联重复扫描性能 / $and 等值下推 / ANALYZE 索引统计 / React-Vue hooks 生命周期 / 迁移主键兜底 + 58 回归
CI / test (18.x) (push) Successful in 17m43s
CI / test (22.x) (push) Successful in 13m45s
CI / test (20.x) (push) Successful in 15m33s
CI / test (24.x) (push) Successful in 24m46s
CI / e2e (push) Successful in 52s

This commit is contained in:
thzxx
2026-08-14 22:53:46 +08:00
parent f8f8d1b2ff
commit 50468b9b0e
29 changed files with 2374 additions and 650 deletions
+133 -58
View File
@@ -4,7 +4,7 @@
*/
import type { IStorageEngine } from './interface';
import type { QueryPlan, TableSchema } from '../constants';
import type { QueryPlan, TableSchema, WhereCondition } from '../constants';
import { DatabaseError } from '../constants';
import { matchWhere, applyOrderBy, projectColumns } from '../query/where-matcher';
import { stripUndefinedUpdates } from '../table/schema';
@@ -118,6 +118,11 @@ export class MemoryEngine implements IStorageEngine {
if (!schema.columns[column.name]) {
throw new DatabaseError(`Column "${column.name}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND');
}
// v0.7.3: 被删列是索引列 → 同步清理索引 Map —— 此前残留旧索引:
// 查询已删列仍走旧索引(不含新行)→ 结果不完整(对齐 AriaEngine cleanupTableIndexes
if (schema.columns[column.name].index || schema.columns[column.name].unique) {
this.indexes.get(tableName)?.delete(column.name);
}
delete schema.columns[column.name];
// 清理已有行中该列的值(find 返回行引用,直接删除生效)
const table = this.tables.get(tableName)!;
@@ -134,11 +139,31 @@ export class MemoryEngine implements IStorageEngine {
const pkColumn = this.getPrimaryKey(schema);
const pks: string[] = [];
// v0.7.3: 语句级原子性 —— 两阶段(先全量预检,后执行)。
// 此前逐行"校验+写入":第 N 行主键重复/唯一冲突抛错时,前 N-1 行已提交
// (无事务下语句级部分提交,与 v0.7.2 修复的 UPDATE 同类问题)。
const validated: Record<string, unknown>[] = [];
const pkSet = new Set<string>();
const batchUnique: Map<string, Set<unknown>> = new Map();
// 阶段 1:全量预检(任何一行失败 → 整条语句不执行)
for (const row of rows) {
const validatedRow = this.validateRow(schema, row);
const pkValue = String(validatedRow[pkColumn]);
if (table.has(pkValue)) throw new DatabaseError(`Duplicate primary key "${pkValue}" in table "${tableName}"`, 'DUPLICATE_KEY');
this.checkUniqueness(schema, validatedRow);
// 批内主键互查(内存表尚未反映本批写入)
if (table.has(pkValue) || pkSet.has(pkValue)) {
throw new DatabaseError(`Duplicate primary key "${pkValue}" in table "${tableName}"`, 'DUPLICATE_KEY');
}
pkSet.add(pkValue);
// v0.7.3: 批内唯一互查 + 索引查(此前两行同批写入同一唯一值时,
// 第一行已写入索引 → 第二行 checkUniqueness 抛错 → 第一行残留)
this.checkInsertUniqueness(schema, tableName, validatedRow, batchUnique);
validated.push(validatedRow);
}
// 阶段 2:执行(预检已通过,此阶段不再抛校验类错误)
for (const validatedRow of validated) {
const pkValue = String(validatedRow[pkColumn]);
table.set(pkValue, validatedRow);
this.updateIndexes(tableName, validatedRow, pkValue);
pks.push(pkValue);
@@ -146,6 +171,13 @@ export class MemoryEngine implements IStorageEngine {
return pks;
}
/** v0.7.3: 按主键取已验证行(KVStoreEngine 持久化 validated 行用,含 default/类型归一) */
getRow(tableName: string, pkValue: string): Record<string, unknown> | null {
const table = this.tables.get(tableName);
if (!table) return null;
return table.get(pkValue) ?? null;
}
async find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]> {
this.ensureTable(tableName);
const table = this.tables.get(tableName)!;
@@ -242,7 +274,46 @@ export class MemoryEngine implements IStorageEngine {
}
/**
* v0.7.2: 更新唯一性预检 — 批内互查(多条行更新到同一唯一值)+ 索引查
* v0.7.3: 插入唯一性预检 — 批内互查(本批前几行写入同一唯一值)
* + 索引查(表中已有行)。与 update 的 checkUpdateUniqueness 对称,
* 两阶段 insert 预检阶段调用(索引尚未反映本批写入)。
*/
private checkInsertUniqueness(
schema: TableSchema,
tableName: string,
row: Record<string, unknown>,
batchUnique: Map<string, Set<unknown>>,
): void {
const tableIndexes = this.indexes.get(tableName);
for (const [colName, colDef] of Object.entries(schema.columns)) {
if (!colDef.unique) continue;
const value = row[colName];
if (value === undefined || value === null) continue;
let seen = batchUnique.get(colName);
if (!seen) {
seen = new Set<unknown>();
batchUnique.set(colName, seen);
}
if (seen.has(value)) {
throw new DatabaseError(
`Unique constraint violation on column "${colName}" in table "${schema.name}"`,
'UNIQUE_VIOLATION',
);
}
seen.add(value);
if (!tableIndexes) continue;
const colIndex = tableIndexes.get(colName);
if (colIndex && colIndex.has(value)) {
throw new DatabaseError(
`Unique constraint violation on column "${colName}" in table "${schema.name}"`,
'UNIQUE_VIOLATION',
);
}
}
}
/**
* v0.7.3: 更新唯一性预检 — 批内互查(多条行更新到同一唯一值)+ 索引查
* (排除自身旧条目)。阶段 1 中索引尚未更新,批内互查避免"两行同时改到
* 同一新值"绕过唯一约束。
*/
@@ -324,29 +395,11 @@ export class MemoryEngine implements IStorageEngine {
/**
* v0.4.2-fix: ON UPDATE 外键级联 — 被引用表主键变更时处理引用表:
* RESTRICT 抛错 / CASCADE 更新 FK 值 / SET NULL 置空。
* 分两阶段:先全量 RESTRICT 检查(任何修改前),再执行级联(防部分修改)。
* v0.7.3-perf: 删除冗余的阶段 1 RESTRICT 扫描 —— checkUpdateRestrict 已在
* 两阶段 update 预检(阶段 1b)覆盖 RESTRICT 与 SET NULL+required
* 此处任何修改前重复全表扫描纯属浪费。直接执行 CASCADE / SET NULL。
*/
private async applyUpdateCascade(tableName: string, oldPk: string, newPk: string): Promise<void> {
// 阶段 1: RESTRICT 检查(引用旧主键的行存在即拒绝)
for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) continue;
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate) continue;
const [refTable] = colDef.references.split('.');
if (refTable !== tableName) continue;
const refTableData = this.tables.get(refTableName);
if (!refTableData) continue;
for (const [, refRow] of refTableData) {
if (String(refRow[colName]) === oldPk && colDef.onUpdate === 'RESTRICT') {
throw new DatabaseError(
`Cannot update "${tableName}" key "${oldPk}": foreign key "${colName}" in "${refTableName}" has dependent rows`,
'FOREIGN_KEY_VIOLATION',
);
}
}
}
}
// 阶段 2: CASCADE / SET NULL
for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) continue;
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
@@ -369,29 +422,30 @@ export class MemoryEngine implements IStorageEngine {
async delete(tableName: string, query: QueryPlan): Promise<number> {
this.ensureTable(tableName);
const table = this.tables.get(tableName)!;
const toDelete: string[] = [];
const toDelete: { pk: string; row: Record<string, unknown> }[] = [];
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);
toDelete.push({ pk, row });
}
}
// v0.6.3-fix: 级联两阶段 —— 先对全部待删行做 RESTRICT 预检(沿 CASCADE 链递归),
// 任何一行违规则整体拒绝。此前逐行执行:第 N 行 RESTRICT 抛错时,前 N-1 行的
// 级联子行已被删除、父行未删 → 无事务下部分级联(数据不一致)
//
// v0.7.3-fix: 索引清理移到预检之后 —— 此前 removeIndexEntries 在收集阶段执行,
// RESTRICT 预检抛错时行未删但索引条目已删 → 唯一约束失效、索引查询丢行
const restrictVisited = new Set<string>();
for (const pk of toDelete) {
const row = table.get(pk);
if (row) this.checkCascadeRestrict(tableName, pk, restrictVisited);
for (const { pk } of toDelete) {
this.checkCascadeRestrict(tableName, pk, restrictVisited);
}
// 级联删除:检查引用此表的其他表(RESTRICT 已预检通过,此阶段不再抛
// 预检通过:清理索引 + 级联删除(此阶段不再抛校验类错误
let cascadeCount = 0;
for (const pk of toDelete) {
const row = table.get(pk);
if (row) cascadeCount += await this.cascadeDelete(tableName, pk, row);
for (const { pk, row } of toDelete) {
// v0.3.3: 删除行前清理其索引条目(修复删除后索引残留)
this.removeIndexEntries(tableName, row, pk);
cascadeCount += await this.cascadeDelete(tableName, pk, row);
}
for (const pk of toDelete) table.delete(pk);
for (const { pk } of toDelete) table.delete(pk);
return toDelete.length + cascadeCount;
}
@@ -470,20 +524,34 @@ export class MemoryEngine implements IStorageEngine {
const colDef = schema.columns[column];
if (!colDef) throw new DatabaseError(`Column "${column}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND');
if (colDef.index || colDef.unique) return; // 已存在
colDef.index = true;
if (unique) colDef.unique = true;
const tableIndexes = this.indexes.get(tableName)!;
if (!tableIndexes.has(column)) tableIndexes.set(column, new Map());
const colIndex = tableIndexes.get(column)!;
const table = this.tables.get(tableName)!;
for (const [pk, row] of table) {
const value = row[column];
if (value !== undefined && value !== null) {
if (!colIndex.has(value)) colIndex.set(value, new Set());
colIndex.get(value)!.add(pk);
try {
for (const [pk, row] of table) {
const value = row[column];
if (value !== undefined && value !== null) {
// v0.7.3: UNIQUE 索引回填校验存量唯一性 —— 此前重复数据静默建索引
// (SQLite 语义应报错),且此后该列唯一约束永远无法满足
if (unique && colIndex.has(value)) {
throw new DatabaseError(
`Unique index on column "${column}" in table "${tableName}" cannot be created: duplicate value "${String(value)}"`,
'UNIQUE_VIOLATION',
);
}
if (!colIndex.has(value)) colIndex.set(value, new Set());
colIndex.get(value)!.add(pk);
}
}
} catch (error) {
// 回填失败(唯一冲突):清理半初始化索引,标志未落,保持原子语义
tableIndexes.delete(column);
throw error;
}
colDef.index = true;
if (unique) colDef.unique = true;
}
async dropIndex(tableName: string, column: string, _indexName?: string): Promise<void> {
@@ -593,26 +661,29 @@ export class MemoryEngine implements IStorageEngine {
}
}
/** O(1) 唯一性检查:利用哈希索引 */
private checkUniqueness(schema: TableSchema, row: Record<string, unknown>): void {
const tableIndexes = this.indexes.get(schema.name);
if (!tableIndexes) return;
for (const [colName, colDef] of Object.entries(schema.columns)) {
if (!colDef.unique || row[colName] === undefined || row[colName] === null) continue;
const colIndex = tableIndexes.get(colName);
if (colIndex && colIndex.has(row[colName])) {
throw new DatabaseError(`Unique constraint violation on column "${colName}" in table "${schema.name}"`, 'UNIQUE_VIOLATION');
}
}
}
/** 索引查找 */
private tryIndexLookup(
tableName: string, table: Map<string, Record<string, unknown>>, query: QueryPlan,
): Record<string, unknown>[] {
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.7.3: 递归展开 $and 中的等值条件 —— 此前仅顶层键,
// `WHERE a AND b`(解析为顶层 $and)永远全表扫描,索引形同虚设。
// $or/$not 语义不适用单索引下推,保守跳过。命中索引后 find 仍以
// 全条件 matchWhere 过滤(子集语义安全)。
const flat: [string, unknown][] = [];
const collect = (w: WhereCondition): void => {
for (const [k, v] of Object.entries(w)) {
if (k === '$and') {
for (const sub of (v as WhereCondition[])) collect(sub);
continue;
}
if (k === '$or' || k === '$not') continue;
flat.push([k, v]);
}
};
collect(query.where);
for (const [col, condition] of flat) {
// v0.4.1: 支持 { $eq: value } 形式(SQL 解析器生成的等值条件)走索引
let targetValue: unknown;
if (typeof condition !== 'object' || condition === null) {
@@ -622,6 +693,10 @@ export class MemoryEngine implements IStorageEngine {
} else {
continue;
}
// v0.7.3: null/undefined 条件不走索引 —— 索引不含 null 条目,
// colIndex.get(null) 恒 undefined → return [] 短路全表扫描 → 索引列
// IS NULL 恒空(对齐 AriaEngine v0.6.2 修复)
if (targetValue === null || targetValue === undefined) continue;
const colIndex = tableIndexes.get(col);
if (colIndex) {
const pks = colIndex.get(targetValue);