fix: v0.7.2 语句级原子性 + 事务 DDL 拒绝 + 约束/绑定硬化 — 6 项修复 + 43 回归 + CI 重型套件串行
CI / test (22.x) (push) Successful in 24m26s
CI / e2e (push) Successful in 10m0s
CI / test (18.x) (push) Successful in 27m14s
CI / test (20.x) (push) Failing after 1h19m9s
CI / test (24.x) (push) Successful in 37m49s

- 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:
thzxx
2026-08-13 15:31:16 +08:00
parent cbe407eb49
commit 05e6823bf1
31 changed files with 2635 additions and 762 deletions
+154 -25
View File
@@ -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 绕过 validateRowrequired 列被静默置空)
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];