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:
+141
-57
@@ -9,7 +9,7 @@ import type { IStorageEngine } from '../interface';
|
||||
import type { QueryPlan, TableSchema, ColumnDef } from '../../constants';
|
||||
import { DatabaseError } from '../../constants';
|
||||
import { matchWhere, applyOrderBy, projectColumns } from '../../query/where-matcher';
|
||||
import { checkFieldType } from '../../table/schema';
|
||||
import { checkFieldType, stripUndefinedUpdates } from '../../table/schema';
|
||||
|
||||
import type { AriaEngineConfig, SSTableMeta } from './types';
|
||||
import { DEFAULT_ARIA_CONFIG } from './types';
|
||||
@@ -695,16 +695,18 @@ export class AriaEngine implements IStorageEngine {
|
||||
const walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[] = [];
|
||||
// v0.4.2-fix: ON UPDATE 级联环路保护
|
||||
const visited = new Set<string>();
|
||||
// v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空
|
||||
const cleanUpdates = stripUndefinedUpdates(updates);
|
||||
|
||||
// v0.6.2: 唯一约束 — 批量预加载本批更新涉及的唯一列索引范围(一次 drainChain)
|
||||
const uniqueCols = this.uniqueColumns(tableName, schema);
|
||||
for (const colName of uniqueCols) {
|
||||
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`)!;
|
||||
const ranges: [string, string][] = [];
|
||||
if (updates[colName] !== undefined && updates[colName] !== null) {
|
||||
const p = `${String(updates[colName])}:`;
|
||||
if (cleanUpdates[colName] !== undefined && cleanUpdates[colName] !== null) {
|
||||
const p = `${String(cleanUpdates[colName])}:`;
|
||||
ranges.push([p, `${p}\uffff`]);
|
||||
} else if (!(colName in updates)) {
|
||||
} else if (!(colName in cleanUpdates)) {
|
||||
for (const row of rows) {
|
||||
const val = row[colName];
|
||||
if (val === undefined || val === null) continue;
|
||||
@@ -715,77 +717,95 @@ export class AriaEngine implements IStorageEngine {
|
||||
await idxLsm.prefetchPrefixRanges(ranges);
|
||||
}
|
||||
|
||||
// v0.7.2: 语句级原子性 — 两阶段(先全量预检,后执行)。
|
||||
// 此前逐行"校验+写入":第 N 行唯一冲突/校验失败抛错时,前 N-1 行已写入
|
||||
// 且其 WAL 记录随 appendBatch 一起丢失 → 内存已改、WAL 无记录、调用方已收到错误
|
||||
// (无事务下语句级部分提交 + 崩溃后进一步不一致)。
|
||||
const planned: { row: Record<string, unknown>; pk: string; key: string; updated: Record<string, unknown>; newPk: string; pkChanged: boolean }[] = [];
|
||||
const batchUnique: Map<string, Set<unknown>> = new Map();
|
||||
|
||||
// 阶段 1:全量预检(任何一行失败 → 整条语句不执行)
|
||||
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)) continue;
|
||||
|
||||
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
|
||||
const updated = { ...row, ...updates };
|
||||
this.validateRow(schema, updated);
|
||||
const updated = { ...row, ...cleanUpdates };
|
||||
this.validateRow(schema, updated);
|
||||
|
||||
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在)
|
||||
this.checkUniqueSync(tableName, uniqueCols, updated, String(row[pkCol]));
|
||||
// 批内唯一互查(索引尚未更新,两行同时改到同一新值需要互查兜底)
|
||||
this.checkBatchUnique(tableName, uniqueCols, updated, batchUnique);
|
||||
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在)
|
||||
this.checkUniqueSync(tableName, uniqueCols, updated, String(row[pkCol]));
|
||||
|
||||
// v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录
|
||||
const newPk = String(updated[pkCol]);
|
||||
const pkChanged = newPk !== String(row[pkCol]);
|
||||
// v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录
|
||||
const newPk = String(updated[pkCol]);
|
||||
const pkChanged = newPk !== String(row[pkCol]);
|
||||
|
||||
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY
|
||||
// (此前静默覆盖另一行丢数据;与 MemoryEngine 对齐)
|
||||
if (pkChanged) {
|
||||
const newKey = `${tableName}:${newPk}`;
|
||||
const existing = this.currentTxnId
|
||||
? (this.txnSnapshot?.get(newKey) ?? this.lsm.get(newKey))
|
||||
: this.lsm.get(newKey);
|
||||
if (existing && !(existing as unknown as Record<string, unknown>).__txn_deleted) {
|
||||
throw new DatabaseError(
|
||||
`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`,
|
||||
'DUPLICATE_KEY',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (pkChanged) {
|
||||
// ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL)
|
||||
await this.applyForeignKeyUpdateRules(
|
||||
tableName, String(row[pkCol]), newPk, walRecords, visited,
|
||||
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY
|
||||
// (此前静默覆盖另一行丢数据;与 MemoryEngine 对齐)
|
||||
if (pkChanged) {
|
||||
const newKey = `${tableName}:${newPk}`;
|
||||
const existing = this.currentTxnId
|
||||
? (this.txnSnapshot?.get(newKey) ?? this.lsm.get(newKey))
|
||||
: this.lsm.get(newKey);
|
||||
if (existing && !(existing as unknown as Record<string, unknown>).__txn_deleted) {
|
||||
throw new DatabaseError(
|
||||
`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`,
|
||||
'DUPLICATE_KEY',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.currentTxnId && this.txnSnapshot) {
|
||||
if (pkChanged) {
|
||||
this.txnSnapshot.set(key, { __txn_deleted: true } as unknown as Record<string, unknown>);
|
||||
this.mvcc.deleteVersion(tableName, String(row[pkCol]), this.currentTxnId);
|
||||
}
|
||||
this.txnSnapshot.set(`${tableName}:${newPk}`, updated);
|
||||
this.mvcc.writeVersion(tableName, newPk, updated, this.currentTxnId);
|
||||
} else {
|
||||
if (pkChanged) this.lsm.delete(key);
|
||||
this.lsm.put(`${tableName}:${newPk}`, updated);
|
||||
}
|
||||
count++;
|
||||
planned.push({ row, pk: String(row[pkCol]), key, updated, newPk, pkChanged });
|
||||
}
|
||||
// 阶段 1b:主键变更 RESTRICT / SET NULL+required 预检(任何修改前)
|
||||
for (const p of planned) {
|
||||
if (p.pkChanged) {
|
||||
await this.checkForeignKeyUpdateRestrict(tableName, p.pk, p.newPk);
|
||||
}
|
||||
}
|
||||
|
||||
// 阶段 2:执行(预检已通过,此阶段不再抛校验类错误)
|
||||
for (const { row, pk, key, updated, newPk, pkChanged } of planned) {
|
||||
if (pkChanged) {
|
||||
// ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL)
|
||||
await this.applyForeignKeyUpdateRules(tableName, pk, newPk, walRecords, visited);
|
||||
}
|
||||
|
||||
if (this.currentTxnId && this.txnSnapshot) {
|
||||
if (pkChanged) {
|
||||
walRecords.push({
|
||||
type: WALRecordType.DELETE,
|
||||
txnId: this.currentTxnId ?? 0,
|
||||
tableName,
|
||||
key: String(row[pkCol]),
|
||||
});
|
||||
this.txnSnapshot.set(key, { __txn_deleted: true } as unknown as Record<string, unknown>);
|
||||
this.mvcc.deleteVersion(tableName, pk, this.currentTxnId);
|
||||
}
|
||||
this.txnSnapshot.set(`${tableName}:${newPk}`, updated);
|
||||
this.mvcc.writeVersion(tableName, newPk, updated, this.currentTxnId);
|
||||
} else {
|
||||
if (pkChanged) this.lsm.delete(key);
|
||||
this.lsm.put(`${tableName}:${newPk}`, updated);
|
||||
}
|
||||
count++;
|
||||
|
||||
if (pkChanged) {
|
||||
walRecords.push({
|
||||
type: WALRecordType.UPDATE,
|
||||
type: WALRecordType.DELETE,
|
||||
txnId: this.currentTxnId ?? 0,
|
||||
tableName,
|
||||
key: newPk,
|
||||
data: updated,
|
||||
key: pk,
|
||||
});
|
||||
|
||||
// 更新二级索引(主键变更时旧索引条目一并清理)
|
||||
// v0.6.2-fix: 此前非主键更新不传旧行 → 旧索引条目残留
|
||||
// (唯一性检查误报 / 索引存储膨胀);现在统一传旧行清理旧值
|
||||
this.updateSecondaryIndexes(tableName, newPk, updated, row);
|
||||
}
|
||||
walRecords.push({
|
||||
type: WALRecordType.UPDATE,
|
||||
txnId: this.currentTxnId ?? 0,
|
||||
tableName,
|
||||
key: newPk,
|
||||
data: updated,
|
||||
});
|
||||
|
||||
// 更新二级索引(主键变更时旧索引条目一并清理)
|
||||
// v0.6.2-fix: 此前非主键更新不传旧行 → 旧索引条目残留
|
||||
// (唯一性检查误报 / 索引存储膨胀);现在统一传旧行清理旧值
|
||||
this.updateSecondaryIndexes(tableName, newPk, updated, row);
|
||||
}
|
||||
|
||||
await this.wal.appendBatch(walRecords);
|
||||
@@ -796,6 +816,63 @@ export class AriaEngine implements IStorageEngine {
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.7.2: 批内唯一互查 — 两条行在同一语句中更新到同一唯一值时的兜底检查
|
||||
* (阶段 1 中索引尚未反映本语句的变更)。
|
||||
*/
|
||||
private checkBatchUnique(
|
||||
tableName: string,
|
||||
uniqueCols: string[],
|
||||
updated: Record<string, unknown>,
|
||||
batchUnique: Map<string, Set<unknown>>,
|
||||
): void {
|
||||
for (const colName of uniqueCols) {
|
||||
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 "${tableName}"`,
|
||||
'UNIQUE_VIOLATION',
|
||||
);
|
||||
}
|
||||
seen.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.7.2: ON UPDATE 外键预检 — 从 applyForeignKeyUpdateRules 提取(两阶段 update 用):
|
||||
* RESTRICT 存在依赖行抛错;SET NULL 撞 required 列同样整体拒绝。
|
||||
*/
|
||||
private async checkForeignKeyUpdateRestrict(tableName: string, oldPk: string, _newPk: string): Promise<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;
|
||||
if (colDef.onUpdate === 'RESTRICT' || (colDef.onUpdate === 'SET NULL' && colDef.required)) {
|
||||
const refRows = await this.getAllRows(refTableName);
|
||||
for (const refRow of refRows) {
|
||||
if (String(refRow[colName]) === oldPk) {
|
||||
const reason = colDef.onUpdate === 'RESTRICT'
|
||||
? `foreign key "${colName}" in "${refTableName}" has dependent rows`
|
||||
: `foreign key "${colName}" in "${refTableName}" is required (SET NULL violates constraint)`;
|
||||
throw new DatabaseError(
|
||||
`Cannot update "${tableName}" key "${oldPk}": ${reason}`,
|
||||
'FOREIGN_KEY_VIOLATION',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.4.2-fix: ON UPDATE 外键级联 — 主键 oldPk → newPk 时处理引用表。
|
||||
* RESTRICT 抛错 / CASCADE 更新 FK / SET NULL 置空(含索引与 WAL 记录)。
|
||||
@@ -947,6 +1024,13 @@ export class AriaEngine implements IStorageEngine {
|
||||
'FOREIGN_KEY_VIOLATION',
|
||||
);
|
||||
}
|
||||
// v0.7.2: SET NULL 到 required 列违反约束 —— 预检阶段整体拒绝
|
||||
if (colDef.onDelete === 'SET NULL' && colDef.required && matched.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') {
|
||||
const refPkCol = this.tablePKs.get(refTableName)!;
|
||||
for (const refRow of matched) {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { DatabaseError } from '../constants';
|
||||
import { MemoryEngine } from './memory';
|
||||
import { KVStore } from './kvstore/index';
|
||||
import type { IStorageBackend } from './aria/store/backend';
|
||||
import { stripUndefinedUpdates } from '../table/schema';
|
||||
|
||||
const SCHEMA_KEY = '__schema';
|
||||
const ROW_PREFIX = 't:';
|
||||
@@ -229,6 +230,15 @@ export class KVStoreEngine implements IStorageEngine {
|
||||
column: import('../constants').ColumnDef & { name: string },
|
||||
): Promise<void> {
|
||||
this.ensureOpen();
|
||||
// v0.7.2: 事务内 ALTER 显式拒绝(与 AriaEngine/MemoryEngine 对齐)——
|
||||
// memory.alterTable 直接修改共享 columns 对象,事务快照无法回滚
|
||||
// (此前 ROLLBACK 后新增列残留)
|
||||
if (this.txActive) {
|
||||
throw new DatabaseError(
|
||||
`ALTER TABLE is not supported inside a transaction (KVStoreEngine DDL is not transactional)`,
|
||||
'NOT_SUPPORTED',
|
||||
);
|
||||
}
|
||||
await this.memory.alterTable(tableName, action, column);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(tableName);
|
||||
@@ -296,11 +306,13 @@ export class KVStoreEngine implements IStorageEngine {
|
||||
const schema = await this.memory.getTableSchema(tableName);
|
||||
if (!schema) throw new DatabaseError(`Table "${tableName}" does not exist`, 'TABLE_NOT_FOUND');
|
||||
const pkCol = this.getPK(schema);
|
||||
const pkChanged = pkCol in updates;
|
||||
// v0.7.2: undefined 值视为"不更新该列"(与 memory.update 语义对齐)
|
||||
const cleanUpdates = stripUndefinedUpdates(updates);
|
||||
const pkChanged = pkCol in cleanUpdates;
|
||||
|
||||
// 收集受影响旧主键(内存匹配)
|
||||
const affected = pkChanged ? [] : await this.collectMatchingPks(tableName, query);
|
||||
const count = await this.memory.update(tableName, query, updates);
|
||||
const count = await this.memory.update(tableName, query, cleanUpdates);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(tableName);
|
||||
// v0.7.0: 行级变更记录 —— 主键变更无法行级追踪(旧键删除+新键落盘+级联),
|
||||
@@ -425,6 +437,12 @@ export class KVStoreEngine implements IStorageEngine {
|
||||
|
||||
async createIndex(tableName: string, column: string, unique?: boolean): Promise<void> {
|
||||
this.ensureOpen();
|
||||
if (this.txActive) {
|
||||
throw new DatabaseError(
|
||||
`CREATE INDEX is not supported inside a transaction (KVStoreEngine DDL is not transactional)`,
|
||||
'NOT_SUPPORTED',
|
||||
);
|
||||
}
|
||||
await this.memory.createIndex(tableName, column, unique);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(tableName);
|
||||
@@ -436,6 +454,12 @@ export class KVStoreEngine implements IStorageEngine {
|
||||
|
||||
async dropIndex(tableName: string, column: string, indexName?: string): Promise<void> {
|
||||
this.ensureOpen();
|
||||
if (this.txActive) {
|
||||
throw new DatabaseError(
|
||||
`DROP INDEX is not supported inside a transaction (KVStoreEngine DDL is not transactional)`,
|
||||
'NOT_SUPPORTED',
|
||||
);
|
||||
}
|
||||
await this.memory.dropIndex(tableName, column, indexName);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(tableName);
|
||||
|
||||
+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