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:
Vendored
+501
-201
@@ -34,7 +34,7 @@ class DatabaseError extends Error {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 版本
|
||||
// ---------------------------------------------------------------------------
|
||||
const VERSION = '0.7.1';
|
||||
const VERSION = '0.7.2';
|
||||
|
||||
/**
|
||||
* metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑
|
||||
@@ -160,7 +160,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');
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -219,6 +222,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
|
||||
@@ -301,6 +421,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') {
|
||||
@@ -389,32 +515,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 绕过 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 置空。
|
||||
@@ -526,6 +736,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);
|
||||
@@ -556,6 +770,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];
|
||||
@@ -581,6 +800,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];
|
||||
@@ -2029,6 +2252,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);
|
||||
@@ -2090,10 +2319,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: 行级变更记录 —— 主键变更无法行级追踪(旧键删除+新键落盘+级联),
|
||||
@@ -2218,6 +2449,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);
|
||||
@@ -2228,6 +2462,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);
|
||||
@@ -2436,110 +2673,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
|
||||
@@ -6854,16 +6987,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)
|
||||
@@ -6874,66 +7009,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;
|
||||
@@ -6941,6 +7095,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 记录)。
|
||||
@@ -7083,6 +7285,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) {
|
||||
@@ -8255,11 +8461,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);
|
||||
@@ -8273,21 +8489,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);
|
||||
}
|
||||
}
|
||||
// ---- CRUD(write-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) {
|
||||
@@ -8301,13 +8545,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) {
|
||||
@@ -8315,19 +8569,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);
|
||||
}
|
||||
}
|
||||
// ---- 事务 ----
|
||||
@@ -9057,6 +9326,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,
|
||||
@@ -11623,6 +11897,10 @@ class QueryExecutor {
|
||||
* 绑定在词法层面完成:仅替换字符串字面量之外的 `?`,
|
||||
* 值按 SQL 字面量编码(字符串 `''` 转义、数字/布尔/JSON 直出),
|
||||
* 从根上规避 SQL 注入(不经过字符串拼接由用户自行转义)。
|
||||
*
|
||||
* v0.7.2: 词法扫描感知注释 —— 行注释(`--`)与块注释(slash-star 包裹)中的 `?`
|
||||
* 与引号不再参与占位符识别与字符串状态机(此前注释中的 `?` 计入占位符导致
|
||||
* PARAM_ERROR 错位、注释中的单引号触发 "Unterminated string literal")。
|
||||
*/
|
||||
/** 将单个参数值编码为 SQL 字面量 */
|
||||
function encodeParam(value) {
|
||||
@@ -11641,7 +11919,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 参数数量不匹配
|
||||
@@ -11676,6 +11954,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');
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+29
-1
@@ -164,7 +164,7 @@ interface MetonaPlugin {
|
||||
/** 销毁 */
|
||||
destroy(): void;
|
||||
}
|
||||
declare const VERSION = "0.7.1";
|
||||
declare const VERSION = "0.7.2";
|
||||
|
||||
/**
|
||||
* metona-sqlark Plugin — 插件系统
|
||||
@@ -807,6 +807,17 @@ declare class MemoryEngine implements IStorageEngine {
|
||||
/** v0.4.0: 流式查询 — 逐行回调(单次迭代,不物化结果数组) */
|
||||
findStream(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number>;
|
||||
update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number>;
|
||||
/**
|
||||
* v0.7.2: 更新唯一性预检 — 批内互查(多条行更新到同一唯一值)+ 索引查
|
||||
* (排除自身旧条目)。阶段 1 中索引尚未更新,批内互查避免"两行同时改到
|
||||
* 同一新值"绕过唯一约束。
|
||||
*/
|
||||
private checkUpdateUniqueness;
|
||||
/**
|
||||
* v0.7.2: ON UPDATE RESTRICT 预检 — 从 applyUpdateCascade 提取,
|
||||
* 两阶段 update 在任何修改前调用(整体拒绝语义)。
|
||||
*/
|
||||
private checkUpdateRestrict;
|
||||
/**
|
||||
* v0.4.2-fix: ON UPDATE 外键级联 — 被引用表主键变更时处理引用表:
|
||||
* RESTRICT 抛错 / CASCADE 更新 FK 值 / SET NULL 置空。
|
||||
@@ -1026,6 +1037,16 @@ declare class AriaEngine implements IStorageEngine {
|
||||
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
|
||||
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
||||
update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number>;
|
||||
/**
|
||||
* v0.7.2: 批内唯一互查 — 两条行在同一语句中更新到同一唯一值时的兜底检查
|
||||
* (阶段 1 中索引尚未反映本语句的变更)。
|
||||
*/
|
||||
private checkBatchUnique;
|
||||
/**
|
||||
* v0.7.2: ON UPDATE 外键预检 — 从 applyForeignKeyUpdateRules 提取(两阶段 update 用):
|
||||
* RESTRICT 存在依赖行抛错;SET NULL 撞 required 列同样整体拒绝。
|
||||
*/
|
||||
private checkForeignKeyUpdateRestrict;
|
||||
/**
|
||||
* v0.4.2-fix: ON UPDATE 外键级联 — 主键 oldPk → newPk 时处理引用表。
|
||||
* RESTRICT 抛错 / CASCADE 更新 FK / SET NULL 置空(含索引与 WAL 记录)。
|
||||
@@ -1181,6 +1202,13 @@ declare class HybridEngine implements IStorageEngine {
|
||||
alterTable(tableName: string, action: 'ADD' | 'DROP', column: ColumnDef & {
|
||||
name: string;
|
||||
}): Promise<void>;
|
||||
/**
|
||||
* v0.7.2: 磁盘写失败补偿 — 内存已先行写入、磁盘失败 → 内存与磁盘不一致
|
||||
* (重启后数据丢失且调用方已收到错误)。从磁盘重载内存对齐真实状态
|
||||
* (内存=磁盘),再重新抛出原始错误。事务路径由双引擎快照回滚保证,
|
||||
* 无需此补偿。
|
||||
*/
|
||||
private recoverMemoryAfterDiskError;
|
||||
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
|
||||
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
||||
/** v0.4.0: 流式查询(内存引擎逐行回调) */
|
||||
|
||||
Vendored
+501
-201
@@ -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 绕过 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 置空。
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
// ---- CRUD(write-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');
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+501
-201
@@ -36,7 +36,7 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// 版本
|
||||
// ---------------------------------------------------------------------------
|
||||
const VERSION = '0.7.1';
|
||||
const VERSION = '0.7.2';
|
||||
|
||||
/**
|
||||
* metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑
|
||||
@@ -162,7 +162,10 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -221,6 +224,123 @@
|
||||
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
|
||||
@@ -303,6 +423,12 @@
|
||||
* (此前走 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') {
|
||||
@@ -391,32 +517,116 @@
|
||||
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 绕过 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 置空。
|
||||
@@ -528,6 +738,10 @@
|
||||
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);
|
||||
@@ -558,6 +772,11 @@
|
||||
}
|
||||
// ---- 动态索引(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];
|
||||
@@ -583,6 +802,10 @@
|
||||
}
|
||||
}
|
||||
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];
|
||||
@@ -2031,6 +2254,12 @@
|
||||
}
|
||||
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);
|
||||
@@ -2092,10 +2321,12 @@
|
||||
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: 行级变更记录 —— 主键变更无法行级追踪(旧键删除+新键落盘+级联),
|
||||
@@ -2220,6 +2451,9 @@
|
||||
// ---- 动态索引 ----
|
||||
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);
|
||||
@@ -2230,6 +2464,9 @@
|
||||
}
|
||||
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);
|
||||
@@ -2438,110 +2675,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -6856,16 +6989,18 @@
|
||||
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)
|
||||
@@ -6876,66 +7011,85 @@
|
||||
}
|
||||
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;
|
||||
@@ -6943,6 +7097,54 @@
|
||||
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 记录)。
|
||||
@@ -7085,6 +7287,10 @@
|
||||
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) {
|
||||
@@ -8257,11 +8463,21 @@
|
||||
// ---- 表管理 ----
|
||||
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);
|
||||
@@ -8275,21 +8491,49 @@
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
// ---- CRUD(write-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) {
|
||||
@@ -8303,13 +8547,23 @@
|
||||
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) {
|
||||
@@ -8317,19 +8571,34 @@
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
// ---- 事务 ----
|
||||
@@ -9059,6 +9328,11 @@
|
||||
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,
|
||||
@@ -11625,6 +11899,10 @@
|
||||
* 绑定在词法层面完成:仅替换字符串字面量之外的 `?`,
|
||||
* 值按 SQL 字面量编码(字符串 `''` 转义、数字/布尔/JSON 直出),
|
||||
* 从根上规避 SQL 注入(不经过字符串拼接由用户自行转义)。
|
||||
*
|
||||
* v0.7.2: 词法扫描感知注释 —— 行注释(`--`)与块注释(slash-star 包裹)中的 `?`
|
||||
* 与引号不再参与占位符识别与字符串状态机(此前注释中的 `?` 计入占位符导致
|
||||
* PARAM_ERROR 错位、注释中的单引号触发 "Unterminated string literal")。
|
||||
*/
|
||||
/** 将单个参数值编码为 SQL 字面量 */
|
||||
function encodeParam(value) {
|
||||
@@ -11643,7 +11921,7 @@
|
||||
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 参数数量不匹配
|
||||
@@ -11678,6 +11956,28 @@
|
||||
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');
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user