fix: v0.7.2 语句级原子性 + 事务 DDL 拒绝 + 约束/绑定硬化 — 6 项修复 + 43 回归 + CI 重型套件串行
- UPDATE 语句级部分提交(P1,四引擎):两阶段全量预检后执行,批内唯一互查, 任何一行失败整句不执行(aria 场景 WAL 与内存不再错位) - 事务内 ALTER/CREATE INDEX/DROP INDEX 残留(P1):Memory/KVStore 显式拒绝 (对齐 Aria),createTable/dropTable 保持可回滚 - SET NULL 级联绕过 required 约束(P1):预检阶段整体拒绝 FOREIGN_KEY_VIOLATION - bindParameters 注释误判(P2):行注释/块注释中的 ? 与引号不再参与绑定 - 未闭合字符串静默接受 → lexer 抛 PARSE_ERROR;未知 where 操作符抛 QUERY_ERROR - UPDATE undefined 覆盖列值 → 语义化为不更新(null 仍置空) - Hybrid 写穿透非原子(P1):磁盘失败自动重载内存对齐磁盘再抛原错误 - CI:Run tests 拆常规并行 + 重型串行(runInBand),重型测试超时余量提升, 性能护栏 kv 120→240s / opfs 150→300s(仍拦截悬崖回归) - 测试 1155 → 1198(74 套件),覆盖率 89.82% 保持
This commit is contained in:
+154
-25
@@ -7,6 +7,7 @@ import type { IStorageEngine } from './interface';
|
||||
import type { QueryPlan, TableSchema } from '../constants';
|
||||
import { DatabaseError } from '../constants';
|
||||
import { matchWhere, applyOrderBy, projectColumns } from '../query/where-matcher';
|
||||
import { stripUndefinedUpdates } from '../table/schema';
|
||||
|
||||
export class MemoryEngine implements IStorageEngine {
|
||||
readonly name = 'memory';
|
||||
@@ -96,6 +97,15 @@ export class MemoryEngine implements IStorageEngine {
|
||||
action: 'ADD' | 'DROP',
|
||||
column: import('../constants').ColumnDef & { name: string },
|
||||
): Promise<void> {
|
||||
// v0.7.2: 事务内 DDL 显式拒绝(与 AriaEngine 对齐)。此前事务快照对 schema
|
||||
// 是浅拷贝,alterTable 直接修改共享 columns 对象 → ROLLBACK 后结构变更残留
|
||||
// (三引擎行为不一致:Aria 拒绝 / Memory、KVStore 静默残留)
|
||||
if (this.snapshot) {
|
||||
throw new DatabaseError(
|
||||
`ALTER TABLE is not supported inside a transaction (MemoryEngine DDL is not transactional)`,
|
||||
'NOT_SUPPORTED',
|
||||
);
|
||||
}
|
||||
this.ensureTable(tableName);
|
||||
const schema = this.schemas.get(tableName)!;
|
||||
if (action === 'ADD') {
|
||||
@@ -184,36 +194,133 @@ export class MemoryEngine implements IStorageEngine {
|
||||
const schema = this.schemas.get(tableName)!;
|
||||
const table = this.tables.get(tableName)!;
|
||||
const pkCol = this.getPrimaryKey(schema);
|
||||
let count = 0;
|
||||
// v0.4.2-fix: 迭代期间会 delete/set 同一 Map(主键变更)→ 拷贝快照避免跳过/重复
|
||||
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);
|
||||
const newPk = String(updated[pkCol]);
|
||||
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据)
|
||||
if (newPk !== pk && table.has(newPk)) {
|
||||
throw new DatabaseError(
|
||||
`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`,
|
||||
'DUPLICATE_KEY',
|
||||
);
|
||||
}
|
||||
// v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
|
||||
if (newPk !== pk) {
|
||||
await this.applyUpdateCascade(tableName, pk, newPk);
|
||||
}
|
||||
table.delete(pk);
|
||||
table.set(newPk, updated);
|
||||
this.updateIndexes(tableName, updated, newPk);
|
||||
count++;
|
||||
// v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空
|
||||
const cleanUpdates = stripUndefinedUpdates(updates);
|
||||
|
||||
// v0.7.2: 语句级原子性 — 两阶段(先全量预检,后执行)。
|
||||
// 此前逐行"校验+写入":第 N 行唯一冲突/校验失败抛错时,前 N-1 行已写入
|
||||
// → 无事务下语句级部分提交(数据半更新且调用方已收到错误)。
|
||||
const planned: { pk: string; row: Record<string, unknown>; updated: Record<string, unknown>; newPk: string }[] = [];
|
||||
const batchUnique: Map<string, Set<unknown>> = new Map();
|
||||
|
||||
// 阶段 1:全量预检(任何一行失败 → 整条语句不执行)
|
||||
for (const [pk, row] of table) {
|
||||
if (query.where && Object.keys(query.where).length > 0 && !matchWhere(row, query.where)) continue;
|
||||
const updated = { ...row, ...cleanUpdates };
|
||||
this.validateRow(schema, updated);
|
||||
this.checkUpdateUniqueness(schema, tableName, pk, updated, batchUnique);
|
||||
const newPk = String(updated[pkCol]);
|
||||
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据)
|
||||
if (newPk !== pk && table.has(newPk)) {
|
||||
throw new DatabaseError(
|
||||
`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`,
|
||||
'DUPLICATE_KEY',
|
||||
);
|
||||
}
|
||||
planned.push({ pk, row, updated, newPk });
|
||||
}
|
||||
// 阶段 1b:主键变更 RESTRICT 预检(引用表依赖行检查,任何修改前)
|
||||
for (const p of planned) {
|
||||
if (p.newPk !== p.pk) this.checkUpdateRestrict(tableName, p.pk);
|
||||
}
|
||||
|
||||
// 阶段 2:执行(预检已通过,此阶段不再抛校验类错误)
|
||||
let count = 0;
|
||||
for (const { pk, row, updated, newPk } of planned) {
|
||||
// v0.3.3: 先移除旧值索引条目(修复 update 后唯一约束被绕过、按新值查索引丢行)
|
||||
this.removeIndexEntries(tableName, row, pk);
|
||||
// v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
|
||||
if (newPk !== pk) {
|
||||
await this.applyUpdateCascade(tableName, pk, newPk);
|
||||
}
|
||||
table.delete(pk);
|
||||
table.set(newPk, updated);
|
||||
this.updateIndexes(tableName, updated, newPk);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.7.2: 更新唯一性预检 — 批内互查(多条行更新到同一唯一值)+ 索引查
|
||||
* (排除自身旧条目)。阶段 1 中索引尚未更新,批内互查避免"两行同时改到
|
||||
* 同一新值"绕过唯一约束。
|
||||
*/
|
||||
private checkUpdateUniqueness(
|
||||
schema: TableSchema,
|
||||
tableName: string,
|
||||
pk: string,
|
||||
updated: 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 = updated[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)) {
|
||||
const pks = colIndex.get(value)!;
|
||||
// 值未变(新值 = 旧值)且索引中只有自身 → 允许
|
||||
if (!(pks.size === 1 && pks.has(pk))) {
|
||||
throw new DatabaseError(
|
||||
`Unique constraint violation on column "${colName}" in table "${schema.name}"`,
|
||||
'UNIQUE_VIOLATION',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.7.2: ON UPDATE RESTRICT 预检 — 从 applyUpdateCascade 提取,
|
||||
* 两阶段 update 在任何修改前调用(整体拒绝语义)。
|
||||
*/
|
||||
private checkUpdateRestrict(tableName: string, oldPk: string): void {
|
||||
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;
|
||||
let hasDependents = false;
|
||||
for (const [, refRow] of refTableData) {
|
||||
if (String(refRow[colName]) !== oldPk) continue;
|
||||
hasDependents = true;
|
||||
if (colDef.onUpdate === 'RESTRICT') {
|
||||
throw new DatabaseError(
|
||||
`Cannot update "${tableName}" key "${oldPk}": foreign key "${colName}" in "${refTableName}" has dependent rows`,
|
||||
'FOREIGN_KEY_VIOLATION',
|
||||
);
|
||||
}
|
||||
}
|
||||
// v0.7.2: SET NULL 到 required 列违反约束 —— 与 RESTRICT 同样整体拒绝
|
||||
// (此前级联直写 null 绕过 validateRow,required 列被静默置空)
|
||||
if (hasDependents && colDef.onUpdate === 'SET NULL' && colDef.required) {
|
||||
throw new DatabaseError(
|
||||
`Cannot update "${tableName}" key "${oldPk}": foreign key "${colName}" in "${refTableName}" is required (SET NULL violates constraint)`,
|
||||
'FOREIGN_KEY_VIOLATION',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.4.2-fix: ON UPDATE 外键级联 — 被引用表主键变更时处理引用表:
|
||||
* RESTRICT 抛错 / CASCADE 更新 FK 值 / SET NULL 置空。
|
||||
@@ -315,6 +422,13 @@ export class MemoryEngine implements IStorageEngine {
|
||||
'FOREIGN_KEY_VIOLATION',
|
||||
);
|
||||
}
|
||||
// v0.7.2: SET NULL 到 required 列违反约束 —— 预检阶段整体拒绝
|
||||
if (colDef.onDelete === 'SET NULL' && colDef.required && refPks.length > 0) {
|
||||
throw new DatabaseError(
|
||||
`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" is required (SET NULL violates constraint)`,
|
||||
'FOREIGN_KEY_VIOLATION',
|
||||
);
|
||||
}
|
||||
if (colDef.onDelete === 'CASCADE') {
|
||||
for (const refPk of refPks) {
|
||||
this.checkCascadeRestrict(refTableName, refPk, visited);
|
||||
@@ -343,6 +457,14 @@ export class MemoryEngine implements IStorageEngine {
|
||||
// ---- 动态索引(v0.3.0) ----
|
||||
|
||||
async createIndex(tableName: string, column: string, unique?: boolean): Promise<void> {
|
||||
// v0.7.2: 事务内修改列级标志(colDef.index/unique)会写入共享列对象,
|
||||
// 事务快照无法回滚 → 与 alterTable 同样显式拒绝
|
||||
if (this.snapshot) {
|
||||
throw new DatabaseError(
|
||||
`CREATE INDEX is not supported inside a transaction (MemoryEngine DDL is not transactional)`,
|
||||
'NOT_SUPPORTED',
|
||||
);
|
||||
}
|
||||
this.ensureTable(tableName);
|
||||
const schema = this.schemas.get(tableName)!;
|
||||
const colDef = schema.columns[column];
|
||||
@@ -365,6 +487,13 @@ export class MemoryEngine implements IStorageEngine {
|
||||
}
|
||||
|
||||
async dropIndex(tableName: string, column: string, _indexName?: string): Promise<void> {
|
||||
// v0.7.2: 同 createIndex —— 列级标志修改无法通过事务快照回滚,显式拒绝
|
||||
if (this.snapshot) {
|
||||
throw new DatabaseError(
|
||||
`DROP INDEX is not supported inside a transaction (MemoryEngine DDL is not transactional)`,
|
||||
'NOT_SUPPORTED',
|
||||
);
|
||||
}
|
||||
this.ensureTable(tableName);
|
||||
const schema = this.schemas.get(tableName)!;
|
||||
const colDef = schema.columns[column];
|
||||
|
||||
Reference in New Issue
Block a user