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
+501 -201
View File
@@ -30,7 +30,7 @@ class DatabaseError extends Error {
// ---------------------------------------------------------------------------
// 版本
// ---------------------------------------------------------------------------
const VERSION = '0.7.1';
const VERSION = '0.7.2';
/**
* metona-sqlark Shared WHERE Matcher 统一的条件匹配逻辑
@@ -156,7 +156,10 @@ function matchOperator(value, op, operand) {
case '$in': return Array.isArray(operand) && operand.includes(value);
case '$nin': return Array.isArray(operand) && !operand.includes(value);
case '$like': return compileLikeRegex(String(operand)).test(String(value));
default: return true;
// v0.7.2: 未知操作符显式报错 —— 此前静默返回 true(所有行匹配),
// 拼错操作符(如 $betwen)时过滤形同虚设且无任何提示
default:
throw new DatabaseError(`Unknown where operator "${op}"`, 'QUERY_ERROR');
}
}
// ---------------------------------------------------------------------------
@@ -215,6 +218,123 @@ function projectColumns(row, columns) {
return projected;
}
/**
* metona-sqlark Schema 表结构定义与校验
* @module table/schema
*/
// ---------------------------------------------------------------------------
// Schema 工具
// ---------------------------------------------------------------------------
/** 从列定义创建 TableSchema */
function createSchema(name, columns) {
validateColumns(columns);
return { name, columns };
}
/** 校验列定义 */
function validateColumns(columns) {
const colNames = Object.keys(columns);
if (colNames.length === 0) {
throw new DatabaseError('Table must have at least one column', 'SCHEMA_ERROR');
}
let primaryKeyCount = 0;
for (const [colName, colDef] of Object.entries(columns)) {
// v0.7.1: '__proto__' 作为列名会触发对象原型 setter(列静默丢失);
// 显式拒绝避免原型污染类攻击面
if (colName === '__proto__') {
throw new DatabaseError('Column name "__proto__" is not allowed', 'SCHEMA_ERROR');
}
// 类型校验
if (!FIELD_TYPES.includes(colDef.type)) {
throw new DatabaseError(`Invalid type "${colDef.type}" for column "${colName}". Valid types: ${FIELD_TYPES.join(', ')}`, 'SCHEMA_ERROR');
}
// 主键计数
if (colDef.primaryKey) {
primaryKeyCount++;
}
}
// 至少需要一个主键
if (primaryKeyCount === 0) {
throw new DatabaseError('Table must have at least one primary key column', 'SCHEMA_ERROR');
}
// v0.7.0: 复合主键(多列 primaryKey)当前不支持 —— 所有引擎的存储布局与
// 外键引用均为单主键假设(此前静默取第一个主键,其余标记被忽略 → 语义陷阱)。
// 显式拒绝避免用户误用;复合主键列入 v0.8 路线图。
if (primaryKeyCount > 1) {
throw new DatabaseError(`Composite primary keys are not supported yet: table has ${primaryKeyCount} primary key columns. ` +
'Use a single primary key column (or a unique column combination) instead.', 'SCHEMA_ERROR');
}
}
/**
* v0.7.2: 更新载荷清洗 undefined 值视为"不更新该列"保留旧值
* 此前 `update({ col: undefined })` 会把 undefined 写入行覆盖旧值列键丢失
* null 保留显式置空语义
*/
function stripUndefinedUpdates(updates) {
const clean = {};
for (const [key, value] of Object.entries(updates)) {
if (value !== undefined)
clean[key] = value;
}
return clean;
}
/** 检查字段类型(含约束校验) */
function checkFieldType(tableName, colName, type, value, colDef) {
const jsType = typeof value;
switch (type) {
case 'string':
if (jsType !== 'string') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects string, got ${jsType}`, 'TYPE_ERROR');
}
if (colDef?.maxLength !== undefined && value.length > colDef.maxLength) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" exceeds max length ${colDef.maxLength}`, 'VALIDATION_ERROR');
}
break;
case 'number':
if (jsType !== 'number') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects number, got ${jsType}`, 'TYPE_ERROR');
}
if (colDef?.min !== undefined && value < colDef.min) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" value ${value} below minimum ${colDef.min}`, 'VALIDATION_ERROR');
}
if (colDef?.max !== undefined && value > colDef.max) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" value ${value} above maximum ${colDef.max}`, 'VALIDATION_ERROR');
}
break;
case 'boolean':
if (jsType !== 'boolean') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects boolean, got ${jsType}`, 'TYPE_ERROR');
}
break;
case 'date':
if (jsType !== 'string' || isNaN(Date.parse(value))) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects valid date string, got ${typeof value}`, 'TYPE_ERROR');
}
break;
case 'json':
if (jsType !== 'object') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects object/array, got ${jsType}`, 'TYPE_ERROR');
}
break;
}
}
/** 将 AST 列定义转换为 ColumnDef */
function astColumnToColumnDef(astCol) {
return {
type: astCol.type,
primaryKey: astCol.primaryKey,
unique: astCol.unique,
required: astCol.required,
default: astCol.default,
index: astCol.index,
maxLength: astCol.maxLength,
min: astCol.min,
max: astCol.max,
references: astCol.references,
onDelete: astCol.onDelete,
onUpdate: astCol.onUpdate,
};
}
/**
* metona-sqlark Memory Engine 基于 Map 的内存存储引擎
* @module engine/memory
@@ -297,6 +417,12 @@ class MemoryEngine {
* 此前走 executor 通用路径行为相同统一到引擎层保证 Hybrid/IndexedDB 委托一致性
*/
async alterTable(tableName, action, column) {
// 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') {
@@ -385,32 +511,116 @@ class MemoryEngine {
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 = [];
const batchUnique = 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 中索引尚未更新批内互查避免"两行同时改到
* 同一新值"绕过唯一约束
*/
checkUpdateUniqueness(schema, tableName, pk, updated, batchUnique) {
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();
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 在任何修改前调用整体拒绝语义
*/
checkUpdateRestrict(tableName, oldPk) {
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 置空
@@ -522,6 +732,10 @@ class MemoryEngine {
if (colDef.onDelete === 'RESTRICT' && refPks.length > 0) {
throw new DatabaseError(`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" has dependent rows`, '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);
@@ -552,6 +766,11 @@ class MemoryEngine {
}
// ---- 动态索引(v0.3.0 ----
async createIndex(tableName, column, unique) {
// 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];
@@ -577,6 +796,10 @@ class MemoryEngine {
}
}
async dropIndex(tableName, column, _indexName) {
// 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];
@@ -2025,6 +2248,12 @@ class KVStoreEngine {
}
async alterTable(tableName, action, column) {
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);
@@ -2086,10 +2315,12 @@ class KVStoreEngine {
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: 行级变更记录 —— 主键变更无法行级追踪(旧键删除+新键落盘+级联),
@@ -2214,6 +2445,9 @@ class KVStoreEngine {
// ---- 动态索引 ----
async createIndex(tableName, column, unique) {
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);
@@ -2224,6 +2458,9 @@ class KVStoreEngine {
}
async dropIndex(tableName, column, indexName) {
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);
@@ -2432,110 +2669,6 @@ class KVStoreEngine {
}
}
/**
* metona-sqlark Schema 表结构定义与校验
* @module table/schema
*/
// ---------------------------------------------------------------------------
// Schema 工具
// ---------------------------------------------------------------------------
/** 从列定义创建 TableSchema */
function createSchema(name, columns) {
validateColumns(columns);
return { name, columns };
}
/** 校验列定义 */
function validateColumns(columns) {
const colNames = Object.keys(columns);
if (colNames.length === 0) {
throw new DatabaseError('Table must have at least one column', 'SCHEMA_ERROR');
}
let primaryKeyCount = 0;
for (const [colName, colDef] of Object.entries(columns)) {
// v0.7.1: '__proto__' 作为列名会触发对象原型 setter(列静默丢失);
// 显式拒绝避免原型污染类攻击面
if (colName === '__proto__') {
throw new DatabaseError('Column name "__proto__" is not allowed', 'SCHEMA_ERROR');
}
// 类型校验
if (!FIELD_TYPES.includes(colDef.type)) {
throw new DatabaseError(`Invalid type "${colDef.type}" for column "${colName}". Valid types: ${FIELD_TYPES.join(', ')}`, 'SCHEMA_ERROR');
}
// 主键计数
if (colDef.primaryKey) {
primaryKeyCount++;
}
}
// 至少需要一个主键
if (primaryKeyCount === 0) {
throw new DatabaseError('Table must have at least one primary key column', 'SCHEMA_ERROR');
}
// v0.7.0: 复合主键(多列 primaryKey)当前不支持 —— 所有引擎的存储布局与
// 外键引用均为单主键假设(此前静默取第一个主键,其余标记被忽略 → 语义陷阱)。
// 显式拒绝避免用户误用;复合主键列入 v0.8 路线图。
if (primaryKeyCount > 1) {
throw new DatabaseError(`Composite primary keys are not supported yet: table has ${primaryKeyCount} primary key columns. ` +
'Use a single primary key column (or a unique column combination) instead.', 'SCHEMA_ERROR');
}
}
/** 检查字段类型(含约束校验) */
function checkFieldType(tableName, colName, type, value, colDef) {
const jsType = typeof value;
switch (type) {
case 'string':
if (jsType !== 'string') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects string, got ${jsType}`, 'TYPE_ERROR');
}
if (colDef?.maxLength !== undefined && value.length > colDef.maxLength) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" exceeds max length ${colDef.maxLength}`, 'VALIDATION_ERROR');
}
break;
case 'number':
if (jsType !== 'number') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects number, got ${jsType}`, 'TYPE_ERROR');
}
if (colDef?.min !== undefined && value < colDef.min) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" value ${value} below minimum ${colDef.min}`, 'VALIDATION_ERROR');
}
if (colDef?.max !== undefined && value > colDef.max) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" value ${value} above maximum ${colDef.max}`, 'VALIDATION_ERROR');
}
break;
case 'boolean':
if (jsType !== 'boolean') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects boolean, got ${jsType}`, 'TYPE_ERROR');
}
break;
case 'date':
if (jsType !== 'string' || isNaN(Date.parse(value))) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects valid date string, got ${typeof value}`, 'TYPE_ERROR');
}
break;
case 'json':
if (jsType !== 'object') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects object/array, got ${jsType}`, 'TYPE_ERROR');
}
break;
}
}
/** 将 AST 列定义转换为 ColumnDef */
function astColumnToColumnDef(astCol) {
return {
type: astCol.type,
primaryKey: astCol.primaryKey,
unique: astCol.unique,
required: astCol.required,
default: astCol.default,
index: astCol.index,
maxLength: astCol.maxLength,
min: astCol.min,
max: astCol.max,
references: astCol.references,
onDelete: astCol.onDelete,
onUpdate: astCol.onUpdate,
};
}
/**
* AriaEngine Types 内部类型定义
* @module engine/aria/types
@@ -6850,16 +6983,18 @@ class AriaEngine {
const walRecords = [];
// v0.4.2-fix: ON UPDATE 级联环路保护
const visited = new Set();
// 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 = [];
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)
@@ -6870,66 +7005,85 @@ class AriaEngine {
}
await idxLsm.prefetchPrefixRanges(ranges);
}
// v0.7.2: 语句级原子性 — 两阶段(先全量预检,后执行)。
// 此前逐行"校验+写入":第 N 行唯一冲突/校验失败抛错时,前 N-1 行已写入
// 且其 WAL 记录随 appendBatch 一起丢失 → 内存已改、WAL 无记录、调用方已收到错误
// (无事务下语句级部分提交 + 崩溃后进一步不一致)。
const planned = [];
const batchUnique = 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)) {
const updated = { ...row, ...updates };
this.validateRow(schema, updated);
// 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.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY
// (此前静默覆盖另一行丢数据;与 MemoryEngine 对齐)
if (query.where && Object.keys(query.where).length > 0 && !matchWhere(row, query.where))
continue;
const updated = { ...row, ...cleanUpdates };
this.validateRow(schema, updated);
// 批内唯一互查(索引尚未更新,两行同时改到同一新值需要互查兜底)
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.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.__txn_deleted) {
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
}
}
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) {
const newKey = `${tableName}:${newPk}`;
const existing = this.currentTxnId
? (this.txnSnapshot?.get(newKey) ?? this.lsm.get(newKey))
: this.lsm.get(newKey);
if (existing && !existing.__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);
}
if (this.currentTxnId && this.txnSnapshot) {
if (pkChanged) {
this.txnSnapshot.set(key, { __txn_deleted: true });
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++;
if (pkChanged) {
walRecords.push({
type: WALRecordType.DELETE,
txnId: this.currentTxnId ?? 0,
tableName,
key: String(row[pkCol]),
});
this.txnSnapshot.set(key, { __txn_deleted: true });
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);
this.opCounter += count;
@@ -6937,6 +7091,54 @@ class AriaEngine {
this.trimAllCaches();
return count;
}
/**
* v0.7.2: 批内唯一互查 两条行在同一语句中更新到同一唯一值时的兜底检查
* 阶段 1 中索引尚未反映本语句的变更
*/
checkBatchUnique(tableName, uniqueCols, updated, batchUnique) {
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();
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 列同样整体拒绝
*/
async checkForeignKeyUpdateRestrict(tableName, oldPk, _newPk) {
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 记录
@@ -7079,6 +7281,10 @@ class AriaEngine {
if (colDef.onDelete === 'RESTRICT' && matched.length > 0) {
throw new DatabaseError(`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" has dependent rows`, '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) {
@@ -8251,11 +8457,21 @@ class HybridEngine {
// ---- 表管理 ----
async createTable(schema) {
await this.memoryEngine.createTable(schema);
await this.diskEngine.createTable(schema);
try {
await this.diskEngine.createTable(schema);
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
async dropTable(tableName) {
await this.memoryEngine.dropTable(tableName);
await this.diskEngine.dropTable(tableName);
try {
await this.diskEngine.dropTable(tableName);
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
async hasTable(tableName) {
return this.memoryEngine.hasTable(tableName);
@@ -8269,21 +8485,49 @@ class HybridEngine {
/** v0.4.2-fix: 引擎级 ALTER TABLE — 双引擎同步(磁盘持久化 + 内存引用) */
async alterTable(tableName, action, column) {
await this.memoryEngine.alterTable(tableName, action, column);
if (typeof this.diskEngine.alterTable === 'function') {
await this.diskEngine.alterTable(tableName, action, column);
try {
if (typeof this.diskEngine.alterTable === 'function') {
await this.diskEngine.alterTable(tableName, action, column);
}
else {
// 磁盘引擎无引擎级实现 → 从磁盘重建内存 schema(disk 引擎 schema 以自身为准)
const schema = await this.diskEngine.getTableSchema(tableName);
if (schema && action === 'DROP')
delete schema.columns[column.name];
}
}
else {
// 磁盘引擎无引擎级实现 → 从磁盘重建内存 schema(disk 引擎 schema 以自身为准)
const schema = await this.diskEngine.getTableSchema(tableName);
if (schema && action === 'DROP')
delete schema.columns[column.name];
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
// ---- CRUDwrite-through 策略) ----
/**
* v0.7.2: 磁盘写失败补偿 内存已先行写入磁盘失败 内存与磁盘不一致
* 重启后数据丢失且调用方已收到错误从磁盘重载内存对齐真实状态
* 内存=磁盘再重新抛出原始错误事务路径由双引擎快照回滚保证
* 无需此补偿
*/
async recoverMemoryAfterDiskError(error) {
try {
await this.reloadMemoryFromDisk();
}
catch {
// 磁盘本身不可用(错误根源)时重载可能失败:错误已抛给调用方,
// 内存保持失败前状态,repair()/重试可恢复
// eslint-disable-next-line no-console
console.warn('[metona-sqlark] Hybrid: failed to reload memory after disk write error');
}
throw error;
}
async insert(tableName, rows) {
const pks = await this.memoryEngine.insert(tableName, rows);
// write-through: 同步写入磁盘
await this.diskEngine.insert(tableName, rows);
try {
await this.diskEngine.insert(tableName, rows);
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
return pks;
}
async find(tableName, query) {
@@ -8297,13 +8541,23 @@ class HybridEngine {
async update(tableName, query, updates) {
const count = await this.memoryEngine.update(tableName, query, updates);
// write-through: 同步更新磁盘
await this.diskEngine.update(tableName, query, updates);
try {
await this.diskEngine.update(tableName, query, updates);
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
return count;
}
async delete(tableName, query) {
const count = await this.memoryEngine.delete(tableName, query);
// write-through: 同步删除磁盘
await this.diskEngine.delete(tableName, query);
try {
await this.diskEngine.delete(tableName, query);
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
return count;
}
async count(tableName, query) {
@@ -8311,19 +8565,34 @@ class HybridEngine {
}
async clear(tableName) {
await this.memoryEngine.clear(tableName);
await this.diskEngine.clear(tableName);
try {
await this.diskEngine.clear(tableName);
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
// ---- 动态索引(v0.3.0 ----
async createIndex(tableName, column, unique) {
await this.memoryEngine.createIndex(tableName, column, unique);
if (typeof this.diskEngine.createIndex === 'function') {
await this.diskEngine.createIndex(tableName, column, unique);
try {
if (typeof this.diskEngine.createIndex === 'function') {
await this.diskEngine.createIndex(tableName, column, unique);
}
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
async dropIndex(tableName, column, indexName) {
await this.memoryEngine.dropIndex(tableName, column, indexName);
if (typeof this.diskEngine.dropIndex === 'function') {
await this.diskEngine.dropIndex(tableName, column, indexName);
try {
if (typeof this.diskEngine.dropIndex === 'function') {
await this.diskEngine.dropIndex(tableName, column, indexName);
}
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
// ---- 事务 ----
@@ -9053,6 +9322,11 @@ class Lexer {
value += this.ch;
this.readChar();
}
// v0.7.2: 未闭合字符串字面量显式报错(此前静默返回残缺 STRING token
// 上层可解析出错误结果,如 `SELECT 'abc` 被当作合法常量列)
if (this.ch === '') {
throw new DatabaseError(`Unterminated string literal at position ${start}`, 'PARSE_ERROR');
}
return {
type: TokenType.STRING,
value,
@@ -11619,6 +11893,10 @@ class QueryExecutor {
* 绑定在词法层面完成仅替换字符串字面量之外的 `?`
* 值按 SQL 字面量编码字符串 `''` 转义数字/布尔/JSON 直出
* 从根上规避 SQL 注入不经过字符串拼接由用户自行转义
*
* v0.7.2: 词法扫描感知注释 行注释`--`与块注释slash-star 包裹中的 `?`
* 与引号不再参与占位符识别与字符串状态机此前注释中的 `?` 计入占位符导致
* PARAM_ERROR 错位注释中的单引号触发 "Unterminated string literal"
*/
/** 将单个参数值编码为 SQL 字面量 */
function encodeParam(value) {
@@ -11637,7 +11915,7 @@ function encodeParam(value) {
throw new DatabaseError('Object/array query parameters are not supported by SQL binding (pass JSON strings explicitly)', 'PARAM_ERROR');
}
/**
* SQL 中的位置参数 `?`字符串字面量之外替换为编码后的字面量
* SQL 中的位置参数 `?`字符串字面量与注释之外替换为编码后的字面量
* @param sql `?` 占位符的 SQL
* @param params 位置参数数组
* @throws PARAM_ERROR 参数数量不匹配
@@ -11672,6 +11950,28 @@ function bindParameters(sql, params) {
i++;
continue;
}
// v0.7.2: 行注释 `-- ...`(含其中的 ? 与引号)原样保留、不参与绑定
if (ch === '-' && sql[i + 1] === '-') {
while (i < sql.length && sql[i] !== '\n' && sql[i] !== '\r') {
out += sql[i];
i++;
}
continue;
}
// v0.7.2: 块注释(slash-star 包裹)同样跳过
if (ch === '/' && sql[i + 1] === '*') {
out += sql[i] + sql[i + 1];
i += 2;
while (i < sql.length && !(sql[i] === '*' && sql[i + 1] === '/')) {
out += sql[i];
i++;
}
if (i < sql.length) {
out += sql[i] + sql[i + 1];
i += 2;
}
continue;
}
if (ch === '?') {
if (pIdx >= params.length) {
throw new DatabaseError(`Too few query parameters: placeholder #${pIdx + 1} has no value (got ${params.length} total)`, 'PARAM_ERROR');