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:
+10
-2
@@ -33,8 +33,16 @@ jobs:
|
|||||||
run: npm run lint
|
run: npm run lint
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
- name: Run tests
|
# v0.7.2: Run tests 拆两步 —— 常规套件并行(快),重型套件串行(runInBand)。
|
||||||
run: npx jest --forceExit --maxWorkers=2 --no-cache
|
# 此前重型测试(10 万行 kv/opfs、生产矩阵)与常规套件在慢 runner 上并行
|
||||||
|
# 争抢 CPU 与 4GB 堆 → 单测超时(120~180s)与 OOM 类假失败。
|
||||||
|
- name: Run tests (regular suites)
|
||||||
|
run: npx jest --forceExit --maxWorkers=2 --no-cache --testPathIgnorePatterns='/node_modules/|/tests/e2e/|/tests/helpers/|aria-prod-load|kvstore-stress|aria-matrix-audit|aria-idx-flush-race'
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
|
|
||||||
|
- name: Run tests (heavy suites, serial)
|
||||||
|
run: npx jest --forceExit --runInBand --no-cache tests/engine/aria-prod-load.test.ts tests/engine/kvstore-stress.test.ts tests/engine/aria-matrix-audit.test.ts tests/engine/aria-idx-flush-race.test.ts
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --max-old-space-size=4096
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,54 @@
|
|||||||
|
|
||||||
All notable changes to MetonaSqlark will be documented in this file.
|
All notable changes to MetonaSqlark will be documented in this file.
|
||||||
|
|
||||||
|
## [0.7.2] - 2026-08-13
|
||||||
|
|
||||||
|
### 语句级原子性 / 事务 DDL 语义统一 / 约束与绑定硬化
|
||||||
|
|
||||||
|
> 深度审计第五阶段:修复 UPDATE 语句级部分提交、事务内 DDL 回滚残留、
|
||||||
|
> SET NULL 级联绕过 required 约束、参数绑定注释误判等 6 项问题,
|
||||||
|
> Hybrid 写穿透失败补偿,四引擎语义对齐。
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **UPDATE 语句级部分提交(P1,Memory/Aria/KVStore/Hybrid)** — 单条 UPDATE 匹配
|
||||||
|
多行时,第 N 行唯一约束/校验失败抛错,前 N-1 行已写入(aria 场景其 WAL 记录
|
||||||
|
随 appendBatch 一起丢失,内存与 WAL 进一步不一致)。改为两阶段:先全量预检
|
||||||
|
(validateRow + 批内唯一互查 + 索引唯一查 + 主键冲突 + 外键 RESTRICT/SET NULL
|
||||||
|
预检),任何一行失败整句不执行,后统一执行
|
||||||
|
- **批内唯一互查缺失** — 两行在同一语句中更新到同一新唯一值:索引尚未反映本语句
|
||||||
|
变更,逐行检查相互看不见 → 绕过唯一约束。两阶段预检增加批内 Set 互查
|
||||||
|
- **事务内 DDL 回滚残留(P1)** — `BEGIN; ALTER ADD COLUMN; ROLLBACK` 后新列残留
|
||||||
|
(Memory/KVStore 事务快照对 schema 是浅拷贝,alterTable 直改共享 columns 对象)。
|
||||||
|
Memory/KVStore 的 ALTER TABLE / CREATE INDEX / DROP INDEX 在事务内显式拒绝
|
||||||
|
(`NOT_SUPPORTED`,与 AriaEngine 对齐);createTable/dropTable 保持可回滚
|
||||||
|
- **SET NULL 级联绕过 required 约束(P1)** — 外键列 `required: true` 时 delete 级联
|
||||||
|
与 update 主键变更级联静默写入 null(不经过 validateRow)。预检阶段整体拒绝
|
||||||
|
(`FOREIGN_KEY_VIOLATION`),Memory/Aria 双引擎对齐
|
||||||
|
- **bindParameters 注释误判(P2)** — `-- comment ?` 中的 `?` 计入占位符(参数错位
|
||||||
|
PARAM_ERROR);注释中的单引号触发 "Unterminated string literal"。词法扫描感知
|
||||||
|
行注释与块注释(其中 `?`/引号不参与绑定与字符串状态机)
|
||||||
|
- **未闭合字符串静默接受** — `SELECT 'abc` 此前解析成功(错误结果),lexer 现抛
|
||||||
|
`PARSE_ERROR`
|
||||||
|
- **未知 where 操作符静默全匹配(P3)** — `$betwen` 等未实现/拼错操作符此前
|
||||||
|
`default: return true`(所有行匹配、过滤形同虚设)。现抛 `QUERY_ERROR`
|
||||||
|
- **UPDATE undefined 覆盖列值** — `update({ col: undefined })` 此前把 undefined 写入
|
||||||
|
行(列键丢失)。undefined 现语义化为"不更新该列"(保留旧值),null 仍显式置空
|
||||||
|
(四引擎统一;KVStore 主键 undefined 不再误触主键变更路径)
|
||||||
|
- **Hybrid write-through 非原子(P1)** — 磁盘写失败时内存已写入(重启丢数据且错误
|
||||||
|
已抛)。insert/update/delete/createTable/dropTable/alterTable/clear/createIndex/
|
||||||
|
dropIndex 磁盘失败后自动从磁盘重载内存(内存=磁盘对齐),再抛原始错误;
|
||||||
|
事务路径由双引擎快照回滚保证不受影响
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 测试 1155 → **1198**(74 套件,+43 个 v0.7.2 回归);新增 `tests/v072-fixes.test.ts`
|
||||||
|
(语句原子性 ×6 / 批内互查 ×3 / 事务 DDL ×6 / SET NULL 约束 ×5 / 注释感知 ×6 /
|
||||||
|
未闭合字符串 ×2 / 未知操作符 ×2 / undefined 语义 ×9 / Hybrid 补偿 ×4)
|
||||||
|
- `CONTRIBUTING.md` 项目结构同步(移除已删除的 indexeddb/opfs 引擎与 utils.ts)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.7.1] - 2026-08-13
|
## [0.7.1] - 2026-08-13
|
||||||
|
|
||||||
### API 修复 / 防御统一 / 工程质量
|
### API 修复 / 防御统一 / 工程质量
|
||||||
|
|||||||
+8
-6
@@ -42,22 +42,23 @@ src/
|
|||||||
├── index.ts # Entry point, global API (MetonaSqlark + MeSqlark)
|
├── index.ts # Entry point, global API (MetonaSqlark + MeSqlark)
|
||||||
├── core.ts # MetonaSqlark main class
|
├── core.ts # MetonaSqlark main class
|
||||||
├── constants.ts # Types, defaults, enums, errors
|
├── constants.ts # Types, defaults, enums, errors
|
||||||
├── utils.ts # Utility functions
|
|
||||||
├── connection-manager.ts # Connection pool (connect/disconnect)
|
├── connection-manager.ts # Connection pool (connect/disconnect)
|
||||||
├── engine/ # Storage engines
|
├── engine/ # Storage engines
|
||||||
│ ├── interface.ts # IStorageEngine interface
|
│ ├── interface.ts # IStorageEngine interface
|
||||||
│ ├── memory.ts # MemoryEngine (Map-based)
|
│ ├── memory.ts # MemoryEngine (Map-based)
|
||||||
│ ├── indexeddb.ts # IndexedDBEngine (browser persistence)
|
│ ├── kvstore_engine.ts # KVStoreEngine (disk mode, self-built KV store)
|
||||||
│ ├── opfs.ts # OPFSEngine (Origin Private File System)
|
│ ├── kvstore/ # KVStore (log + snapshot + atomic multi-key write)
|
||||||
│ └── aria/ # AriaEngine (LSM-Tree page storage engine)
|
│ └── aria/ # AriaEngine (LSM-Tree page storage engine)
|
||||||
│ ├── index/ # LSM / MemTable / SSTable / Bloom / MergeIterator
|
│ ├── index/ # LSM / MemTable / SSTable / Bloom / MergeIterator
|
||||||
│ ├── page/ # 4KB slotted page format
|
│ ├── page/ # 4KB slotted page format
|
||||||
│ ├── buffer/ # Buffer Pool (LRU eviction)
|
│ ├── buffer/ # Buffer Pool (LRU eviction)
|
||||||
│ ├── wal/ # Write-Ahead Log + Checkpoint
|
│ ├── wal/ # Write-Ahead Log + Checkpoint
|
||||||
│ ├── transaction/ # MVCC manager
|
│ ├── transaction/ # MVCC manager
|
||||||
│ ├── store/ # Backends (IndexedDB / OPFS / Memory)
|
│ ├── store/ # Backends (OPFS / KVStore / Memory / Encrypted)
|
||||||
|
│ ├── locks.ts # Web Locks multi-tab exclusive lock
|
||||||
│ └── compression/ # LZ4
|
│ └── compression/ # LZ4
|
||||||
├── hybrid/ # HybridEngine (write-through)
|
├── hybrid/ # HybridEngine (write-through)
|
||||||
|
├── migration/ # Legacy IndexedDB migration tool (one-shot)
|
||||||
├── table/ # Table management & Schema validation
|
├── table/ # Table management & Schema validation
|
||||||
├── query/ # Query system
|
├── query/ # Query system
|
||||||
│ ├── ast.ts # SQL AST type definitions
|
│ ├── ast.ts # SQL AST type definitions
|
||||||
@@ -68,12 +69,13 @@ src/
|
|||||||
├── sql/ # SQL parser
|
├── sql/ # SQL parser
|
||||||
│ ├── tokens.ts # Token types & keywords
|
│ ├── tokens.ts # Token types & keywords
|
||||||
│ ├── lexer.ts # Tokenizer
|
│ ├── lexer.ts # Tokenizer
|
||||||
│ └── parser.ts # Recursive descent parser
|
│ ├── parser.ts # Recursive descent parser
|
||||||
|
│ └── params.ts # Parameter binding (? placeholders)
|
||||||
├── transaction/ # Transaction manager
|
├── transaction/ # Transaction manager
|
||||||
├── plugin/ # Plugin system (14 lifecycle hooks)
|
├── plugin/ # Plugin system (14 lifecycle hooks)
|
||||||
└── integrations/ # React & Vue hooks
|
└── integrations/ # React & Vue hooks
|
||||||
|
|
||||||
tests/ # Test suite (1022 test cases, 62 suites + 7 e2e)
|
tests/ # Test suite (1198 test cases, 74 suites + 12 e2e)
|
||||||
tests/helpers/ # 共享测试工具(OPFS mock 等)
|
tests/helpers/ # 共享测试工具(OPFS mock 等)
|
||||||
tests/e2e/ # Playwright e2e(真实 Chromium + OPFS)
|
tests/e2e/ # Playwright e2e(真实 Chromium + OPFS)
|
||||||
site/ # Documentation site (index / docs / demo)
|
site/ # Documentation site (index / docs / demo)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# MetonaSqlark
|
# MetonaSqlark
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/version-0.7.1-blue?style=flat-square" alt="version">
|
<img src="https://img.shields.io/badge/version-0.7.2-blue?style=flat-square" alt="version">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license">
|
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license">
|
||||||
<img src="https://img.shields.io/badge/coverage-89.8%25-brightgreen?style=flat-square" alt="coverage">
|
<img src="https://img.shields.io/badge/coverage-89.8%25-brightgreen?style=flat-square" alt="coverage">
|
||||||
<img src="https://img.shields.io/badge/tests-1155%20passed-success?style=flat-square" alt="tests">
|
<img src="https://img.shields.io/badge/tests-1198%20passed-success?style=flat-square" alt="tests">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> 基于 TypeScript 的**前端关系型数据库**:完整 SQL + Query Builder 双 API,
|
> 基于 TypeScript 的**前端关系型数据库**:完整 SQL + Query Builder 双 API,
|
||||||
@@ -401,7 +401,7 @@ const { data, loading, error, refresh } = useSqlarkQuery(db, 'SELECT * FROM user
|
|||||||
npm install # 安装依赖
|
npm install # 安装依赖
|
||||||
npm run dev # 开发模式(localhost:3001)
|
npm run dev # 开发模式(localhost:3001)
|
||||||
npm run build # 生产构建(生成 dist/)
|
npm run build # 生产构建(生成 dist/)
|
||||||
npm test # 运行测试(1155 用例 · 73 套件)
|
npm test # 运行测试(1198 用例 · 74 套件)
|
||||||
npm run test:e2e # Playwright e2e(真实 Chromium + OPFS + 崩溃注入,需先 build)
|
npm run test:e2e # Playwright e2e(真实 Chromium + OPFS + 崩溃注入,需先 build)
|
||||||
npm run lint # 代码检查
|
npm run lint # 代码检查
|
||||||
npm run typecheck # 类型检查
|
npm run typecheck # 类型检查
|
||||||
@@ -413,8 +413,8 @@ npm run typecheck # 类型检查
|
|||||||
|
|
||||||
| 指标 | 数值 |
|
| 指标 | 数值 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 测试用例 | 1155(+12 Playwright e2e) |
|
| 测试用例 | 1198(+12 Playwright e2e) |
|
||||||
| 测试套件 | 70 |
|
| 测试套件 | 74 |
|
||||||
| 行覆盖率 | 89.8% |
|
| 行覆盖率 | 89.8% |
|
||||||
| SQL 关键字 | 72 |
|
| SQL 关键字 | 72 |
|
||||||
| 存储引擎 | 5(Memory / KVStore / OPFS / Hybrid / Aria) |
|
| 存储引擎 | 5(Memory / KVStore / OPFS / Hybrid / Aria) |
|
||||||
|
|||||||
Vendored
+427
-127
@@ -34,7 +34,7 @@ class DatabaseError extends Error {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 版本
|
// 版本
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const VERSION = '0.7.1';
|
const VERSION = '0.7.2';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑
|
* metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑
|
||||||
@@ -160,7 +160,10 @@ function matchOperator(value, op, operand) {
|
|||||||
case '$in': return Array.isArray(operand) && operand.includes(value);
|
case '$in': return Array.isArray(operand) && operand.includes(value);
|
||||||
case '$nin': 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));
|
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;
|
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 的内存存储引擎
|
* metona-sqlark Memory Engine — 基于 Map 的内存存储引擎
|
||||||
* @module engine/memory
|
* @module engine/memory
|
||||||
@@ -301,6 +421,12 @@ class MemoryEngine {
|
|||||||
* (此前走 executor 通用路径,行为相同;统一到引擎层保证 Hybrid/IndexedDB 委托一致性)
|
* (此前走 executor 通用路径,行为相同;统一到引擎层保证 Hybrid/IndexedDB 委托一致性)
|
||||||
*/
|
*/
|
||||||
async alterTable(tableName, action, column) {
|
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);
|
this.ensureTable(tableName);
|
||||||
const schema = this.schemas.get(tableName);
|
const schema = this.schemas.get(tableName);
|
||||||
if (action === 'ADD') {
|
if (action === 'ADD') {
|
||||||
@@ -389,20 +515,37 @@ class MemoryEngine {
|
|||||||
const schema = this.schemas.get(tableName);
|
const schema = this.schemas.get(tableName);
|
||||||
const table = this.tables.get(tableName);
|
const table = this.tables.get(tableName);
|
||||||
const pkCol = this.getPrimaryKey(schema);
|
const pkCol = this.getPrimaryKey(schema);
|
||||||
let count = 0;
|
// v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空
|
||||||
// v0.4.2-fix: 迭代期间会 delete/set 同一 Map(主键变更)→ 拷贝快照避免跳过/重复
|
const cleanUpdates = stripUndefinedUpdates(updates);
|
||||||
for (const [pk, row] of [...table]) {
|
// v0.7.2: 语句级原子性 — 两阶段(先全量预检,后执行)。
|
||||||
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
|
// 此前逐行"校验+写入":第 N 行唯一冲突/校验失败抛错时,前 N-1 行已写入
|
||||||
// v0.3.3: 先移除旧值索引条目(修复 update 后唯一约束被绕过、按新值查索引丢行)
|
// → 无事务下语句级部分提交(数据半更新且调用方已收到错误)。
|
||||||
this.removeIndexEntries(tableName, row, pk);
|
const planned = [];
|
||||||
const updated = { ...row, ...updates };
|
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.validateRow(schema, updated);
|
||||||
this.checkUniqueness(schema, updated);
|
this.checkUpdateUniqueness(schema, tableName, pk, updated, batchUnique);
|
||||||
const newPk = String(updated[pkCol]);
|
const newPk = String(updated[pkCol]);
|
||||||
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据)
|
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据)
|
||||||
if (newPk !== pk && table.has(newPk)) {
|
if (newPk !== pk && table.has(newPk)) {
|
||||||
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
|
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: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
|
// v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
|
||||||
if (newPk !== pk) {
|
if (newPk !== pk) {
|
||||||
await this.applyUpdateCascade(tableName, pk, newPk);
|
await this.applyUpdateCascade(tableName, pk, newPk);
|
||||||
@@ -412,9 +555,76 @@ class MemoryEngine {
|
|||||||
this.updateIndexes(tableName, updated, newPk);
|
this.updateIndexes(tableName, updated, newPk);
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return 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 外键级联 — 被引用表主键变更时处理引用表:
|
* v0.4.2-fix: ON UPDATE 外键级联 — 被引用表主键变更时处理引用表:
|
||||||
* RESTRICT 抛错 / CASCADE 更新 FK 值 / SET NULL 置空。
|
* RESTRICT 抛错 / CASCADE 更新 FK 值 / SET NULL 置空。
|
||||||
@@ -526,6 +736,10 @@ class MemoryEngine {
|
|||||||
if (colDef.onDelete === 'RESTRICT' && refPks.length > 0) {
|
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');
|
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') {
|
if (colDef.onDelete === 'CASCADE') {
|
||||||
for (const refPk of refPks) {
|
for (const refPk of refPks) {
|
||||||
this.checkCascadeRestrict(refTableName, refPk, visited);
|
this.checkCascadeRestrict(refTableName, refPk, visited);
|
||||||
@@ -556,6 +770,11 @@ class MemoryEngine {
|
|||||||
}
|
}
|
||||||
// ---- 动态索引(v0.3.0) ----
|
// ---- 动态索引(v0.3.0) ----
|
||||||
async createIndex(tableName, column, unique) {
|
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);
|
this.ensureTable(tableName);
|
||||||
const schema = this.schemas.get(tableName);
|
const schema = this.schemas.get(tableName);
|
||||||
const colDef = schema.columns[column];
|
const colDef = schema.columns[column];
|
||||||
@@ -581,6 +800,10 @@ class MemoryEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async dropIndex(tableName, column, _indexName) {
|
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);
|
this.ensureTable(tableName);
|
||||||
const schema = this.schemas.get(tableName);
|
const schema = this.schemas.get(tableName);
|
||||||
const colDef = schema.columns[column];
|
const colDef = schema.columns[column];
|
||||||
@@ -2029,6 +2252,12 @@ class KVStoreEngine {
|
|||||||
}
|
}
|
||||||
async alterTable(tableName, action, column) {
|
async alterTable(tableName, action, column) {
|
||||||
this.ensureOpen();
|
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);
|
await this.memory.alterTable(tableName, action, column);
|
||||||
if (this.txActive) {
|
if (this.txActive) {
|
||||||
this.txDirtyTables.add(tableName);
|
this.txDirtyTables.add(tableName);
|
||||||
@@ -2090,10 +2319,12 @@ class KVStoreEngine {
|
|||||||
if (!schema)
|
if (!schema)
|
||||||
throw new DatabaseError(`Table "${tableName}" does not exist`, 'TABLE_NOT_FOUND');
|
throw new DatabaseError(`Table "${tableName}" does not exist`, 'TABLE_NOT_FOUND');
|
||||||
const pkCol = this.getPK(schema);
|
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 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) {
|
if (this.txActive) {
|
||||||
this.txDirtyTables.add(tableName);
|
this.txDirtyTables.add(tableName);
|
||||||
// v0.7.0: 行级变更记录 —— 主键变更无法行级追踪(旧键删除+新键落盘+级联),
|
// v0.7.0: 行级变更记录 —— 主键变更无法行级追踪(旧键删除+新键落盘+级联),
|
||||||
@@ -2218,6 +2449,9 @@ class KVStoreEngine {
|
|||||||
// ---- 动态索引 ----
|
// ---- 动态索引 ----
|
||||||
async createIndex(tableName, column, unique) {
|
async createIndex(tableName, column, unique) {
|
||||||
this.ensureOpen();
|
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);
|
await this.memory.createIndex(tableName, column, unique);
|
||||||
if (this.txActive) {
|
if (this.txActive) {
|
||||||
this.txDirtyTables.add(tableName);
|
this.txDirtyTables.add(tableName);
|
||||||
@@ -2228,6 +2462,9 @@ class KVStoreEngine {
|
|||||||
}
|
}
|
||||||
async dropIndex(tableName, column, indexName) {
|
async dropIndex(tableName, column, indexName) {
|
||||||
this.ensureOpen();
|
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);
|
await this.memory.dropIndex(tableName, column, indexName);
|
||||||
if (this.txActive) {
|
if (this.txActive) {
|
||||||
this.txDirtyTables.add(tableName);
|
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 — 内部类型定义
|
* AriaEngine Types — 内部类型定义
|
||||||
* @module engine/aria/types
|
* @module engine/aria/types
|
||||||
@@ -6854,16 +6987,18 @@ class AriaEngine {
|
|||||||
const walRecords = [];
|
const walRecords = [];
|
||||||
// v0.4.2-fix: ON UPDATE 级联环路保护
|
// v0.4.2-fix: ON UPDATE 级联环路保护
|
||||||
const visited = new Set();
|
const visited = new Set();
|
||||||
|
// v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空
|
||||||
|
const cleanUpdates = stripUndefinedUpdates(updates);
|
||||||
// v0.6.2: 唯一约束 — 批量预加载本批更新涉及的唯一列索引范围(一次 drainChain)
|
// v0.6.2: 唯一约束 — 批量预加载本批更新涉及的唯一列索引范围(一次 drainChain)
|
||||||
const uniqueCols = this.uniqueColumns(tableName, schema);
|
const uniqueCols = this.uniqueColumns(tableName, schema);
|
||||||
for (const colName of uniqueCols) {
|
for (const colName of uniqueCols) {
|
||||||
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`);
|
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`);
|
||||||
const ranges = [];
|
const ranges = [];
|
||||||
if (updates[colName] !== undefined && updates[colName] !== null) {
|
if (cleanUpdates[colName] !== undefined && cleanUpdates[colName] !== null) {
|
||||||
const p = `${String(updates[colName])}:`;
|
const p = `${String(cleanUpdates[colName])}:`;
|
||||||
ranges.push([p, `${p}\uffff`]);
|
ranges.push([p, `${p}\uffff`]);
|
||||||
}
|
}
|
||||||
else if (!(colName in updates)) {
|
else if (!(colName in cleanUpdates)) {
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const val = row[colName];
|
const val = row[colName];
|
||||||
if (val === undefined || val === null)
|
if (val === undefined || val === null)
|
||||||
@@ -6874,12 +7009,22 @@ class AriaEngine {
|
|||||||
}
|
}
|
||||||
await idxLsm.prefetchPrefixRanges(ranges);
|
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) {
|
for (const row of rows) {
|
||||||
const pkCol = this.tablePKs.get(tableName);
|
const pkCol = this.tablePKs.get(tableName);
|
||||||
const key = `${tableName}:${row[pkCol]}`;
|
const key = `${tableName}:${row[pkCol]}`;
|
||||||
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
|
if (query.where && Object.keys(query.where).length > 0 && !matchWhere(row, query.where))
|
||||||
const updated = { ...row, ...updates };
|
continue;
|
||||||
|
const updated = { ...row, ...cleanUpdates };
|
||||||
this.validateRow(schema, updated);
|
this.validateRow(schema, updated);
|
||||||
|
// 批内唯一互查(索引尚未更新,两行同时改到同一新值需要互查兜底)
|
||||||
|
this.checkBatchUnique(tableName, uniqueCols, updated, batchUnique);
|
||||||
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在)
|
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在)
|
||||||
this.checkUniqueSync(tableName, uniqueCols, updated, String(row[pkCol]));
|
this.checkUniqueSync(tableName, uniqueCols, updated, String(row[pkCol]));
|
||||||
// v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录
|
// v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录
|
||||||
@@ -6896,14 +7041,24 @@ class AriaEngine {
|
|||||||
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
|
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) {
|
if (pkChanged) {
|
||||||
// ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL)
|
// ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL)
|
||||||
await this.applyForeignKeyUpdateRules(tableName, String(row[pkCol]), newPk, walRecords, visited);
|
await this.applyForeignKeyUpdateRules(tableName, pk, newPk, walRecords, visited);
|
||||||
}
|
}
|
||||||
if (this.currentTxnId && this.txnSnapshot) {
|
if (this.currentTxnId && this.txnSnapshot) {
|
||||||
if (pkChanged) {
|
if (pkChanged) {
|
||||||
this.txnSnapshot.set(key, { __txn_deleted: true });
|
this.txnSnapshot.set(key, { __txn_deleted: true });
|
||||||
this.mvcc.deleteVersion(tableName, String(row[pkCol]), this.currentTxnId);
|
this.mvcc.deleteVersion(tableName, pk, this.currentTxnId);
|
||||||
}
|
}
|
||||||
this.txnSnapshot.set(`${tableName}:${newPk}`, updated);
|
this.txnSnapshot.set(`${tableName}:${newPk}`, updated);
|
||||||
this.mvcc.writeVersion(tableName, newPk, updated, this.currentTxnId);
|
this.mvcc.writeVersion(tableName, newPk, updated, this.currentTxnId);
|
||||||
@@ -6919,7 +7074,7 @@ class AriaEngine {
|
|||||||
type: WALRecordType.DELETE,
|
type: WALRecordType.DELETE,
|
||||||
txnId: this.currentTxnId ?? 0,
|
txnId: this.currentTxnId ?? 0,
|
||||||
tableName,
|
tableName,
|
||||||
key: String(row[pkCol]),
|
key: pk,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
walRecords.push({
|
walRecords.push({
|
||||||
@@ -6934,13 +7089,60 @@ class AriaEngine {
|
|||||||
// (唯一性检查误报 / 索引存储膨胀);现在统一传旧行清理旧值
|
// (唯一性检查误报 / 索引存储膨胀);现在统一传旧行清理旧值
|
||||||
this.updateSecondaryIndexes(tableName, newPk, updated, row);
|
this.updateSecondaryIndexes(tableName, newPk, updated, row);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
await this.wal.appendBatch(walRecords);
|
await this.wal.appendBatch(walRecords);
|
||||||
this.opCounter += count;
|
this.opCounter += count;
|
||||||
await this.checkpointManager.tick();
|
await this.checkpointManager.tick();
|
||||||
this.trimAllCaches();
|
this.trimAllCaches();
|
||||||
return count;
|
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 时处理引用表。
|
* v0.4.2-fix: ON UPDATE 外键级联 — 主键 oldPk → newPk 时处理引用表。
|
||||||
* RESTRICT 抛错 / CASCADE 更新 FK / SET NULL 置空(含索引与 WAL 记录)。
|
* RESTRICT 抛错 / CASCADE 更新 FK / SET NULL 置空(含索引与 WAL 记录)。
|
||||||
@@ -7083,6 +7285,10 @@ class AriaEngine {
|
|||||||
if (colDef.onDelete === 'RESTRICT' && matched.length > 0) {
|
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');
|
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') {
|
if (colDef.onDelete === 'CASCADE') {
|
||||||
const refPkCol = this.tablePKs.get(refTableName);
|
const refPkCol = this.tablePKs.get(refTableName);
|
||||||
for (const refRow of matched) {
|
for (const refRow of matched) {
|
||||||
@@ -8255,12 +8461,22 @@ class HybridEngine {
|
|||||||
// ---- 表管理 ----
|
// ---- 表管理 ----
|
||||||
async createTable(schema) {
|
async createTable(schema) {
|
||||||
await this.memoryEngine.createTable(schema);
|
await this.memoryEngine.createTable(schema);
|
||||||
|
try {
|
||||||
await this.diskEngine.createTable(schema);
|
await this.diskEngine.createTable(schema);
|
||||||
}
|
}
|
||||||
|
catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
async dropTable(tableName) {
|
async dropTable(tableName) {
|
||||||
await this.memoryEngine.dropTable(tableName);
|
await this.memoryEngine.dropTable(tableName);
|
||||||
|
try {
|
||||||
await this.diskEngine.dropTable(tableName);
|
await this.diskEngine.dropTable(tableName);
|
||||||
}
|
}
|
||||||
|
catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
async hasTable(tableName) {
|
async hasTable(tableName) {
|
||||||
return this.memoryEngine.hasTable(tableName);
|
return this.memoryEngine.hasTable(tableName);
|
||||||
}
|
}
|
||||||
@@ -8273,6 +8489,7 @@ class HybridEngine {
|
|||||||
/** v0.4.2-fix: 引擎级 ALTER TABLE — 双引擎同步(磁盘持久化 + 内存引用) */
|
/** v0.4.2-fix: 引擎级 ALTER TABLE — 双引擎同步(磁盘持久化 + 内存引用) */
|
||||||
async alterTable(tableName, action, column) {
|
async alterTable(tableName, action, column) {
|
||||||
await this.memoryEngine.alterTable(tableName, action, column);
|
await this.memoryEngine.alterTable(tableName, action, column);
|
||||||
|
try {
|
||||||
if (typeof this.diskEngine.alterTable === 'function') {
|
if (typeof this.diskEngine.alterTable === 'function') {
|
||||||
await this.diskEngine.alterTable(tableName, action, column);
|
await this.diskEngine.alterTable(tableName, action, column);
|
||||||
}
|
}
|
||||||
@@ -8283,11 +8500,38 @@ class HybridEngine {
|
|||||||
delete schema.columns[column.name];
|
delete schema.columns[column.name];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
// ---- CRUD(write-through 策略) ----
|
// ---- 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) {
|
async insert(tableName, rows) {
|
||||||
const pks = await this.memoryEngine.insert(tableName, rows);
|
const pks = await this.memoryEngine.insert(tableName, rows);
|
||||||
// write-through: 同步写入磁盘
|
// write-through: 同步写入磁盘
|
||||||
|
try {
|
||||||
await this.diskEngine.insert(tableName, rows);
|
await this.diskEngine.insert(tableName, rows);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
return pks;
|
return pks;
|
||||||
}
|
}
|
||||||
async find(tableName, query) {
|
async find(tableName, query) {
|
||||||
@@ -8301,13 +8545,23 @@ class HybridEngine {
|
|||||||
async update(tableName, query, updates) {
|
async update(tableName, query, updates) {
|
||||||
const count = await this.memoryEngine.update(tableName, query, updates);
|
const count = await this.memoryEngine.update(tableName, query, updates);
|
||||||
// write-through: 同步更新磁盘
|
// write-through: 同步更新磁盘
|
||||||
|
try {
|
||||||
await this.diskEngine.update(tableName, query, updates);
|
await this.diskEngine.update(tableName, query, updates);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
async delete(tableName, query) {
|
async delete(tableName, query) {
|
||||||
const count = await this.memoryEngine.delete(tableName, query);
|
const count = await this.memoryEngine.delete(tableName, query);
|
||||||
// write-through: 同步删除磁盘
|
// write-through: 同步删除磁盘
|
||||||
|
try {
|
||||||
await this.diskEngine.delete(tableName, query);
|
await this.diskEngine.delete(tableName, query);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
async count(tableName, query) {
|
async count(tableName, query) {
|
||||||
@@ -8315,21 +8569,36 @@ class HybridEngine {
|
|||||||
}
|
}
|
||||||
async clear(tableName) {
|
async clear(tableName) {
|
||||||
await this.memoryEngine.clear(tableName);
|
await this.memoryEngine.clear(tableName);
|
||||||
|
try {
|
||||||
await this.diskEngine.clear(tableName);
|
await this.diskEngine.clear(tableName);
|
||||||
}
|
}
|
||||||
|
catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
// ---- 动态索引(v0.3.0) ----
|
// ---- 动态索引(v0.3.0) ----
|
||||||
async createIndex(tableName, column, unique) {
|
async createIndex(tableName, column, unique) {
|
||||||
await this.memoryEngine.createIndex(tableName, column, unique);
|
await this.memoryEngine.createIndex(tableName, column, unique);
|
||||||
|
try {
|
||||||
if (typeof this.diskEngine.createIndex === 'function') {
|
if (typeof this.diskEngine.createIndex === 'function') {
|
||||||
await this.diskEngine.createIndex(tableName, column, unique);
|
await this.diskEngine.createIndex(tableName, column, unique);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
async dropIndex(tableName, column, indexName) {
|
async dropIndex(tableName, column, indexName) {
|
||||||
await this.memoryEngine.dropIndex(tableName, column, indexName);
|
await this.memoryEngine.dropIndex(tableName, column, indexName);
|
||||||
|
try {
|
||||||
if (typeof this.diskEngine.dropIndex === 'function') {
|
if (typeof this.diskEngine.dropIndex === 'function') {
|
||||||
await this.diskEngine.dropIndex(tableName, column, indexName);
|
await this.diskEngine.dropIndex(tableName, column, indexName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
// ---- 事务 ----
|
// ---- 事务 ----
|
||||||
async beginTransaction() {
|
async beginTransaction() {
|
||||||
await this.memoryEngine.beginTransaction();
|
await this.memoryEngine.beginTransaction();
|
||||||
@@ -9057,6 +9326,11 @@ class Lexer {
|
|||||||
value += this.ch;
|
value += this.ch;
|
||||||
this.readChar();
|
this.readChar();
|
||||||
}
|
}
|
||||||
|
// v0.7.2: 未闭合字符串字面量显式报错(此前静默返回残缺 STRING token,
|
||||||
|
// 上层可解析出错误结果,如 `SELECT 'abc` 被当作合法常量列)
|
||||||
|
if (this.ch === '') {
|
||||||
|
throw new DatabaseError(`Unterminated string literal at position ${start}`, 'PARSE_ERROR');
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
type: TokenType.STRING,
|
type: TokenType.STRING,
|
||||||
value,
|
value,
|
||||||
@@ -11623,6 +11897,10 @@ class QueryExecutor {
|
|||||||
* 绑定在词法层面完成:仅替换字符串字面量之外的 `?`,
|
* 绑定在词法层面完成:仅替换字符串字面量之外的 `?`,
|
||||||
* 值按 SQL 字面量编码(字符串 `''` 转义、数字/布尔/JSON 直出),
|
* 值按 SQL 字面量编码(字符串 `''` 转义、数字/布尔/JSON 直出),
|
||||||
* 从根上规避 SQL 注入(不经过字符串拼接由用户自行转义)。
|
* 从根上规避 SQL 注入(不经过字符串拼接由用户自行转义)。
|
||||||
|
*
|
||||||
|
* v0.7.2: 词法扫描感知注释 —— 行注释(`--`)与块注释(slash-star 包裹)中的 `?`
|
||||||
|
* 与引号不再参与占位符识别与字符串状态机(此前注释中的 `?` 计入占位符导致
|
||||||
|
* PARAM_ERROR 错位、注释中的单引号触发 "Unterminated string literal")。
|
||||||
*/
|
*/
|
||||||
/** 将单个参数值编码为 SQL 字面量 */
|
/** 将单个参数值编码为 SQL 字面量 */
|
||||||
function encodeParam(value) {
|
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');
|
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 sql 含 `?` 占位符的 SQL
|
||||||
* @param params 位置参数数组
|
* @param params 位置参数数组
|
||||||
* @throws PARAM_ERROR 参数数量不匹配
|
* @throws PARAM_ERROR 参数数量不匹配
|
||||||
@@ -11676,6 +11954,28 @@ function bindParameters(sql, params) {
|
|||||||
i++;
|
i++;
|
||||||
continue;
|
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 (ch === '?') {
|
||||||
if (pIdx >= params.length) {
|
if (pIdx >= params.length) {
|
||||||
throw new DatabaseError(`Too few query parameters: placeholder #${pIdx + 1} has no value (got ${params.length} total)`, 'PARAM_ERROR');
|
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;
|
destroy(): void;
|
||||||
}
|
}
|
||||||
declare const VERSION = "0.7.1";
|
declare const VERSION = "0.7.2";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* metona-sqlark Plugin — 插件系统
|
* metona-sqlark Plugin — 插件系统
|
||||||
@@ -807,6 +807,17 @@ declare class MemoryEngine implements IStorageEngine {
|
|||||||
/** v0.4.0: 流式查询 — 逐行回调(单次迭代,不物化结果数组) */
|
/** v0.4.0: 流式查询 — 逐行回调(单次迭代,不物化结果数组) */
|
||||||
findStream(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number>;
|
findStream(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number>;
|
||||||
update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): 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 外键级联 — 被引用表主键变更时处理引用表:
|
* v0.4.2-fix: ON UPDATE 外键级联 — 被引用表主键变更时处理引用表:
|
||||||
* RESTRICT 抛错 / CASCADE 更新 FK 值 / SET NULL 置空。
|
* RESTRICT 抛错 / CASCADE 更新 FK 值 / SET NULL 置空。
|
||||||
@@ -1026,6 +1037,16 @@ declare class AriaEngine implements IStorageEngine {
|
|||||||
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
|
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
|
||||||
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
||||||
update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number>;
|
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 时处理引用表。
|
* v0.4.2-fix: ON UPDATE 外键级联 — 主键 oldPk → newPk 时处理引用表。
|
||||||
* RESTRICT 抛错 / CASCADE 更新 FK / SET NULL 置空(含索引与 WAL 记录)。
|
* RESTRICT 抛错 / CASCADE 更新 FK / SET NULL 置空(含索引与 WAL 记录)。
|
||||||
@@ -1181,6 +1202,13 @@ declare class HybridEngine implements IStorageEngine {
|
|||||||
alterTable(tableName: string, action: 'ADD' | 'DROP', column: ColumnDef & {
|
alterTable(tableName: string, action: 'ADD' | 'DROP', column: ColumnDef & {
|
||||||
name: string;
|
name: string;
|
||||||
}): Promise<void>;
|
}): Promise<void>;
|
||||||
|
/**
|
||||||
|
* v0.7.2: 磁盘写失败补偿 — 内存已先行写入、磁盘失败 → 内存与磁盘不一致
|
||||||
|
* (重启后数据丢失且调用方已收到错误)。从磁盘重载内存对齐真实状态
|
||||||
|
* (内存=磁盘),再重新抛出原始错误。事务路径由双引擎快照回滚保证,
|
||||||
|
* 无需此补偿。
|
||||||
|
*/
|
||||||
|
private recoverMemoryAfterDiskError;
|
||||||
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
|
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
|
||||||
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
||||||
/** v0.4.0: 流式查询(内存引擎逐行回调) */
|
/** v0.4.0: 流式查询(内存引擎逐行回调) */
|
||||||
|
|||||||
Vendored
+427
-127
@@ -30,7 +30,7 @@ class DatabaseError extends Error {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 版本
|
// 版本
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const VERSION = '0.7.1';
|
const VERSION = '0.7.2';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑
|
* metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑
|
||||||
@@ -156,7 +156,10 @@ function matchOperator(value, op, operand) {
|
|||||||
case '$in': return Array.isArray(operand) && operand.includes(value);
|
case '$in': return Array.isArray(operand) && operand.includes(value);
|
||||||
case '$nin': 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));
|
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;
|
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 的内存存储引擎
|
* metona-sqlark Memory Engine — 基于 Map 的内存存储引擎
|
||||||
* @module engine/memory
|
* @module engine/memory
|
||||||
@@ -297,6 +417,12 @@ class MemoryEngine {
|
|||||||
* (此前走 executor 通用路径,行为相同;统一到引擎层保证 Hybrid/IndexedDB 委托一致性)
|
* (此前走 executor 通用路径,行为相同;统一到引擎层保证 Hybrid/IndexedDB 委托一致性)
|
||||||
*/
|
*/
|
||||||
async alterTable(tableName, action, column) {
|
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);
|
this.ensureTable(tableName);
|
||||||
const schema = this.schemas.get(tableName);
|
const schema = this.schemas.get(tableName);
|
||||||
if (action === 'ADD') {
|
if (action === 'ADD') {
|
||||||
@@ -385,20 +511,37 @@ class MemoryEngine {
|
|||||||
const schema = this.schemas.get(tableName);
|
const schema = this.schemas.get(tableName);
|
||||||
const table = this.tables.get(tableName);
|
const table = this.tables.get(tableName);
|
||||||
const pkCol = this.getPrimaryKey(schema);
|
const pkCol = this.getPrimaryKey(schema);
|
||||||
let count = 0;
|
// v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空
|
||||||
// v0.4.2-fix: 迭代期间会 delete/set 同一 Map(主键变更)→ 拷贝快照避免跳过/重复
|
const cleanUpdates = stripUndefinedUpdates(updates);
|
||||||
for (const [pk, row] of [...table]) {
|
// v0.7.2: 语句级原子性 — 两阶段(先全量预检,后执行)。
|
||||||
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
|
// 此前逐行"校验+写入":第 N 行唯一冲突/校验失败抛错时,前 N-1 行已写入
|
||||||
// v0.3.3: 先移除旧值索引条目(修复 update 后唯一约束被绕过、按新值查索引丢行)
|
// → 无事务下语句级部分提交(数据半更新且调用方已收到错误)。
|
||||||
this.removeIndexEntries(tableName, row, pk);
|
const planned = [];
|
||||||
const updated = { ...row, ...updates };
|
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.validateRow(schema, updated);
|
||||||
this.checkUniqueness(schema, updated);
|
this.checkUpdateUniqueness(schema, tableName, pk, updated, batchUnique);
|
||||||
const newPk = String(updated[pkCol]);
|
const newPk = String(updated[pkCol]);
|
||||||
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据)
|
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据)
|
||||||
if (newPk !== pk && table.has(newPk)) {
|
if (newPk !== pk && table.has(newPk)) {
|
||||||
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
|
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: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
|
// v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
|
||||||
if (newPk !== pk) {
|
if (newPk !== pk) {
|
||||||
await this.applyUpdateCascade(tableName, pk, newPk);
|
await this.applyUpdateCascade(tableName, pk, newPk);
|
||||||
@@ -408,9 +551,76 @@ class MemoryEngine {
|
|||||||
this.updateIndexes(tableName, updated, newPk);
|
this.updateIndexes(tableName, updated, newPk);
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return 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 外键级联 — 被引用表主键变更时处理引用表:
|
* v0.4.2-fix: ON UPDATE 外键级联 — 被引用表主键变更时处理引用表:
|
||||||
* RESTRICT 抛错 / CASCADE 更新 FK 值 / SET NULL 置空。
|
* RESTRICT 抛错 / CASCADE 更新 FK 值 / SET NULL 置空。
|
||||||
@@ -522,6 +732,10 @@ class MemoryEngine {
|
|||||||
if (colDef.onDelete === 'RESTRICT' && refPks.length > 0) {
|
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');
|
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') {
|
if (colDef.onDelete === 'CASCADE') {
|
||||||
for (const refPk of refPks) {
|
for (const refPk of refPks) {
|
||||||
this.checkCascadeRestrict(refTableName, refPk, visited);
|
this.checkCascadeRestrict(refTableName, refPk, visited);
|
||||||
@@ -552,6 +766,11 @@ class MemoryEngine {
|
|||||||
}
|
}
|
||||||
// ---- 动态索引(v0.3.0) ----
|
// ---- 动态索引(v0.3.0) ----
|
||||||
async createIndex(tableName, column, unique) {
|
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);
|
this.ensureTable(tableName);
|
||||||
const schema = this.schemas.get(tableName);
|
const schema = this.schemas.get(tableName);
|
||||||
const colDef = schema.columns[column];
|
const colDef = schema.columns[column];
|
||||||
@@ -577,6 +796,10 @@ class MemoryEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async dropIndex(tableName, column, _indexName) {
|
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);
|
this.ensureTable(tableName);
|
||||||
const schema = this.schemas.get(tableName);
|
const schema = this.schemas.get(tableName);
|
||||||
const colDef = schema.columns[column];
|
const colDef = schema.columns[column];
|
||||||
@@ -2025,6 +2248,12 @@ class KVStoreEngine {
|
|||||||
}
|
}
|
||||||
async alterTable(tableName, action, column) {
|
async alterTable(tableName, action, column) {
|
||||||
this.ensureOpen();
|
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);
|
await this.memory.alterTable(tableName, action, column);
|
||||||
if (this.txActive) {
|
if (this.txActive) {
|
||||||
this.txDirtyTables.add(tableName);
|
this.txDirtyTables.add(tableName);
|
||||||
@@ -2086,10 +2315,12 @@ class KVStoreEngine {
|
|||||||
if (!schema)
|
if (!schema)
|
||||||
throw new DatabaseError(`Table "${tableName}" does not exist`, 'TABLE_NOT_FOUND');
|
throw new DatabaseError(`Table "${tableName}" does not exist`, 'TABLE_NOT_FOUND');
|
||||||
const pkCol = this.getPK(schema);
|
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 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) {
|
if (this.txActive) {
|
||||||
this.txDirtyTables.add(tableName);
|
this.txDirtyTables.add(tableName);
|
||||||
// v0.7.0: 行级变更记录 —— 主键变更无法行级追踪(旧键删除+新键落盘+级联),
|
// v0.7.0: 行级变更记录 —— 主键变更无法行级追踪(旧键删除+新键落盘+级联),
|
||||||
@@ -2214,6 +2445,9 @@ class KVStoreEngine {
|
|||||||
// ---- 动态索引 ----
|
// ---- 动态索引 ----
|
||||||
async createIndex(tableName, column, unique) {
|
async createIndex(tableName, column, unique) {
|
||||||
this.ensureOpen();
|
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);
|
await this.memory.createIndex(tableName, column, unique);
|
||||||
if (this.txActive) {
|
if (this.txActive) {
|
||||||
this.txDirtyTables.add(tableName);
|
this.txDirtyTables.add(tableName);
|
||||||
@@ -2224,6 +2458,9 @@ class KVStoreEngine {
|
|||||||
}
|
}
|
||||||
async dropIndex(tableName, column, indexName) {
|
async dropIndex(tableName, column, indexName) {
|
||||||
this.ensureOpen();
|
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);
|
await this.memory.dropIndex(tableName, column, indexName);
|
||||||
if (this.txActive) {
|
if (this.txActive) {
|
||||||
this.txDirtyTables.add(tableName);
|
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 — 内部类型定义
|
* AriaEngine Types — 内部类型定义
|
||||||
* @module engine/aria/types
|
* @module engine/aria/types
|
||||||
@@ -6850,16 +6983,18 @@ class AriaEngine {
|
|||||||
const walRecords = [];
|
const walRecords = [];
|
||||||
// v0.4.2-fix: ON UPDATE 级联环路保护
|
// v0.4.2-fix: ON UPDATE 级联环路保护
|
||||||
const visited = new Set();
|
const visited = new Set();
|
||||||
|
// v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空
|
||||||
|
const cleanUpdates = stripUndefinedUpdates(updates);
|
||||||
// v0.6.2: 唯一约束 — 批量预加载本批更新涉及的唯一列索引范围(一次 drainChain)
|
// v0.6.2: 唯一约束 — 批量预加载本批更新涉及的唯一列索引范围(一次 drainChain)
|
||||||
const uniqueCols = this.uniqueColumns(tableName, schema);
|
const uniqueCols = this.uniqueColumns(tableName, schema);
|
||||||
for (const colName of uniqueCols) {
|
for (const colName of uniqueCols) {
|
||||||
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`);
|
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`);
|
||||||
const ranges = [];
|
const ranges = [];
|
||||||
if (updates[colName] !== undefined && updates[colName] !== null) {
|
if (cleanUpdates[colName] !== undefined && cleanUpdates[colName] !== null) {
|
||||||
const p = `${String(updates[colName])}:`;
|
const p = `${String(cleanUpdates[colName])}:`;
|
||||||
ranges.push([p, `${p}\uffff`]);
|
ranges.push([p, `${p}\uffff`]);
|
||||||
}
|
}
|
||||||
else if (!(colName in updates)) {
|
else if (!(colName in cleanUpdates)) {
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const val = row[colName];
|
const val = row[colName];
|
||||||
if (val === undefined || val === null)
|
if (val === undefined || val === null)
|
||||||
@@ -6870,12 +7005,22 @@ class AriaEngine {
|
|||||||
}
|
}
|
||||||
await idxLsm.prefetchPrefixRanges(ranges);
|
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) {
|
for (const row of rows) {
|
||||||
const pkCol = this.tablePKs.get(tableName);
|
const pkCol = this.tablePKs.get(tableName);
|
||||||
const key = `${tableName}:${row[pkCol]}`;
|
const key = `${tableName}:${row[pkCol]}`;
|
||||||
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
|
if (query.where && Object.keys(query.where).length > 0 && !matchWhere(row, query.where))
|
||||||
const updated = { ...row, ...updates };
|
continue;
|
||||||
|
const updated = { ...row, ...cleanUpdates };
|
||||||
this.validateRow(schema, updated);
|
this.validateRow(schema, updated);
|
||||||
|
// 批内唯一互查(索引尚未更新,两行同时改到同一新值需要互查兜底)
|
||||||
|
this.checkBatchUnique(tableName, uniqueCols, updated, batchUnique);
|
||||||
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在)
|
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在)
|
||||||
this.checkUniqueSync(tableName, uniqueCols, updated, String(row[pkCol]));
|
this.checkUniqueSync(tableName, uniqueCols, updated, String(row[pkCol]));
|
||||||
// v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录
|
// v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录
|
||||||
@@ -6892,14 +7037,24 @@ class AriaEngine {
|
|||||||
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
|
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) {
|
if (pkChanged) {
|
||||||
// ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL)
|
// ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL)
|
||||||
await this.applyForeignKeyUpdateRules(tableName, String(row[pkCol]), newPk, walRecords, visited);
|
await this.applyForeignKeyUpdateRules(tableName, pk, newPk, walRecords, visited);
|
||||||
}
|
}
|
||||||
if (this.currentTxnId && this.txnSnapshot) {
|
if (this.currentTxnId && this.txnSnapshot) {
|
||||||
if (pkChanged) {
|
if (pkChanged) {
|
||||||
this.txnSnapshot.set(key, { __txn_deleted: true });
|
this.txnSnapshot.set(key, { __txn_deleted: true });
|
||||||
this.mvcc.deleteVersion(tableName, String(row[pkCol]), this.currentTxnId);
|
this.mvcc.deleteVersion(tableName, pk, this.currentTxnId);
|
||||||
}
|
}
|
||||||
this.txnSnapshot.set(`${tableName}:${newPk}`, updated);
|
this.txnSnapshot.set(`${tableName}:${newPk}`, updated);
|
||||||
this.mvcc.writeVersion(tableName, newPk, updated, this.currentTxnId);
|
this.mvcc.writeVersion(tableName, newPk, updated, this.currentTxnId);
|
||||||
@@ -6915,7 +7070,7 @@ class AriaEngine {
|
|||||||
type: WALRecordType.DELETE,
|
type: WALRecordType.DELETE,
|
||||||
txnId: this.currentTxnId ?? 0,
|
txnId: this.currentTxnId ?? 0,
|
||||||
tableName,
|
tableName,
|
||||||
key: String(row[pkCol]),
|
key: pk,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
walRecords.push({
|
walRecords.push({
|
||||||
@@ -6930,13 +7085,60 @@ class AriaEngine {
|
|||||||
// (唯一性检查误报 / 索引存储膨胀);现在统一传旧行清理旧值
|
// (唯一性检查误报 / 索引存储膨胀);现在统一传旧行清理旧值
|
||||||
this.updateSecondaryIndexes(tableName, newPk, updated, row);
|
this.updateSecondaryIndexes(tableName, newPk, updated, row);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
await this.wal.appendBatch(walRecords);
|
await this.wal.appendBatch(walRecords);
|
||||||
this.opCounter += count;
|
this.opCounter += count;
|
||||||
await this.checkpointManager.tick();
|
await this.checkpointManager.tick();
|
||||||
this.trimAllCaches();
|
this.trimAllCaches();
|
||||||
return count;
|
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 时处理引用表。
|
* v0.4.2-fix: ON UPDATE 外键级联 — 主键 oldPk → newPk 时处理引用表。
|
||||||
* RESTRICT 抛错 / CASCADE 更新 FK / SET NULL 置空(含索引与 WAL 记录)。
|
* RESTRICT 抛错 / CASCADE 更新 FK / SET NULL 置空(含索引与 WAL 记录)。
|
||||||
@@ -7079,6 +7281,10 @@ class AriaEngine {
|
|||||||
if (colDef.onDelete === 'RESTRICT' && matched.length > 0) {
|
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');
|
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') {
|
if (colDef.onDelete === 'CASCADE') {
|
||||||
const refPkCol = this.tablePKs.get(refTableName);
|
const refPkCol = this.tablePKs.get(refTableName);
|
||||||
for (const refRow of matched) {
|
for (const refRow of matched) {
|
||||||
@@ -8251,12 +8457,22 @@ class HybridEngine {
|
|||||||
// ---- 表管理 ----
|
// ---- 表管理 ----
|
||||||
async createTable(schema) {
|
async createTable(schema) {
|
||||||
await this.memoryEngine.createTable(schema);
|
await this.memoryEngine.createTable(schema);
|
||||||
|
try {
|
||||||
await this.diskEngine.createTable(schema);
|
await this.diskEngine.createTable(schema);
|
||||||
}
|
}
|
||||||
|
catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
async dropTable(tableName) {
|
async dropTable(tableName) {
|
||||||
await this.memoryEngine.dropTable(tableName);
|
await this.memoryEngine.dropTable(tableName);
|
||||||
|
try {
|
||||||
await this.diskEngine.dropTable(tableName);
|
await this.diskEngine.dropTable(tableName);
|
||||||
}
|
}
|
||||||
|
catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
async hasTable(tableName) {
|
async hasTable(tableName) {
|
||||||
return this.memoryEngine.hasTable(tableName);
|
return this.memoryEngine.hasTable(tableName);
|
||||||
}
|
}
|
||||||
@@ -8269,6 +8485,7 @@ class HybridEngine {
|
|||||||
/** v0.4.2-fix: 引擎级 ALTER TABLE — 双引擎同步(磁盘持久化 + 内存引用) */
|
/** v0.4.2-fix: 引擎级 ALTER TABLE — 双引擎同步(磁盘持久化 + 内存引用) */
|
||||||
async alterTable(tableName, action, column) {
|
async alterTable(tableName, action, column) {
|
||||||
await this.memoryEngine.alterTable(tableName, action, column);
|
await this.memoryEngine.alterTable(tableName, action, column);
|
||||||
|
try {
|
||||||
if (typeof this.diskEngine.alterTable === 'function') {
|
if (typeof this.diskEngine.alterTable === 'function') {
|
||||||
await this.diskEngine.alterTable(tableName, action, column);
|
await this.diskEngine.alterTable(tableName, action, column);
|
||||||
}
|
}
|
||||||
@@ -8279,11 +8496,38 @@ class HybridEngine {
|
|||||||
delete schema.columns[column.name];
|
delete schema.columns[column.name];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
// ---- CRUD(write-through 策略) ----
|
// ---- 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) {
|
async insert(tableName, rows) {
|
||||||
const pks = await this.memoryEngine.insert(tableName, rows);
|
const pks = await this.memoryEngine.insert(tableName, rows);
|
||||||
// write-through: 同步写入磁盘
|
// write-through: 同步写入磁盘
|
||||||
|
try {
|
||||||
await this.diskEngine.insert(tableName, rows);
|
await this.diskEngine.insert(tableName, rows);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
return pks;
|
return pks;
|
||||||
}
|
}
|
||||||
async find(tableName, query) {
|
async find(tableName, query) {
|
||||||
@@ -8297,13 +8541,23 @@ class HybridEngine {
|
|||||||
async update(tableName, query, updates) {
|
async update(tableName, query, updates) {
|
||||||
const count = await this.memoryEngine.update(tableName, query, updates);
|
const count = await this.memoryEngine.update(tableName, query, updates);
|
||||||
// write-through: 同步更新磁盘
|
// write-through: 同步更新磁盘
|
||||||
|
try {
|
||||||
await this.diskEngine.update(tableName, query, updates);
|
await this.diskEngine.update(tableName, query, updates);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
async delete(tableName, query) {
|
async delete(tableName, query) {
|
||||||
const count = await this.memoryEngine.delete(tableName, query);
|
const count = await this.memoryEngine.delete(tableName, query);
|
||||||
// write-through: 同步删除磁盘
|
// write-through: 同步删除磁盘
|
||||||
|
try {
|
||||||
await this.diskEngine.delete(tableName, query);
|
await this.diskEngine.delete(tableName, query);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
async count(tableName, query) {
|
async count(tableName, query) {
|
||||||
@@ -8311,21 +8565,36 @@ class HybridEngine {
|
|||||||
}
|
}
|
||||||
async clear(tableName) {
|
async clear(tableName) {
|
||||||
await this.memoryEngine.clear(tableName);
|
await this.memoryEngine.clear(tableName);
|
||||||
|
try {
|
||||||
await this.diskEngine.clear(tableName);
|
await this.diskEngine.clear(tableName);
|
||||||
}
|
}
|
||||||
|
catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
// ---- 动态索引(v0.3.0) ----
|
// ---- 动态索引(v0.3.0) ----
|
||||||
async createIndex(tableName, column, unique) {
|
async createIndex(tableName, column, unique) {
|
||||||
await this.memoryEngine.createIndex(tableName, column, unique);
|
await this.memoryEngine.createIndex(tableName, column, unique);
|
||||||
|
try {
|
||||||
if (typeof this.diskEngine.createIndex === 'function') {
|
if (typeof this.diskEngine.createIndex === 'function') {
|
||||||
await this.diskEngine.createIndex(tableName, column, unique);
|
await this.diskEngine.createIndex(tableName, column, unique);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
async dropIndex(tableName, column, indexName) {
|
async dropIndex(tableName, column, indexName) {
|
||||||
await this.memoryEngine.dropIndex(tableName, column, indexName);
|
await this.memoryEngine.dropIndex(tableName, column, indexName);
|
||||||
|
try {
|
||||||
if (typeof this.diskEngine.dropIndex === 'function') {
|
if (typeof this.diskEngine.dropIndex === 'function') {
|
||||||
await this.diskEngine.dropIndex(tableName, column, indexName);
|
await this.diskEngine.dropIndex(tableName, column, indexName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
// ---- 事务 ----
|
// ---- 事务 ----
|
||||||
async beginTransaction() {
|
async beginTransaction() {
|
||||||
await this.memoryEngine.beginTransaction();
|
await this.memoryEngine.beginTransaction();
|
||||||
@@ -9053,6 +9322,11 @@ class Lexer {
|
|||||||
value += this.ch;
|
value += this.ch;
|
||||||
this.readChar();
|
this.readChar();
|
||||||
}
|
}
|
||||||
|
// v0.7.2: 未闭合字符串字面量显式报错(此前静默返回残缺 STRING token,
|
||||||
|
// 上层可解析出错误结果,如 `SELECT 'abc` 被当作合法常量列)
|
||||||
|
if (this.ch === '') {
|
||||||
|
throw new DatabaseError(`Unterminated string literal at position ${start}`, 'PARSE_ERROR');
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
type: TokenType.STRING,
|
type: TokenType.STRING,
|
||||||
value,
|
value,
|
||||||
@@ -11619,6 +11893,10 @@ class QueryExecutor {
|
|||||||
* 绑定在词法层面完成:仅替换字符串字面量之外的 `?`,
|
* 绑定在词法层面完成:仅替换字符串字面量之外的 `?`,
|
||||||
* 值按 SQL 字面量编码(字符串 `''` 转义、数字/布尔/JSON 直出),
|
* 值按 SQL 字面量编码(字符串 `''` 转义、数字/布尔/JSON 直出),
|
||||||
* 从根上规避 SQL 注入(不经过字符串拼接由用户自行转义)。
|
* 从根上规避 SQL 注入(不经过字符串拼接由用户自行转义)。
|
||||||
|
*
|
||||||
|
* v0.7.2: 词法扫描感知注释 —— 行注释(`--`)与块注释(slash-star 包裹)中的 `?`
|
||||||
|
* 与引号不再参与占位符识别与字符串状态机(此前注释中的 `?` 计入占位符导致
|
||||||
|
* PARAM_ERROR 错位、注释中的单引号触发 "Unterminated string literal")。
|
||||||
*/
|
*/
|
||||||
/** 将单个参数值编码为 SQL 字面量 */
|
/** 将单个参数值编码为 SQL 字面量 */
|
||||||
function encodeParam(value) {
|
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');
|
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 sql 含 `?` 占位符的 SQL
|
||||||
* @param params 位置参数数组
|
* @param params 位置参数数组
|
||||||
* @throws PARAM_ERROR 参数数量不匹配
|
* @throws PARAM_ERROR 参数数量不匹配
|
||||||
@@ -11672,6 +11950,28 @@ function bindParameters(sql, params) {
|
|||||||
i++;
|
i++;
|
||||||
continue;
|
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 (ch === '?') {
|
||||||
if (pIdx >= params.length) {
|
if (pIdx >= params.length) {
|
||||||
throw new DatabaseError(`Too few query parameters: placeholder #${pIdx + 1} has no value (got ${params.length} total)`, 'PARAM_ERROR');
|
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
+427
-127
@@ -36,7 +36,7 @@
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 版本
|
// 版本
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const VERSION = '0.7.1';
|
const VERSION = '0.7.2';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑
|
* metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑
|
||||||
@@ -162,7 +162,10 @@
|
|||||||
case '$in': return Array.isArray(operand) && operand.includes(value);
|
case '$in': return Array.isArray(operand) && operand.includes(value);
|
||||||
case '$nin': 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));
|
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;
|
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 的内存存储引擎
|
* metona-sqlark Memory Engine — 基于 Map 的内存存储引擎
|
||||||
* @module engine/memory
|
* @module engine/memory
|
||||||
@@ -303,6 +423,12 @@
|
|||||||
* (此前走 executor 通用路径,行为相同;统一到引擎层保证 Hybrid/IndexedDB 委托一致性)
|
* (此前走 executor 通用路径,行为相同;统一到引擎层保证 Hybrid/IndexedDB 委托一致性)
|
||||||
*/
|
*/
|
||||||
async alterTable(tableName, action, column) {
|
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);
|
this.ensureTable(tableName);
|
||||||
const schema = this.schemas.get(tableName);
|
const schema = this.schemas.get(tableName);
|
||||||
if (action === 'ADD') {
|
if (action === 'ADD') {
|
||||||
@@ -391,20 +517,37 @@
|
|||||||
const schema = this.schemas.get(tableName);
|
const schema = this.schemas.get(tableName);
|
||||||
const table = this.tables.get(tableName);
|
const table = this.tables.get(tableName);
|
||||||
const pkCol = this.getPrimaryKey(schema);
|
const pkCol = this.getPrimaryKey(schema);
|
||||||
let count = 0;
|
// v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空
|
||||||
// v0.4.2-fix: 迭代期间会 delete/set 同一 Map(主键变更)→ 拷贝快照避免跳过/重复
|
const cleanUpdates = stripUndefinedUpdates(updates);
|
||||||
for (const [pk, row] of [...table]) {
|
// v0.7.2: 语句级原子性 — 两阶段(先全量预检,后执行)。
|
||||||
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
|
// 此前逐行"校验+写入":第 N 行唯一冲突/校验失败抛错时,前 N-1 行已写入
|
||||||
// v0.3.3: 先移除旧值索引条目(修复 update 后唯一约束被绕过、按新值查索引丢行)
|
// → 无事务下语句级部分提交(数据半更新且调用方已收到错误)。
|
||||||
this.removeIndexEntries(tableName, row, pk);
|
const planned = [];
|
||||||
const updated = { ...row, ...updates };
|
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.validateRow(schema, updated);
|
||||||
this.checkUniqueness(schema, updated);
|
this.checkUpdateUniqueness(schema, tableName, pk, updated, batchUnique);
|
||||||
const newPk = String(updated[pkCol]);
|
const newPk = String(updated[pkCol]);
|
||||||
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据)
|
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据)
|
||||||
if (newPk !== pk && table.has(newPk)) {
|
if (newPk !== pk && table.has(newPk)) {
|
||||||
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
|
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: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
|
// v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
|
||||||
if (newPk !== pk) {
|
if (newPk !== pk) {
|
||||||
await this.applyUpdateCascade(tableName, pk, newPk);
|
await this.applyUpdateCascade(tableName, pk, newPk);
|
||||||
@@ -414,9 +557,76 @@
|
|||||||
this.updateIndexes(tableName, updated, newPk);
|
this.updateIndexes(tableName, updated, newPk);
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return 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 外键级联 — 被引用表主键变更时处理引用表:
|
* v0.4.2-fix: ON UPDATE 外键级联 — 被引用表主键变更时处理引用表:
|
||||||
* RESTRICT 抛错 / CASCADE 更新 FK 值 / SET NULL 置空。
|
* RESTRICT 抛错 / CASCADE 更新 FK 值 / SET NULL 置空。
|
||||||
@@ -528,6 +738,10 @@
|
|||||||
if (colDef.onDelete === 'RESTRICT' && refPks.length > 0) {
|
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');
|
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') {
|
if (colDef.onDelete === 'CASCADE') {
|
||||||
for (const refPk of refPks) {
|
for (const refPk of refPks) {
|
||||||
this.checkCascadeRestrict(refTableName, refPk, visited);
|
this.checkCascadeRestrict(refTableName, refPk, visited);
|
||||||
@@ -558,6 +772,11 @@
|
|||||||
}
|
}
|
||||||
// ---- 动态索引(v0.3.0) ----
|
// ---- 动态索引(v0.3.0) ----
|
||||||
async createIndex(tableName, column, unique) {
|
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);
|
this.ensureTable(tableName);
|
||||||
const schema = this.schemas.get(tableName);
|
const schema = this.schemas.get(tableName);
|
||||||
const colDef = schema.columns[column];
|
const colDef = schema.columns[column];
|
||||||
@@ -583,6 +802,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async dropIndex(tableName, column, _indexName) {
|
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);
|
this.ensureTable(tableName);
|
||||||
const schema = this.schemas.get(tableName);
|
const schema = this.schemas.get(tableName);
|
||||||
const colDef = schema.columns[column];
|
const colDef = schema.columns[column];
|
||||||
@@ -2031,6 +2254,12 @@
|
|||||||
}
|
}
|
||||||
async alterTable(tableName, action, column) {
|
async alterTable(tableName, action, column) {
|
||||||
this.ensureOpen();
|
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);
|
await this.memory.alterTable(tableName, action, column);
|
||||||
if (this.txActive) {
|
if (this.txActive) {
|
||||||
this.txDirtyTables.add(tableName);
|
this.txDirtyTables.add(tableName);
|
||||||
@@ -2092,10 +2321,12 @@
|
|||||||
if (!schema)
|
if (!schema)
|
||||||
throw new DatabaseError(`Table "${tableName}" does not exist`, 'TABLE_NOT_FOUND');
|
throw new DatabaseError(`Table "${tableName}" does not exist`, 'TABLE_NOT_FOUND');
|
||||||
const pkCol = this.getPK(schema);
|
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 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) {
|
if (this.txActive) {
|
||||||
this.txDirtyTables.add(tableName);
|
this.txDirtyTables.add(tableName);
|
||||||
// v0.7.0: 行级变更记录 —— 主键变更无法行级追踪(旧键删除+新键落盘+级联),
|
// v0.7.0: 行级变更记录 —— 主键变更无法行级追踪(旧键删除+新键落盘+级联),
|
||||||
@@ -2220,6 +2451,9 @@
|
|||||||
// ---- 动态索引 ----
|
// ---- 动态索引 ----
|
||||||
async createIndex(tableName, column, unique) {
|
async createIndex(tableName, column, unique) {
|
||||||
this.ensureOpen();
|
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);
|
await this.memory.createIndex(tableName, column, unique);
|
||||||
if (this.txActive) {
|
if (this.txActive) {
|
||||||
this.txDirtyTables.add(tableName);
|
this.txDirtyTables.add(tableName);
|
||||||
@@ -2230,6 +2464,9 @@
|
|||||||
}
|
}
|
||||||
async dropIndex(tableName, column, indexName) {
|
async dropIndex(tableName, column, indexName) {
|
||||||
this.ensureOpen();
|
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);
|
await this.memory.dropIndex(tableName, column, indexName);
|
||||||
if (this.txActive) {
|
if (this.txActive) {
|
||||||
this.txDirtyTables.add(tableName);
|
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 — 内部类型定义
|
* AriaEngine Types — 内部类型定义
|
||||||
* @module engine/aria/types
|
* @module engine/aria/types
|
||||||
@@ -6856,16 +6989,18 @@
|
|||||||
const walRecords = [];
|
const walRecords = [];
|
||||||
// v0.4.2-fix: ON UPDATE 级联环路保护
|
// v0.4.2-fix: ON UPDATE 级联环路保护
|
||||||
const visited = new Set();
|
const visited = new Set();
|
||||||
|
// v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空
|
||||||
|
const cleanUpdates = stripUndefinedUpdates(updates);
|
||||||
// v0.6.2: 唯一约束 — 批量预加载本批更新涉及的唯一列索引范围(一次 drainChain)
|
// v0.6.2: 唯一约束 — 批量预加载本批更新涉及的唯一列索引范围(一次 drainChain)
|
||||||
const uniqueCols = this.uniqueColumns(tableName, schema);
|
const uniqueCols = this.uniqueColumns(tableName, schema);
|
||||||
for (const colName of uniqueCols) {
|
for (const colName of uniqueCols) {
|
||||||
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`);
|
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`);
|
||||||
const ranges = [];
|
const ranges = [];
|
||||||
if (updates[colName] !== undefined && updates[colName] !== null) {
|
if (cleanUpdates[colName] !== undefined && cleanUpdates[colName] !== null) {
|
||||||
const p = `${String(updates[colName])}:`;
|
const p = `${String(cleanUpdates[colName])}:`;
|
||||||
ranges.push([p, `${p}\uffff`]);
|
ranges.push([p, `${p}\uffff`]);
|
||||||
}
|
}
|
||||||
else if (!(colName in updates)) {
|
else if (!(colName in cleanUpdates)) {
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const val = row[colName];
|
const val = row[colName];
|
||||||
if (val === undefined || val === null)
|
if (val === undefined || val === null)
|
||||||
@@ -6876,12 +7011,22 @@
|
|||||||
}
|
}
|
||||||
await idxLsm.prefetchPrefixRanges(ranges);
|
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) {
|
for (const row of rows) {
|
||||||
const pkCol = this.tablePKs.get(tableName);
|
const pkCol = this.tablePKs.get(tableName);
|
||||||
const key = `${tableName}:${row[pkCol]}`;
|
const key = `${tableName}:${row[pkCol]}`;
|
||||||
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
|
if (query.where && Object.keys(query.where).length > 0 && !matchWhere(row, query.where))
|
||||||
const updated = { ...row, ...updates };
|
continue;
|
||||||
|
const updated = { ...row, ...cleanUpdates };
|
||||||
this.validateRow(schema, updated);
|
this.validateRow(schema, updated);
|
||||||
|
// 批内唯一互查(索引尚未更新,两行同时改到同一新值需要互查兜底)
|
||||||
|
this.checkBatchUnique(tableName, uniqueCols, updated, batchUnique);
|
||||||
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在)
|
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在)
|
||||||
this.checkUniqueSync(tableName, uniqueCols, updated, String(row[pkCol]));
|
this.checkUniqueSync(tableName, uniqueCols, updated, String(row[pkCol]));
|
||||||
// v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录
|
// v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录
|
||||||
@@ -6898,14 +7043,24 @@
|
|||||||
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
|
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) {
|
if (pkChanged) {
|
||||||
// ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL)
|
// ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL)
|
||||||
await this.applyForeignKeyUpdateRules(tableName, String(row[pkCol]), newPk, walRecords, visited);
|
await this.applyForeignKeyUpdateRules(tableName, pk, newPk, walRecords, visited);
|
||||||
}
|
}
|
||||||
if (this.currentTxnId && this.txnSnapshot) {
|
if (this.currentTxnId && this.txnSnapshot) {
|
||||||
if (pkChanged) {
|
if (pkChanged) {
|
||||||
this.txnSnapshot.set(key, { __txn_deleted: true });
|
this.txnSnapshot.set(key, { __txn_deleted: true });
|
||||||
this.mvcc.deleteVersion(tableName, String(row[pkCol]), this.currentTxnId);
|
this.mvcc.deleteVersion(tableName, pk, this.currentTxnId);
|
||||||
}
|
}
|
||||||
this.txnSnapshot.set(`${tableName}:${newPk}`, updated);
|
this.txnSnapshot.set(`${tableName}:${newPk}`, updated);
|
||||||
this.mvcc.writeVersion(tableName, newPk, updated, this.currentTxnId);
|
this.mvcc.writeVersion(tableName, newPk, updated, this.currentTxnId);
|
||||||
@@ -6921,7 +7076,7 @@
|
|||||||
type: WALRecordType.DELETE,
|
type: WALRecordType.DELETE,
|
||||||
txnId: this.currentTxnId ?? 0,
|
txnId: this.currentTxnId ?? 0,
|
||||||
tableName,
|
tableName,
|
||||||
key: String(row[pkCol]),
|
key: pk,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
walRecords.push({
|
walRecords.push({
|
||||||
@@ -6936,13 +7091,60 @@
|
|||||||
// (唯一性检查误报 / 索引存储膨胀);现在统一传旧行清理旧值
|
// (唯一性检查误报 / 索引存储膨胀);现在统一传旧行清理旧值
|
||||||
this.updateSecondaryIndexes(tableName, newPk, updated, row);
|
this.updateSecondaryIndexes(tableName, newPk, updated, row);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
await this.wal.appendBatch(walRecords);
|
await this.wal.appendBatch(walRecords);
|
||||||
this.opCounter += count;
|
this.opCounter += count;
|
||||||
await this.checkpointManager.tick();
|
await this.checkpointManager.tick();
|
||||||
this.trimAllCaches();
|
this.trimAllCaches();
|
||||||
return count;
|
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 时处理引用表。
|
* v0.4.2-fix: ON UPDATE 外键级联 — 主键 oldPk → newPk 时处理引用表。
|
||||||
* RESTRICT 抛错 / CASCADE 更新 FK / SET NULL 置空(含索引与 WAL 记录)。
|
* RESTRICT 抛错 / CASCADE 更新 FK / SET NULL 置空(含索引与 WAL 记录)。
|
||||||
@@ -7085,6 +7287,10 @@
|
|||||||
if (colDef.onDelete === 'RESTRICT' && matched.length > 0) {
|
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');
|
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') {
|
if (colDef.onDelete === 'CASCADE') {
|
||||||
const refPkCol = this.tablePKs.get(refTableName);
|
const refPkCol = this.tablePKs.get(refTableName);
|
||||||
for (const refRow of matched) {
|
for (const refRow of matched) {
|
||||||
@@ -8257,12 +8463,22 @@
|
|||||||
// ---- 表管理 ----
|
// ---- 表管理 ----
|
||||||
async createTable(schema) {
|
async createTable(schema) {
|
||||||
await this.memoryEngine.createTable(schema);
|
await this.memoryEngine.createTable(schema);
|
||||||
|
try {
|
||||||
await this.diskEngine.createTable(schema);
|
await this.diskEngine.createTable(schema);
|
||||||
}
|
}
|
||||||
|
catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
async dropTable(tableName) {
|
async dropTable(tableName) {
|
||||||
await this.memoryEngine.dropTable(tableName);
|
await this.memoryEngine.dropTable(tableName);
|
||||||
|
try {
|
||||||
await this.diskEngine.dropTable(tableName);
|
await this.diskEngine.dropTable(tableName);
|
||||||
}
|
}
|
||||||
|
catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
async hasTable(tableName) {
|
async hasTable(tableName) {
|
||||||
return this.memoryEngine.hasTable(tableName);
|
return this.memoryEngine.hasTable(tableName);
|
||||||
}
|
}
|
||||||
@@ -8275,6 +8491,7 @@
|
|||||||
/** v0.4.2-fix: 引擎级 ALTER TABLE — 双引擎同步(磁盘持久化 + 内存引用) */
|
/** v0.4.2-fix: 引擎级 ALTER TABLE — 双引擎同步(磁盘持久化 + 内存引用) */
|
||||||
async alterTable(tableName, action, column) {
|
async alterTable(tableName, action, column) {
|
||||||
await this.memoryEngine.alterTable(tableName, action, column);
|
await this.memoryEngine.alterTable(tableName, action, column);
|
||||||
|
try {
|
||||||
if (typeof this.diskEngine.alterTable === 'function') {
|
if (typeof this.diskEngine.alterTable === 'function') {
|
||||||
await this.diskEngine.alterTable(tableName, action, column);
|
await this.diskEngine.alterTable(tableName, action, column);
|
||||||
}
|
}
|
||||||
@@ -8285,11 +8502,38 @@
|
|||||||
delete schema.columns[column.name];
|
delete schema.columns[column.name];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
// ---- CRUD(write-through 策略) ----
|
// ---- 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) {
|
async insert(tableName, rows) {
|
||||||
const pks = await this.memoryEngine.insert(tableName, rows);
|
const pks = await this.memoryEngine.insert(tableName, rows);
|
||||||
// write-through: 同步写入磁盘
|
// write-through: 同步写入磁盘
|
||||||
|
try {
|
||||||
await this.diskEngine.insert(tableName, rows);
|
await this.diskEngine.insert(tableName, rows);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
return pks;
|
return pks;
|
||||||
}
|
}
|
||||||
async find(tableName, query) {
|
async find(tableName, query) {
|
||||||
@@ -8303,13 +8547,23 @@
|
|||||||
async update(tableName, query, updates) {
|
async update(tableName, query, updates) {
|
||||||
const count = await this.memoryEngine.update(tableName, query, updates);
|
const count = await this.memoryEngine.update(tableName, query, updates);
|
||||||
// write-through: 同步更新磁盘
|
// write-through: 同步更新磁盘
|
||||||
|
try {
|
||||||
await this.diskEngine.update(tableName, query, updates);
|
await this.diskEngine.update(tableName, query, updates);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
async delete(tableName, query) {
|
async delete(tableName, query) {
|
||||||
const count = await this.memoryEngine.delete(tableName, query);
|
const count = await this.memoryEngine.delete(tableName, query);
|
||||||
// write-through: 同步删除磁盘
|
// write-through: 同步删除磁盘
|
||||||
|
try {
|
||||||
await this.diskEngine.delete(tableName, query);
|
await this.diskEngine.delete(tableName, query);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
async count(tableName, query) {
|
async count(tableName, query) {
|
||||||
@@ -8317,21 +8571,36 @@
|
|||||||
}
|
}
|
||||||
async clear(tableName) {
|
async clear(tableName) {
|
||||||
await this.memoryEngine.clear(tableName);
|
await this.memoryEngine.clear(tableName);
|
||||||
|
try {
|
||||||
await this.diskEngine.clear(tableName);
|
await this.diskEngine.clear(tableName);
|
||||||
}
|
}
|
||||||
|
catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
// ---- 动态索引(v0.3.0) ----
|
// ---- 动态索引(v0.3.0) ----
|
||||||
async createIndex(tableName, column, unique) {
|
async createIndex(tableName, column, unique) {
|
||||||
await this.memoryEngine.createIndex(tableName, column, unique);
|
await this.memoryEngine.createIndex(tableName, column, unique);
|
||||||
|
try {
|
||||||
if (typeof this.diskEngine.createIndex === 'function') {
|
if (typeof this.diskEngine.createIndex === 'function') {
|
||||||
await this.diskEngine.createIndex(tableName, column, unique);
|
await this.diskEngine.createIndex(tableName, column, unique);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
async dropIndex(tableName, column, indexName) {
|
async dropIndex(tableName, column, indexName) {
|
||||||
await this.memoryEngine.dropIndex(tableName, column, indexName);
|
await this.memoryEngine.dropIndex(tableName, column, indexName);
|
||||||
|
try {
|
||||||
if (typeof this.diskEngine.dropIndex === 'function') {
|
if (typeof this.diskEngine.dropIndex === 'function') {
|
||||||
await this.diskEngine.dropIndex(tableName, column, indexName);
|
await this.diskEngine.dropIndex(tableName, column, indexName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
// ---- 事务 ----
|
// ---- 事务 ----
|
||||||
async beginTransaction() {
|
async beginTransaction() {
|
||||||
await this.memoryEngine.beginTransaction();
|
await this.memoryEngine.beginTransaction();
|
||||||
@@ -9059,6 +9328,11 @@
|
|||||||
value += this.ch;
|
value += this.ch;
|
||||||
this.readChar();
|
this.readChar();
|
||||||
}
|
}
|
||||||
|
// v0.7.2: 未闭合字符串字面量显式报错(此前静默返回残缺 STRING token,
|
||||||
|
// 上层可解析出错误结果,如 `SELECT 'abc` 被当作合法常量列)
|
||||||
|
if (this.ch === '') {
|
||||||
|
throw new DatabaseError(`Unterminated string literal at position ${start}`, 'PARSE_ERROR');
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
type: TokenType.STRING,
|
type: TokenType.STRING,
|
||||||
value,
|
value,
|
||||||
@@ -11625,6 +11899,10 @@
|
|||||||
* 绑定在词法层面完成:仅替换字符串字面量之外的 `?`,
|
* 绑定在词法层面完成:仅替换字符串字面量之外的 `?`,
|
||||||
* 值按 SQL 字面量编码(字符串 `''` 转义、数字/布尔/JSON 直出),
|
* 值按 SQL 字面量编码(字符串 `''` 转义、数字/布尔/JSON 直出),
|
||||||
* 从根上规避 SQL 注入(不经过字符串拼接由用户自行转义)。
|
* 从根上规避 SQL 注入(不经过字符串拼接由用户自行转义)。
|
||||||
|
*
|
||||||
|
* v0.7.2: 词法扫描感知注释 —— 行注释(`--`)与块注释(slash-star 包裹)中的 `?`
|
||||||
|
* 与引号不再参与占位符识别与字符串状态机(此前注释中的 `?` 计入占位符导致
|
||||||
|
* PARAM_ERROR 错位、注释中的单引号触发 "Unterminated string literal")。
|
||||||
*/
|
*/
|
||||||
/** 将单个参数值编码为 SQL 字面量 */
|
/** 将单个参数值编码为 SQL 字面量 */
|
||||||
function encodeParam(value) {
|
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');
|
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 sql 含 `?` 占位符的 SQL
|
||||||
* @param params 位置参数数组
|
* @param params 位置参数数组
|
||||||
* @throws PARAM_ERROR 参数数量不匹配
|
* @throws PARAM_ERROR 参数数量不匹配
|
||||||
@@ -11678,6 +11956,28 @@
|
|||||||
i++;
|
i++;
|
||||||
continue;
|
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 (ch === '?') {
|
||||||
if (pIdx >= params.length) {
|
if (pIdx >= params.length) {
|
||||||
throw new DatabaseError(`Too few query parameters: placeholder #${pIdx + 1} has no value (got ${params.length} total)`, 'PARAM_ERROR');
|
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
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@metona-team/metona-sqlark",
|
"name": "@metona-team/metona-sqlark",
|
||||||
"version": "0.7.1",
|
"version": "0.7.2",
|
||||||
"description": "Frontend SQL database with in-memory and disk dual-mode storage",
|
"description": "Frontend SQL database with in-memory and disk dual-mode storage",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/metona-sqlark.cjs",
|
"main": "dist/metona-sqlark.cjs",
|
||||||
|
|||||||
+1
-1
@@ -214,4 +214,4 @@ export class DatabaseError extends Error {
|
|||||||
// 版本
|
// 版本
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const VERSION = '0.7.1';
|
export const VERSION = '0.7.2';
|
||||||
|
|||||||
+96
-12
@@ -9,7 +9,7 @@ import type { IStorageEngine } from '../interface';
|
|||||||
import type { QueryPlan, TableSchema, ColumnDef } from '../../constants';
|
import type { QueryPlan, TableSchema, ColumnDef } from '../../constants';
|
||||||
import { DatabaseError } from '../../constants';
|
import { DatabaseError } from '../../constants';
|
||||||
import { matchWhere, applyOrderBy, projectColumns } from '../../query/where-matcher';
|
import { matchWhere, applyOrderBy, projectColumns } from '../../query/where-matcher';
|
||||||
import { checkFieldType } from '../../table/schema';
|
import { checkFieldType, stripUndefinedUpdates } from '../../table/schema';
|
||||||
|
|
||||||
import type { AriaEngineConfig, SSTableMeta } from './types';
|
import type { AriaEngineConfig, SSTableMeta } from './types';
|
||||||
import { DEFAULT_ARIA_CONFIG } from './types';
|
import { DEFAULT_ARIA_CONFIG } from './types';
|
||||||
@@ -695,16 +695,18 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
const walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[] = [];
|
const walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[] = [];
|
||||||
// v0.4.2-fix: ON UPDATE 级联环路保护
|
// v0.4.2-fix: ON UPDATE 级联环路保护
|
||||||
const visited = new Set<string>();
|
const visited = new Set<string>();
|
||||||
|
// v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空
|
||||||
|
const cleanUpdates = stripUndefinedUpdates(updates);
|
||||||
|
|
||||||
// v0.6.2: 唯一约束 — 批量预加载本批更新涉及的唯一列索引范围(一次 drainChain)
|
// v0.6.2: 唯一约束 — 批量预加载本批更新涉及的唯一列索引范围(一次 drainChain)
|
||||||
const uniqueCols = this.uniqueColumns(tableName, schema);
|
const uniqueCols = this.uniqueColumns(tableName, schema);
|
||||||
for (const colName of uniqueCols) {
|
for (const colName of uniqueCols) {
|
||||||
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`)!;
|
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`)!;
|
||||||
const ranges: [string, string][] = [];
|
const ranges: [string, string][] = [];
|
||||||
if (updates[colName] !== undefined && updates[colName] !== null) {
|
if (cleanUpdates[colName] !== undefined && cleanUpdates[colName] !== null) {
|
||||||
const p = `${String(updates[colName])}:`;
|
const p = `${String(cleanUpdates[colName])}:`;
|
||||||
ranges.push([p, `${p}\uffff`]);
|
ranges.push([p, `${p}\uffff`]);
|
||||||
} else if (!(colName in updates)) {
|
} else if (!(colName in cleanUpdates)) {
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const val = row[colName];
|
const val = row[colName];
|
||||||
if (val === undefined || val === null) continue;
|
if (val === undefined || val === null) continue;
|
||||||
@@ -715,14 +717,24 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
await idxLsm.prefetchPrefixRanges(ranges);
|
await idxLsm.prefetchPrefixRanges(ranges);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.7.2: 语句级原子性 — 两阶段(先全量预检,后执行)。
|
||||||
|
// 此前逐行"校验+写入":第 N 行唯一冲突/校验失败抛错时,前 N-1 行已写入
|
||||||
|
// 且其 WAL 记录随 appendBatch 一起丢失 → 内存已改、WAL 无记录、调用方已收到错误
|
||||||
|
// (无事务下语句级部分提交 + 崩溃后进一步不一致)。
|
||||||
|
const planned: { row: Record<string, unknown>; pk: string; key: string; updated: Record<string, unknown>; newPk: string; pkChanged: boolean }[] = [];
|
||||||
|
const batchUnique: Map<string, Set<unknown>> = new Map();
|
||||||
|
|
||||||
|
// 阶段 1:全量预检(任何一行失败 → 整条语句不执行)
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const pkCol = this.tablePKs.get(tableName)!;
|
const pkCol = this.tablePKs.get(tableName)!;
|
||||||
const key = `${tableName}:${row[pkCol]}`;
|
const key = `${tableName}:${row[pkCol]}`;
|
||||||
|
if (query.where && Object.keys(query.where).length > 0 && !matchWhere(row, query.where)) continue;
|
||||||
|
|
||||||
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
|
const updated = { ...row, ...cleanUpdates };
|
||||||
const updated = { ...row, ...updates };
|
|
||||||
this.validateRow(schema, updated);
|
this.validateRow(schema, updated);
|
||||||
|
|
||||||
|
// 批内唯一互查(索引尚未更新,两行同时改到同一新值需要互查兜底)
|
||||||
|
this.checkBatchUnique(tableName, uniqueCols, updated, batchUnique);
|
||||||
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在)
|
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在)
|
||||||
this.checkUniqueSync(tableName, uniqueCols, updated, String(row[pkCol]));
|
this.checkUniqueSync(tableName, uniqueCols, updated, String(row[pkCol]));
|
||||||
|
|
||||||
@@ -745,17 +757,26 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
if (pkChanged) {
|
||||||
// ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL)
|
// ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL)
|
||||||
await this.applyForeignKeyUpdateRules(
|
await this.applyForeignKeyUpdateRules(tableName, pk, newPk, walRecords, visited);
|
||||||
tableName, String(row[pkCol]), newPk, walRecords, visited,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.currentTxnId && this.txnSnapshot) {
|
if (this.currentTxnId && this.txnSnapshot) {
|
||||||
if (pkChanged) {
|
if (pkChanged) {
|
||||||
this.txnSnapshot.set(key, { __txn_deleted: true } as unknown as Record<string, unknown>);
|
this.txnSnapshot.set(key, { __txn_deleted: true } as unknown as Record<string, unknown>);
|
||||||
this.mvcc.deleteVersion(tableName, String(row[pkCol]), this.currentTxnId);
|
this.mvcc.deleteVersion(tableName, pk, this.currentTxnId);
|
||||||
}
|
}
|
||||||
this.txnSnapshot.set(`${tableName}:${newPk}`, updated);
|
this.txnSnapshot.set(`${tableName}:${newPk}`, updated);
|
||||||
this.mvcc.writeVersion(tableName, newPk, updated, this.currentTxnId);
|
this.mvcc.writeVersion(tableName, newPk, updated, this.currentTxnId);
|
||||||
@@ -770,7 +791,7 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
type: WALRecordType.DELETE,
|
type: WALRecordType.DELETE,
|
||||||
txnId: this.currentTxnId ?? 0,
|
txnId: this.currentTxnId ?? 0,
|
||||||
tableName,
|
tableName,
|
||||||
key: String(row[pkCol]),
|
key: pk,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
walRecords.push({
|
walRecords.push({
|
||||||
@@ -786,7 +807,6 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
// (唯一性检查误报 / 索引存储膨胀);现在统一传旧行清理旧值
|
// (唯一性检查误报 / 索引存储膨胀);现在统一传旧行清理旧值
|
||||||
this.updateSecondaryIndexes(tableName, newPk, updated, row);
|
this.updateSecondaryIndexes(tableName, newPk, updated, row);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
await this.wal.appendBatch(walRecords);
|
await this.wal.appendBatch(walRecords);
|
||||||
|
|
||||||
@@ -796,6 +816,63 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.7.2: 批内唯一互查 — 两条行在同一语句中更新到同一唯一值时的兜底检查
|
||||||
|
* (阶段 1 中索引尚未反映本语句的变更)。
|
||||||
|
*/
|
||||||
|
private checkBatchUnique(
|
||||||
|
tableName: string,
|
||||||
|
uniqueCols: string[],
|
||||||
|
updated: Record<string, unknown>,
|
||||||
|
batchUnique: Map<string, Set<unknown>>,
|
||||||
|
): void {
|
||||||
|
for (const colName of uniqueCols) {
|
||||||
|
const value = updated[colName];
|
||||||
|
if (value === undefined || value === null) continue;
|
||||||
|
let seen = batchUnique.get(colName);
|
||||||
|
if (!seen) {
|
||||||
|
seen = new Set<unknown>();
|
||||||
|
batchUnique.set(colName, seen);
|
||||||
|
}
|
||||||
|
if (seen.has(value)) {
|
||||||
|
throw new DatabaseError(
|
||||||
|
`Unique constraint violation on column "${colName}" in table "${tableName}"`,
|
||||||
|
'UNIQUE_VIOLATION',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
seen.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.7.2: ON UPDATE 外键预检 — 从 applyForeignKeyUpdateRules 提取(两阶段 update 用):
|
||||||
|
* RESTRICT 存在依赖行抛错;SET NULL 撞 required 列同样整体拒绝。
|
||||||
|
*/
|
||||||
|
private async checkForeignKeyUpdateRestrict(tableName: string, oldPk: string, _newPk: string): Promise<void> {
|
||||||
|
for (const [refTableName, refSchema] of this.schemas) {
|
||||||
|
if (refTableName === tableName) continue;
|
||||||
|
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
|
||||||
|
if (!colDef.references || !colDef.onUpdate) continue;
|
||||||
|
const [refTable] = colDef.references.split('.');
|
||||||
|
if (refTable !== tableName) continue;
|
||||||
|
if (colDef.onUpdate === 'RESTRICT' || (colDef.onUpdate === 'SET NULL' && colDef.required)) {
|
||||||
|
const refRows = await this.getAllRows(refTableName);
|
||||||
|
for (const refRow of refRows) {
|
||||||
|
if (String(refRow[colName]) === oldPk) {
|
||||||
|
const reason = colDef.onUpdate === 'RESTRICT'
|
||||||
|
? `foreign key "${colName}" in "${refTableName}" has dependent rows`
|
||||||
|
: `foreign key "${colName}" in "${refTableName}" is required (SET NULL violates constraint)`;
|
||||||
|
throw new DatabaseError(
|
||||||
|
`Cannot update "${tableName}" key "${oldPk}": ${reason}`,
|
||||||
|
'FOREIGN_KEY_VIOLATION',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v0.4.2-fix: ON UPDATE 外键级联 — 主键 oldPk → newPk 时处理引用表。
|
* v0.4.2-fix: ON UPDATE 外键级联 — 主键 oldPk → newPk 时处理引用表。
|
||||||
* RESTRICT 抛错 / CASCADE 更新 FK / SET NULL 置空(含索引与 WAL 记录)。
|
* RESTRICT 抛错 / CASCADE 更新 FK / SET NULL 置空(含索引与 WAL 记录)。
|
||||||
@@ -947,6 +1024,13 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
'FOREIGN_KEY_VIOLATION',
|
'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') {
|
if (colDef.onDelete === 'CASCADE') {
|
||||||
const refPkCol = this.tablePKs.get(refTableName)!;
|
const refPkCol = this.tablePKs.get(refTableName)!;
|
||||||
for (const refRow of matched) {
|
for (const refRow of matched) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { DatabaseError } from '../constants';
|
|||||||
import { MemoryEngine } from './memory';
|
import { MemoryEngine } from './memory';
|
||||||
import { KVStore } from './kvstore/index';
|
import { KVStore } from './kvstore/index';
|
||||||
import type { IStorageBackend } from './aria/store/backend';
|
import type { IStorageBackend } from './aria/store/backend';
|
||||||
|
import { stripUndefinedUpdates } from '../table/schema';
|
||||||
|
|
||||||
const SCHEMA_KEY = '__schema';
|
const SCHEMA_KEY = '__schema';
|
||||||
const ROW_PREFIX = 't:';
|
const ROW_PREFIX = 't:';
|
||||||
@@ -229,6 +230,15 @@ export class KVStoreEngine implements IStorageEngine {
|
|||||||
column: import('../constants').ColumnDef & { name: string },
|
column: import('../constants').ColumnDef & { name: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.ensureOpen();
|
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);
|
await this.memory.alterTable(tableName, action, column);
|
||||||
if (this.txActive) {
|
if (this.txActive) {
|
||||||
this.txDirtyTables.add(tableName);
|
this.txDirtyTables.add(tableName);
|
||||||
@@ -296,11 +306,13 @@ export class KVStoreEngine implements IStorageEngine {
|
|||||||
const schema = await this.memory.getTableSchema(tableName);
|
const schema = await this.memory.getTableSchema(tableName);
|
||||||
if (!schema) throw new DatabaseError(`Table "${tableName}" does not exist`, 'TABLE_NOT_FOUND');
|
if (!schema) throw new DatabaseError(`Table "${tableName}" does not exist`, 'TABLE_NOT_FOUND');
|
||||||
const pkCol = this.getPK(schema);
|
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 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) {
|
if (this.txActive) {
|
||||||
this.txDirtyTables.add(tableName);
|
this.txDirtyTables.add(tableName);
|
||||||
// v0.7.0: 行级变更记录 —— 主键变更无法行级追踪(旧键删除+新键落盘+级联),
|
// v0.7.0: 行级变更记录 —— 主键变更无法行级追踪(旧键删除+新键落盘+级联),
|
||||||
@@ -425,6 +437,12 @@ export class KVStoreEngine implements IStorageEngine {
|
|||||||
|
|
||||||
async createIndex(tableName: string, column: string, unique?: boolean): Promise<void> {
|
async createIndex(tableName: string, column: string, unique?: boolean): Promise<void> {
|
||||||
this.ensureOpen();
|
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);
|
await this.memory.createIndex(tableName, column, unique);
|
||||||
if (this.txActive) {
|
if (this.txActive) {
|
||||||
this.txDirtyTables.add(tableName);
|
this.txDirtyTables.add(tableName);
|
||||||
@@ -436,6 +454,12 @@ export class KVStoreEngine implements IStorageEngine {
|
|||||||
|
|
||||||
async dropIndex(tableName: string, column: string, indexName?: string): Promise<void> {
|
async dropIndex(tableName: string, column: string, indexName?: string): Promise<void> {
|
||||||
this.ensureOpen();
|
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);
|
await this.memory.dropIndex(tableName, column, indexName);
|
||||||
if (this.txActive) {
|
if (this.txActive) {
|
||||||
this.txDirtyTables.add(tableName);
|
this.txDirtyTables.add(tableName);
|
||||||
|
|||||||
+138
-9
@@ -7,6 +7,7 @@ import type { IStorageEngine } from './interface';
|
|||||||
import type { QueryPlan, TableSchema } from '../constants';
|
import type { QueryPlan, TableSchema } from '../constants';
|
||||||
import { DatabaseError } from '../constants';
|
import { DatabaseError } from '../constants';
|
||||||
import { matchWhere, applyOrderBy, projectColumns } from '../query/where-matcher';
|
import { matchWhere, applyOrderBy, projectColumns } from '../query/where-matcher';
|
||||||
|
import { stripUndefinedUpdates } from '../table/schema';
|
||||||
|
|
||||||
export class MemoryEngine implements IStorageEngine {
|
export class MemoryEngine implements IStorageEngine {
|
||||||
readonly name = 'memory';
|
readonly name = 'memory';
|
||||||
@@ -96,6 +97,15 @@ export class MemoryEngine implements IStorageEngine {
|
|||||||
action: 'ADD' | 'DROP',
|
action: 'ADD' | 'DROP',
|
||||||
column: import('../constants').ColumnDef & { name: string },
|
column: import('../constants').ColumnDef & { name: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// v0.7.2: 事务内 DDL 显式拒绝(与 AriaEngine 对齐)。此前事务快照对 schema
|
||||||
|
// 是浅拷贝,alterTable 直接修改共享 columns 对象 → ROLLBACK 后结构变更残留
|
||||||
|
// (三引擎行为不一致:Aria 拒绝 / Memory、KVStore 静默残留)
|
||||||
|
if (this.snapshot) {
|
||||||
|
throw new DatabaseError(
|
||||||
|
`ALTER TABLE is not supported inside a transaction (MemoryEngine DDL is not transactional)`,
|
||||||
|
'NOT_SUPPORTED',
|
||||||
|
);
|
||||||
|
}
|
||||||
this.ensureTable(tableName);
|
this.ensureTable(tableName);
|
||||||
const schema = this.schemas.get(tableName)!;
|
const schema = this.schemas.get(tableName)!;
|
||||||
if (action === 'ADD') {
|
if (action === 'ADD') {
|
||||||
@@ -184,15 +194,21 @@ export class MemoryEngine implements IStorageEngine {
|
|||||||
const schema = this.schemas.get(tableName)!;
|
const schema = this.schemas.get(tableName)!;
|
||||||
const table = this.tables.get(tableName)!;
|
const table = this.tables.get(tableName)!;
|
||||||
const pkCol = this.getPrimaryKey(schema);
|
const pkCol = this.getPrimaryKey(schema);
|
||||||
let count = 0;
|
// v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空
|
||||||
// v0.4.2-fix: 迭代期间会 delete/set 同一 Map(主键变更)→ 拷贝快照避免跳过/重复
|
const cleanUpdates = stripUndefinedUpdates(updates);
|
||||||
for (const [pk, row] of [...table]) {
|
|
||||||
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
|
// v0.7.2: 语句级原子性 — 两阶段(先全量预检,后执行)。
|
||||||
// v0.3.3: 先移除旧值索引条目(修复 update 后唯一约束被绕过、按新值查索引丢行)
|
// 此前逐行"校验+写入":第 N 行唯一冲突/校验失败抛错时,前 N-1 行已写入
|
||||||
this.removeIndexEntries(tableName, row, pk);
|
// → 无事务下语句级部分提交(数据半更新且调用方已收到错误)。
|
||||||
const updated = { ...row, ...updates };
|
const planned: { pk: string; row: Record<string, unknown>; updated: Record<string, unknown>; newPk: string }[] = [];
|
||||||
|
const batchUnique: Map<string, Set<unknown>> = new Map();
|
||||||
|
|
||||||
|
// 阶段 1:全量预检(任何一行失败 → 整条语句不执行)
|
||||||
|
for (const [pk, row] of table) {
|
||||||
|
if (query.where && Object.keys(query.where).length > 0 && !matchWhere(row, query.where)) continue;
|
||||||
|
const updated = { ...row, ...cleanUpdates };
|
||||||
this.validateRow(schema, updated);
|
this.validateRow(schema, updated);
|
||||||
this.checkUniqueness(schema, updated);
|
this.checkUpdateUniqueness(schema, tableName, pk, updated, batchUnique);
|
||||||
const newPk = String(updated[pkCol]);
|
const newPk = String(updated[pkCol]);
|
||||||
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据)
|
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据)
|
||||||
if (newPk !== pk && table.has(newPk)) {
|
if (newPk !== pk && table.has(newPk)) {
|
||||||
@@ -201,6 +217,18 @@ export class MemoryEngine implements IStorageEngine {
|
|||||||
'DUPLICATE_KEY',
|
'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: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
|
// v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
|
||||||
if (newPk !== pk) {
|
if (newPk !== pk) {
|
||||||
await this.applyUpdateCascade(tableName, pk, newPk);
|
await this.applyUpdateCascade(tableName, pk, newPk);
|
||||||
@@ -210,10 +238,89 @@ export class MemoryEngine implements IStorageEngine {
|
|||||||
this.updateIndexes(tableName, updated, newPk);
|
this.updateIndexes(tableName, updated, newPk);
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.7.2: 更新唯一性预检 — 批内互查(多条行更新到同一唯一值)+ 索引查
|
||||||
|
* (排除自身旧条目)。阶段 1 中索引尚未更新,批内互查避免"两行同时改到
|
||||||
|
* 同一新值"绕过唯一约束。
|
||||||
|
*/
|
||||||
|
private checkUpdateUniqueness(
|
||||||
|
schema: TableSchema,
|
||||||
|
tableName: string,
|
||||||
|
pk: string,
|
||||||
|
updated: Record<string, unknown>,
|
||||||
|
batchUnique: Map<string, Set<unknown>>,
|
||||||
|
): void {
|
||||||
|
const tableIndexes = this.indexes.get(tableName);
|
||||||
|
for (const [colName, colDef] of Object.entries(schema.columns)) {
|
||||||
|
if (!colDef.unique) continue;
|
||||||
|
const value = updated[colName];
|
||||||
|
if (value === undefined || value === null) continue;
|
||||||
|
let seen = batchUnique.get(colName);
|
||||||
|
if (!seen) {
|
||||||
|
seen = new Set<unknown>();
|
||||||
|
batchUnique.set(colName, seen);
|
||||||
|
}
|
||||||
|
if (seen.has(value)) {
|
||||||
|
throw new DatabaseError(
|
||||||
|
`Unique constraint violation on column "${colName}" in table "${schema.name}"`,
|
||||||
|
'UNIQUE_VIOLATION',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
seen.add(value);
|
||||||
|
if (!tableIndexes) continue;
|
||||||
|
const colIndex = tableIndexes.get(colName);
|
||||||
|
if (colIndex && colIndex.has(value)) {
|
||||||
|
const pks = colIndex.get(value)!;
|
||||||
|
// 值未变(新值 = 旧值)且索引中只有自身 → 允许
|
||||||
|
if (!(pks.size === 1 && pks.has(pk))) {
|
||||||
|
throw new DatabaseError(
|
||||||
|
`Unique constraint violation on column "${colName}" in table "${schema.name}"`,
|
||||||
|
'UNIQUE_VIOLATION',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.7.2: ON UPDATE RESTRICT 预检 — 从 applyUpdateCascade 提取,
|
||||||
|
* 两阶段 update 在任何修改前调用(整体拒绝语义)。
|
||||||
|
*/
|
||||||
|
private checkUpdateRestrict(tableName: string, oldPk: string): void {
|
||||||
|
for (const [refTableName, refSchema] of this.schemas) {
|
||||||
|
if (refTableName === tableName) continue;
|
||||||
|
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
|
||||||
|
if (!colDef.references || !colDef.onUpdate) continue;
|
||||||
|
const [refTable] = colDef.references.split('.');
|
||||||
|
if (refTable !== tableName) continue;
|
||||||
|
const refTableData = this.tables.get(refTableName);
|
||||||
|
if (!refTableData) continue;
|
||||||
|
let hasDependents = false;
|
||||||
|
for (const [, refRow] of refTableData) {
|
||||||
|
if (String(refRow[colName]) !== oldPk) continue;
|
||||||
|
hasDependents = true;
|
||||||
|
if (colDef.onUpdate === 'RESTRICT') {
|
||||||
|
throw new DatabaseError(
|
||||||
|
`Cannot update "${tableName}" key "${oldPk}": foreign key "${colName}" in "${refTableName}" has dependent rows`,
|
||||||
|
'FOREIGN_KEY_VIOLATION',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// v0.7.2: SET NULL 到 required 列违反约束 —— 与 RESTRICT 同样整体拒绝
|
||||||
|
// (此前级联直写 null 绕过 validateRow,required 列被静默置空)
|
||||||
|
if (hasDependents && colDef.onUpdate === 'SET NULL' && colDef.required) {
|
||||||
|
throw new DatabaseError(
|
||||||
|
`Cannot update "${tableName}" key "${oldPk}": foreign key "${colName}" in "${refTableName}" is required (SET NULL violates constraint)`,
|
||||||
|
'FOREIGN_KEY_VIOLATION',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v0.4.2-fix: ON UPDATE 外键级联 — 被引用表主键变更时处理引用表:
|
* v0.4.2-fix: ON UPDATE 外键级联 — 被引用表主键变更时处理引用表:
|
||||||
* RESTRICT 抛错 / CASCADE 更新 FK 值 / SET NULL 置空。
|
* RESTRICT 抛错 / CASCADE 更新 FK 值 / SET NULL 置空。
|
||||||
@@ -315,6 +422,13 @@ export class MemoryEngine implements IStorageEngine {
|
|||||||
'FOREIGN_KEY_VIOLATION',
|
'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') {
|
if (colDef.onDelete === 'CASCADE') {
|
||||||
for (const refPk of refPks) {
|
for (const refPk of refPks) {
|
||||||
this.checkCascadeRestrict(refTableName, refPk, visited);
|
this.checkCascadeRestrict(refTableName, refPk, visited);
|
||||||
@@ -343,6 +457,14 @@ export class MemoryEngine implements IStorageEngine {
|
|||||||
// ---- 动态索引(v0.3.0) ----
|
// ---- 动态索引(v0.3.0) ----
|
||||||
|
|
||||||
async createIndex(tableName: string, column: string, unique?: boolean): Promise<void> {
|
async createIndex(tableName: string, column: string, unique?: boolean): Promise<void> {
|
||||||
|
// v0.7.2: 事务内修改列级标志(colDef.index/unique)会写入共享列对象,
|
||||||
|
// 事务快照无法回滚 → 与 alterTable 同样显式拒绝
|
||||||
|
if (this.snapshot) {
|
||||||
|
throw new DatabaseError(
|
||||||
|
`CREATE INDEX is not supported inside a transaction (MemoryEngine DDL is not transactional)`,
|
||||||
|
'NOT_SUPPORTED',
|
||||||
|
);
|
||||||
|
}
|
||||||
this.ensureTable(tableName);
|
this.ensureTable(tableName);
|
||||||
const schema = this.schemas.get(tableName)!;
|
const schema = this.schemas.get(tableName)!;
|
||||||
const colDef = schema.columns[column];
|
const colDef = schema.columns[column];
|
||||||
@@ -365,6 +487,13 @@ export class MemoryEngine implements IStorageEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async dropIndex(tableName: string, column: string, _indexName?: string): Promise<void> {
|
async dropIndex(tableName: string, column: string, _indexName?: string): Promise<void> {
|
||||||
|
// v0.7.2: 同 createIndex —— 列级标志修改无法通过事务快照回滚,显式拒绝
|
||||||
|
if (this.snapshot) {
|
||||||
|
throw new DatabaseError(
|
||||||
|
`DROP INDEX is not supported inside a transaction (MemoryEngine DDL is not transactional)`,
|
||||||
|
'NOT_SUPPORTED',
|
||||||
|
);
|
||||||
|
}
|
||||||
this.ensureTable(tableName);
|
this.ensureTable(tableName);
|
||||||
const schema = this.schemas.get(tableName)!;
|
const schema = this.schemas.get(tableName)!;
|
||||||
const colDef = schema.columns[column];
|
const colDef = schema.columns[column];
|
||||||
|
|||||||
@@ -140,12 +140,20 @@ export class HybridEngine implements IStorageEngine {
|
|||||||
|
|
||||||
async createTable(schema: TableSchema): Promise<void> {
|
async createTable(schema: TableSchema): Promise<void> {
|
||||||
await this.memoryEngine.createTable(schema);
|
await this.memoryEngine.createTable(schema);
|
||||||
|
try {
|
||||||
await this.diskEngine.createTable(schema);
|
await this.diskEngine.createTable(schema);
|
||||||
|
} catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async dropTable(tableName: string): Promise<void> {
|
async dropTable(tableName: string): Promise<void> {
|
||||||
await this.memoryEngine.dropTable(tableName);
|
await this.memoryEngine.dropTable(tableName);
|
||||||
|
try {
|
||||||
await this.diskEngine.dropTable(tableName);
|
await this.diskEngine.dropTable(tableName);
|
||||||
|
} catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async hasTable(tableName: string): Promise<boolean> {
|
async hasTable(tableName: string): Promise<boolean> {
|
||||||
@@ -167,6 +175,7 @@ export class HybridEngine implements IStorageEngine {
|
|||||||
column: import('../constants').ColumnDef & { name: string },
|
column: import('../constants').ColumnDef & { name: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.memoryEngine.alterTable(tableName, action, column);
|
await this.memoryEngine.alterTable(tableName, action, column);
|
||||||
|
try {
|
||||||
if (typeof this.diskEngine.alterTable === 'function') {
|
if (typeof this.diskEngine.alterTable === 'function') {
|
||||||
await this.diskEngine.alterTable(tableName, action, column);
|
await this.diskEngine.alterTable(tableName, action, column);
|
||||||
} else {
|
} else {
|
||||||
@@ -174,14 +183,39 @@ export class HybridEngine implements IStorageEngine {
|
|||||||
const schema = await this.diskEngine.getTableSchema(tableName);
|
const schema = await this.diskEngine.getTableSchema(tableName);
|
||||||
if (schema && action === 'DROP') delete schema.columns[column.name];
|
if (schema && action === 'DROP') delete schema.columns[column.name];
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- CRUD(write-through 策略) ----
|
// ---- CRUD(write-through 策略) ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.7.2: 磁盘写失败补偿 — 内存已先行写入、磁盘失败 → 内存与磁盘不一致
|
||||||
|
* (重启后数据丢失且调用方已收到错误)。从磁盘重载内存对齐真实状态
|
||||||
|
* (内存=磁盘),再重新抛出原始错误。事务路径由双引擎快照回滚保证,
|
||||||
|
* 无需此补偿。
|
||||||
|
*/
|
||||||
|
private async recoverMemoryAfterDiskError(error: unknown): Promise<never> {
|
||||||
|
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: string, rows: Record<string, unknown>[]): Promise<string[]> {
|
async insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]> {
|
||||||
const pks = await this.memoryEngine.insert(tableName, rows);
|
const pks = await this.memoryEngine.insert(tableName, rows);
|
||||||
// write-through: 同步写入磁盘
|
// write-through: 同步写入磁盘
|
||||||
|
try {
|
||||||
await this.diskEngine.insert(tableName, rows);
|
await this.diskEngine.insert(tableName, rows);
|
||||||
|
} catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
return pks;
|
return pks;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,14 +232,22 @@ export class HybridEngine implements IStorageEngine {
|
|||||||
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
|
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
|
||||||
const count = await this.memoryEngine.update(tableName, query, updates);
|
const count = await this.memoryEngine.update(tableName, query, updates);
|
||||||
// write-through: 同步更新磁盘
|
// write-through: 同步更新磁盘
|
||||||
|
try {
|
||||||
await this.diskEngine.update(tableName, query, updates);
|
await this.diskEngine.update(tableName, query, updates);
|
||||||
|
} catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(tableName: string, query: QueryPlan): Promise<number> {
|
async delete(tableName: string, query: QueryPlan): Promise<number> {
|
||||||
const count = await this.memoryEngine.delete(tableName, query);
|
const count = await this.memoryEngine.delete(tableName, query);
|
||||||
// write-through: 同步删除磁盘
|
// write-through: 同步删除磁盘
|
||||||
|
try {
|
||||||
await this.diskEngine.delete(tableName, query);
|
await this.diskEngine.delete(tableName, query);
|
||||||
|
} catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,23 +257,35 @@ export class HybridEngine implements IStorageEngine {
|
|||||||
|
|
||||||
async clear(tableName: string): Promise<void> {
|
async clear(tableName: string): Promise<void> {
|
||||||
await this.memoryEngine.clear(tableName);
|
await this.memoryEngine.clear(tableName);
|
||||||
|
try {
|
||||||
await this.diskEngine.clear(tableName);
|
await this.diskEngine.clear(tableName);
|
||||||
|
} catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 动态索引(v0.3.0) ----
|
// ---- 动态索引(v0.3.0) ----
|
||||||
|
|
||||||
async createIndex(tableName: string, column: string, unique?: boolean): Promise<void> {
|
async createIndex(tableName: string, column: string, unique?: boolean): Promise<void> {
|
||||||
await this.memoryEngine.createIndex(tableName, column, unique);
|
await this.memoryEngine.createIndex(tableName, column, unique);
|
||||||
|
try {
|
||||||
if (typeof this.diskEngine.createIndex === 'function') {
|
if (typeof this.diskEngine.createIndex === 'function') {
|
||||||
await this.diskEngine.createIndex(tableName, column, unique);
|
await this.diskEngine.createIndex(tableName, column, unique);
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async dropIndex(tableName: string, column: string, indexName?: string): Promise<void> {
|
async dropIndex(tableName: string, column: string, indexName?: string): Promise<void> {
|
||||||
await this.memoryEngine.dropIndex(tableName, column, indexName);
|
await this.memoryEngine.dropIndex(tableName, column, indexName);
|
||||||
|
try {
|
||||||
if (typeof this.diskEngine.dropIndex === 'function') {
|
if (typeof this.diskEngine.dropIndex === 'function') {
|
||||||
await this.diskEngine.dropIndex(tableName, column, indexName);
|
await this.diskEngine.dropIndex(tableName, column, indexName);
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await this.recoverMemoryAfterDiskError(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 事务 ----
|
// ---- 事务 ----
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { WhereCondition, OrderBy } from '../constants';
|
import type { WhereCondition, OrderBy } from '../constants';
|
||||||
|
import { DatabaseError } from '../constants';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// LIKE 正则缓存
|
// LIKE 正则缓存
|
||||||
@@ -139,7 +140,10 @@ function matchOperator(value: unknown, op: string, operand: unknown): boolean {
|
|||||||
case '$in': return Array.isArray(operand) && operand.includes(value);
|
case '$in': return Array.isArray(operand) && operand.includes(value);
|
||||||
case '$nin': 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));
|
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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { TokenType, type Token, KEYWORDS } from './tokens';
|
import { TokenType, type Token, KEYWORDS } from './tokens';
|
||||||
|
import { DatabaseError } from '../constants';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Lexer
|
// Lexer
|
||||||
@@ -217,6 +218,15 @@ export class Lexer {
|
|||||||
this.readChar();
|
this.readChar();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.7.2: 未闭合字符串字面量显式报错(此前静默返回残缺 STRING token,
|
||||||
|
// 上层可解析出错误结果,如 `SELECT 'abc` 被当作合法常量列)
|
||||||
|
if (this.ch === '') {
|
||||||
|
throw new DatabaseError(
|
||||||
|
`Unterminated string literal at position ${start}`,
|
||||||
|
'PARSE_ERROR',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: TokenType.STRING,
|
type: TokenType.STRING,
|
||||||
value,
|
value,
|
||||||
|
|||||||
+29
-1
@@ -6,6 +6,10 @@
|
|||||||
* 绑定在词法层面完成:仅替换字符串字面量之外的 `?`,
|
* 绑定在词法层面完成:仅替换字符串字面量之外的 `?`,
|
||||||
* 值按 SQL 字面量编码(字符串 `''` 转义、数字/布尔/JSON 直出),
|
* 值按 SQL 字面量编码(字符串 `''` 转义、数字/布尔/JSON 直出),
|
||||||
* 从根上规避 SQL 注入(不经过字符串拼接由用户自行转义)。
|
* 从根上规避 SQL 注入(不经过字符串拼接由用户自行转义)。
|
||||||
|
*
|
||||||
|
* v0.7.2: 词法扫描感知注释 —— 行注释(`--`)与块注释(slash-star 包裹)中的 `?`
|
||||||
|
* 与引号不再参与占位符识别与字符串状态机(此前注释中的 `?` 计入占位符导致
|
||||||
|
* PARAM_ERROR 错位、注释中的单引号触发 "Unterminated string literal")。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { DatabaseError } from '../constants';
|
import { DatabaseError } from '../constants';
|
||||||
@@ -27,7 +31,7 @@ function encodeParam(value: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将 SQL 中的位置参数 `?`(字符串字面量之外)替换为编码后的字面量。
|
* 将 SQL 中的位置参数 `?`(字符串字面量与注释之外)替换为编码后的字面量。
|
||||||
* @param sql 含 `?` 占位符的 SQL
|
* @param sql 含 `?` 占位符的 SQL
|
||||||
* @param params 位置参数数组
|
* @param params 位置参数数组
|
||||||
* @throws PARAM_ERROR 参数数量不匹配
|
* @throws PARAM_ERROR 参数数量不匹配
|
||||||
@@ -66,6 +70,30 @@ export function bindParameters(sql: string, params?: unknown[]): string {
|
|||||||
continue;
|
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 (ch === '?') {
|
||||||
if (pIdx >= params.length) {
|
if (pIdx >= params.length) {
|
||||||
throw new DatabaseError(
|
throw new DatabaseError(
|
||||||
|
|||||||
@@ -70,6 +70,19 @@ export function getPrimaryKey(schema: TableSchema): string {
|
|||||||
return Object.keys(schema.columns)[0];
|
return Object.keys(schema.columns)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.7.2: 更新载荷清洗 —— undefined 值视为"不更新该列"(保留旧值)。
|
||||||
|
* 此前 `update({ col: undefined })` 会把 undefined 写入行(覆盖旧值、列键丢失)。
|
||||||
|
* null 保留(显式置空语义)。
|
||||||
|
*/
|
||||||
|
export function stripUndefinedUpdates(updates: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const clean: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(updates)) {
|
||||||
|
if (value !== undefined) clean[key] = value;
|
||||||
|
}
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
/** 校验行数据 */
|
/** 校验行数据 */
|
||||||
export function validateRow(schema: TableSchema, row: Record<string, unknown>): Record<string, unknown> {
|
export function validateRow(schema: TableSchema, row: Record<string, unknown>): Record<string, unknown> {
|
||||||
const validated: Record<string, unknown> = {};
|
const validated: Record<string, unknown> = {};
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ describe('AriaEngine — 二级索引完整性(P0 回归)', () => {
|
|||||||
expect(viaIdx.length).toBe(5000);
|
expect(viaIdx.length).toBe(5000);
|
||||||
}
|
}
|
||||||
await engine2.close();
|
await engine2.close();
|
||||||
}, 180000);
|
}, 600000);
|
||||||
|
|
||||||
it('小批量高频写入(每批 50 行)触发极端 freeze 竞态', async () => {
|
it('小批量高频写入(每批 50 行)触发极端 freeze 竞态', async () => {
|
||||||
const engine = new AriaEngine({
|
const engine = new AriaEngine({
|
||||||
@@ -89,7 +89,7 @@ describe('AriaEngine — 二级索引完整性(P0 回归)', () => {
|
|||||||
expect(await engine.find('items', { table: 'items', where: { tag: 'a' } })).toHaveLength(Math.ceil(TOTAL / 3));
|
expect(await engine.find('items', { table: 'items', where: { tag: 'a' } })).toHaveLength(Math.ceil(TOTAL / 3));
|
||||||
expect(await engine.find('items', { table: 'items', where: { tag: 'b' } })).toHaveLength(TOTAL - Math.ceil(TOTAL / 3));
|
expect(await engine.find('items', { table: 'items', where: { tag: 'b' } })).toHaveLength(TOTAL - Math.ceil(TOTAL / 3));
|
||||||
await engine.close();
|
await engine.close();
|
||||||
}, 120000);
|
}, 600000);
|
||||||
|
|
||||||
it('多索引列同时写入:每列索引都完整', async () => {
|
it('多索引列同时写入:每列索引都完整', async () => {
|
||||||
const engine = new AriaEngine({
|
const engine = new AriaEngine({
|
||||||
@@ -119,5 +119,5 @@ describe('AriaEngine — 二级索引完整性(P0 回归)', () => {
|
|||||||
expect(await engine.find('multi', { table: 'multi', where: { grp: 0 } })).toHaveLength(TOTAL / 5);
|
expect(await engine.find('multi', { table: 'multi', where: { grp: 0 } })).toHaveLength(TOTAL / 5);
|
||||||
expect(await engine.find('multi', { table: 'multi', where: { grp: 4 } })).toHaveLength(TOTAL / 5);
|
expect(await engine.find('multi', { table: 'multi', where: { grp: 4 } })).toHaveLength(TOTAL / 5);
|
||||||
await engine.close();
|
await engine.close();
|
||||||
}, 120000);
|
}, 600000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ describe('AriaEngine + kv 后端(storageBackend: kv)', () => {
|
|||||||
await e2.open(dbName, 1);
|
await e2.open(dbName, 1);
|
||||||
expect(await e2.count('big')).toBe(1000);
|
expect(await e2.count('big')).toBe(1000);
|
||||||
await e2.close();
|
await e2.close();
|
||||||
}, 60000);
|
}, 300000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ describe('生产矩阵审计 — 后端 × 核心功能', () => {
|
|||||||
await assertIndexes(engine2, 'big', 30000);
|
await assertIndexes(engine2, 'big', 30000);
|
||||||
expect((await engine2.find('big', { table: 'big', where: { id: 'k50' } }))[0].name).toBe('Tx50');
|
expect((await engine2.find('big', { table: 'big', where: { id: 'k50' } }))[0].name).toBe('Tx50');
|
||||||
await engine2.close();
|
await engine2.close();
|
||||||
}, 180000);
|
}, 600000);
|
||||||
|
|
||||||
it('opfs × 3 万行 + 双索引 + 崩溃恢复', async () => {
|
it('opfs × 3 万行 + 双索引 + 崩溃恢复', async () => {
|
||||||
const dbName = uniqueDB();
|
const dbName = uniqueDB();
|
||||||
@@ -110,7 +110,7 @@ describe('生产矩阵审计 — 后端 × 核心功能', () => {
|
|||||||
const engine2 = await reopen(dbName, coreConfig('opfs'));
|
const engine2 = await reopen(dbName, coreConfig('opfs'));
|
||||||
await assertIndexes(engine2, 'big', 30000);
|
await assertIndexes(engine2, 'big', 30000);
|
||||||
await engine2.close();
|
await engine2.close();
|
||||||
}, 180000);
|
}, 600000);
|
||||||
|
|
||||||
it('memory × 2 万行 + 双索引 + 事务回滚', async () => {
|
it('memory × 2 万行 + 双索引 + 事务回滚', async () => {
|
||||||
const engine = new AriaEngine(coreConfig('memory') as never);
|
const engine = new AriaEngine(coreConfig('memory') as never);
|
||||||
@@ -126,7 +126,7 @@ describe('生产矩阵审计 — 后端 × 核心功能', () => {
|
|||||||
expect(await engine.find('big', { table: 'big', where: { tag: 't9' } })).toHaveLength(2001);
|
expect(await engine.find('big', { table: 'big', where: { tag: 't9' } })).toHaveLength(2001);
|
||||||
expect(await engine.find('big', { table: 'big', where: { tag: 't7' } })).toHaveLength(1999);
|
expect(await engine.find('big', { table: 'big', where: { tag: 't7' } })).toHaveLength(1999);
|
||||||
await engine.close();
|
await engine.close();
|
||||||
}, 120000);
|
}, 600000);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('生产矩阵审计 — 特性组合', () => {
|
describe('生产矩阵审计 — 特性组合', () => {
|
||||||
@@ -149,7 +149,7 @@ describe('生产矩阵审计 — 特性组合', () => {
|
|||||||
// 错误密码必须拒绝打开
|
// 错误密码必须拒绝打开
|
||||||
const bad = new AriaEngine({ ...cfg, encryption: { password: 'wrong' } } as never);
|
const bad = new AriaEngine({ ...cfg, encryption: { password: 'wrong' } } as never);
|
||||||
await expect(bad.open(dbName, 1)).rejects.toThrow();
|
await expect(bad.open(dbName, 1)).rejects.toThrow();
|
||||||
}, 180000);
|
}, 600000);
|
||||||
|
|
||||||
it('kv × pageStorage:false(整 value)→ 2 万行 + 崩溃恢复', async () => {
|
it('kv × pageStorage:false(整 value)→ 2 万行 + 崩溃恢复', async () => {
|
||||||
const dbName = uniqueDB();
|
const dbName = uniqueDB();
|
||||||
@@ -167,7 +167,7 @@ describe('生产矩阵审计 — 特性组合', () => {
|
|||||||
const engine2 = await reopen(dbName, cfg);
|
const engine2 = await reopen(dbName, cfg);
|
||||||
await assertIndexes(engine2, 'big', 20000);
|
await assertIndexes(engine2, 'big', 20000);
|
||||||
await engine2.close();
|
await engine2.close();
|
||||||
}, 180000);
|
}, 600000);
|
||||||
|
|
||||||
it('opfs × 加密 × 压缩 × 页面化全开 → 2 万行 + 崩溃恢复', async () => {
|
it('opfs × 加密 × 压缩 × 页面化全开 → 2 万行 + 崩溃恢复', async () => {
|
||||||
const dbName = uniqueDB();
|
const dbName = uniqueDB();
|
||||||
@@ -186,7 +186,7 @@ describe('生产矩阵审计 — 特性组合', () => {
|
|||||||
const engine2 = await reopen(dbName, cfg);
|
const engine2 = await reopen(dbName, cfg);
|
||||||
await assertIndexes(engine2, 'big', 20000);
|
await assertIndexes(engine2, 'big', 20000);
|
||||||
await engine2.close();
|
await engine2.close();
|
||||||
}, 180000);
|
}, 600000);
|
||||||
|
|
||||||
it('opfs × walEnabled:false → 写入 + 优雅关闭后重开完整', async () => {
|
it('opfs × walEnabled:false → 写入 + 优雅关闭后重开完整', async () => {
|
||||||
const dbName = uniqueDB();
|
const dbName = uniqueDB();
|
||||||
@@ -204,7 +204,7 @@ describe('生产矩阵审计 — 特性组合', () => {
|
|||||||
const engine2 = await reopen(dbName, cfg);
|
const engine2 = await reopen(dbName, cfg);
|
||||||
await assertIndexes(engine2, 'big', 20000);
|
await assertIndexes(engine2, 'big', 20000);
|
||||||
await engine2.close();
|
await engine2.close();
|
||||||
}, 180000);
|
}, 600000);
|
||||||
|
|
||||||
it('kv × walEnabled:false → 写入 + 优雅关闭后重开完整', async () => {
|
it('kv × walEnabled:false → 写入 + 优雅关闭后重开完整', async () => {
|
||||||
const dbName = uniqueDB();
|
const dbName = uniqueDB();
|
||||||
@@ -221,7 +221,7 @@ describe('生产矩阵审计 — 特性组合', () => {
|
|||||||
const engine2 = await reopen(dbName, cfg);
|
const engine2 = await reopen(dbName, cfg);
|
||||||
await assertIndexes(engine2, 'big', 20000);
|
await assertIndexes(engine2, 'big', 20000);
|
||||||
await engine2.close();
|
await engine2.close();
|
||||||
}, 180000);
|
}, 600000);
|
||||||
|
|
||||||
it('kv × walSyncMode:batch → 优雅关闭后重开完整(崩溃保底已 checkpoint 数据)', async () => {
|
it('kv × walSyncMode:batch → 优雅关闭后重开完整(崩溃保底已 checkpoint 数据)', async () => {
|
||||||
const dbName = uniqueDB();
|
const dbName = uniqueDB();
|
||||||
@@ -238,7 +238,7 @@ describe('生产矩阵审计 — 特性组合', () => {
|
|||||||
const engine2 = await reopen(dbName, cfg);
|
const engine2 = await reopen(dbName, cfg);
|
||||||
await assertIndexes(engine2, 'big', 20000);
|
await assertIndexes(engine2, 'big', 20000);
|
||||||
await engine2.close();
|
await engine2.close();
|
||||||
}, 180000);
|
}, 600000);
|
||||||
|
|
||||||
it('opfs × walSyncMode:none → 优雅关闭后重开完整(checkpoint 兜底)', async () => {
|
it('opfs × walSyncMode:none → 优雅关闭后重开完整(checkpoint 兜底)', async () => {
|
||||||
const dbName = uniqueDB();
|
const dbName = uniqueDB();
|
||||||
@@ -255,7 +255,7 @@ describe('生产矩阵审计 — 特性组合', () => {
|
|||||||
const engine2 = await reopen(dbName, cfg);
|
const engine2 = await reopen(dbName, cfg);
|
||||||
await assertIndexes(engine2, 'big', 20000);
|
await assertIndexes(engine2, 'big', 20000);
|
||||||
await engine2.close();
|
await engine2.close();
|
||||||
}, 180000);
|
}, 600000);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('生产矩阵审计 — 主键变更索引一致性(三后端)', () => {
|
describe('生产矩阵审计 — 主键变更索引一致性(三后端)', () => {
|
||||||
@@ -270,7 +270,7 @@ describe('生产矩阵审计 — 主键变更索引一致性(三后端)', ()
|
|||||||
expect(await engine.find('big', { table: 'big', where: { tag: 'x' } })).toHaveLength(1);
|
expect(await engine.find('big', { table: 'big', where: { tag: 'x' } })).toHaveLength(1);
|
||||||
expect(await engine.find('big', { table: 'big', where: { id: 'a1' } })).toHaveLength(0);
|
expect(await engine.find('big', { table: 'big', where: { id: 'a1' } })).toHaveLength(0);
|
||||||
await engine.close();
|
await engine.close();
|
||||||
}, 60000);
|
}, 300000);
|
||||||
|
|
||||||
it('opfs × 主键变更 + 崩溃恢复:索引一致', async () => {
|
it('opfs × 主键变更 + 崩溃恢复:索引一致', async () => {
|
||||||
const dbName = uniqueDB();
|
const dbName = uniqueDB();
|
||||||
@@ -292,7 +292,7 @@ describe('生产矩阵审计 — 主键变更索引一致性(三后端)', ()
|
|||||||
expect(await engine2.find('big', { table: 'big', where: { tag: `t${t}` } })).toHaveLength(500);
|
expect(await engine2.find('big', { table: 'big', where: { tag: `t${t}` } })).toHaveLength(500);
|
||||||
}
|
}
|
||||||
await engine2.close();
|
await engine2.close();
|
||||||
}, 120000);
|
}, 600000);
|
||||||
|
|
||||||
it('memory × 级联删除 + 索引清理', async () => {
|
it('memory × 级联删除 + 索引清理', async () => {
|
||||||
const engine = new AriaEngine({ storageBackend: 'memory', memtableSizeThreshold: 256 * 1024 } as never);
|
const engine = new AriaEngine({ storageBackend: 'memory', memtableSizeThreshold: 256 * 1024 } as never);
|
||||||
@@ -313,5 +313,5 @@ describe('生产矩阵审计 — 主键变更索引一致性(三后端)', ()
|
|||||||
expect(await engine.count('child')).toBe(1999);
|
expect(await engine.count('child')).toBe(1999);
|
||||||
expect(await engine.find('child', { table: 'child', where: { pid: 'p5' } })).toHaveLength(0);
|
expect(await engine.find('child', { table: 'child', where: { pid: 'p5' } })).toHaveLength(0);
|
||||||
await engine.close();
|
await engine.close();
|
||||||
}, 60000);
|
}, 300000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ describe('AriaEngine — 生产负载验证', () => {
|
|||||||
// 索引(重启重建)
|
// 索引(重启重建)
|
||||||
expect(await engine2.find('big', { table: 'big', where: { tag: 't5' } })).toHaveLength(5000);
|
expect(await engine2.find('big', { table: 'big', where: { tag: 't5' } })).toHaveLength(5000);
|
||||||
await engine2.close();
|
await engine2.close();
|
||||||
}, 180000);
|
}, 600000);
|
||||||
|
|
||||||
it('高频更新/删除(Compaction 回收墓碑)→ 重启后一致', async () => {
|
it('高频更新/删除(Compaction 回收墓碑)→ 重启后一致', async () => {
|
||||||
const dbName = uniqueDB();
|
const dbName = uniqueDB();
|
||||||
@@ -127,7 +127,7 @@ describe('AriaEngine — 生产负载验证', () => {
|
|||||||
await engine2.open(dbName, 1);
|
await engine2.open(dbName, 1);
|
||||||
expect(await engine2.count('big')).toBe(count);
|
expect(await engine2.count('big')).toBe(count);
|
||||||
await engine2.close();
|
await engine2.close();
|
||||||
}, 120000);
|
}, 600000);
|
||||||
|
|
||||||
it('大 value(100KB × 50)压缩写入/恢复完整', async () => {
|
it('大 value(100KB × 50)压缩写入/恢复完整', async () => {
|
||||||
const dbName = uniqueDB();
|
const dbName = uniqueDB();
|
||||||
@@ -161,7 +161,7 @@ describe('AriaEngine — 生产负载验证', () => {
|
|||||||
const one = await engine2.find('docs', { table: 'docs', where: { id: 'd25' } });
|
const one = await engine2.find('docs', { table: 'docs', where: { id: 'd25' } });
|
||||||
expect((one[0].body as string).length).toBe(chunk.length);
|
expect((one[0].body as string).length).toBe(chunk.length);
|
||||||
await engine2.close();
|
await engine2.close();
|
||||||
}, 120000);
|
}, 600000);
|
||||||
|
|
||||||
it('混合操作 + 崩溃:已确认写入零丢失(20000 操作)', async () => {
|
it('混合操作 + 崩溃:已确认写入零丢失(20000 操作)', async () => {
|
||||||
const dbName = uniqueDB();
|
const dbName = uniqueDB();
|
||||||
@@ -217,7 +217,7 @@ describe('AriaEngine — 生产负载验证', () => {
|
|||||||
expect(rows[0].val).toBe(expected.val);
|
expect(rows[0].val).toBe(expected.val);
|
||||||
}
|
}
|
||||||
await engine2.close();
|
await engine2.close();
|
||||||
}, 120000);
|
}, 600000);
|
||||||
|
|
||||||
it('大量删除(90% 行)+ Compaction → 重启无残留(墓碑清理)', async () => {
|
it('大量删除(90% 行)+ Compaction → 重启无残留(墓碑清理)', async () => {
|
||||||
const dbName = uniqueDB();
|
const dbName = uniqueDB();
|
||||||
@@ -253,7 +253,7 @@ describe('AriaEngine — 生产负载验证', () => {
|
|||||||
const all = await engine2.find('big', { table: 'big' });
|
const all = await engine2.find('big', { table: 'big' });
|
||||||
expect(all.every((r) => Number(String(r.id).slice(1)) < 1000)).toBe(true);
|
expect(all.every((r) => Number(String(r.id).slice(1)) < 1000)).toBe(true);
|
||||||
await engine2.close();
|
await engine2.close();
|
||||||
}, 120000);
|
}, 600000);
|
||||||
|
|
||||||
it('kv 后端 5 万行(页面化路径):写入 → 崩溃 → 恢复完整', async () => {
|
it('kv 后端 5 万行(页面化路径):写入 → 崩溃 → 恢复完整', async () => {
|
||||||
const dbName = uniqueDB();
|
const dbName = uniqueDB();
|
||||||
@@ -289,7 +289,7 @@ describe('AriaEngine — 生产负载验证', () => {
|
|||||||
expect(await engine2.count('big')).toBe(TOTAL);
|
expect(await engine2.count('big')).toBe(TOTAL);
|
||||||
expect(await engine2.find('big', { table: 'big', where: { tag: 't3' } })).toHaveLength(5000);
|
expect(await engine2.find('big', { table: 'big', where: { tag: 't3' } })).toHaveLength(5000);
|
||||||
await engine2.close();
|
await engine2.close();
|
||||||
}, 180000);
|
}, 600000);
|
||||||
|
|
||||||
it('10 万行 kv 后端(含索引):完整查询 + 崩溃恢复(v0.6.1-perf 回归)', async () => {
|
it('10 万行 kv 后端(含索引):完整查询 + 崩溃恢复(v0.6.1-perf 回归)', async () => {
|
||||||
const dbName = uniqueDB();
|
const dbName = uniqueDB();
|
||||||
@@ -314,10 +314,11 @@ describe('AriaEngine — 生产负载验证', () => {
|
|||||||
}
|
}
|
||||||
const insertMs = Date.now() - t0;
|
const insertMs = Date.now() - t0;
|
||||||
// 性能护栏:修复前 353s(batch 32 起每批 8~11s 性能悬崖),
|
// 性能护栏:修复前 353s(batch 32 起每批 8~11s 性能悬崖),
|
||||||
// 修复后本机 ~30s。CI(debian runner + maxWorkers=2 并行重型测试)慢 2~3 倍,
|
// 修复后本机 ~12.5s。CI(debian runner 慢 2~3 倍、重型套件串行)下
|
||||||
// 护栏放宽到 120s —— 仍能拦截性能悬崖回归(353s >> 120s),不误报健康慢环境。
|
// 健康耗时约 30~80s;护栏放宽到 240s —— 仍能拦截性能悬崖回归(353s >> 240s),
|
||||||
|
// 不误报健康慢环境。
|
||||||
console.log(`10万行 kv 插入耗时: ${insertMs}ms`);
|
console.log(`10万行 kv 插入耗时: ${insertMs}ms`);
|
||||||
expect(insertMs).toBeLessThan(120000);
|
expect(insertMs).toBeLessThan(240000);
|
||||||
expect(await engine.count('big')).toBe(TOTAL);
|
expect(await engine.count('big')).toBe(TOTAL);
|
||||||
|
|
||||||
// 全部 10 个 tag 索引查询完整
|
// 全部 10 个 tag 索引查询完整
|
||||||
@@ -339,7 +340,7 @@ describe('AriaEngine — 生产负载验证', () => {
|
|||||||
expect(await engine2.count('big')).toBe(TOTAL);
|
expect(await engine2.count('big')).toBe(TOTAL);
|
||||||
expect(await engine2.find('big', { table: 'big', where: { tag: 't7' } })).toHaveLength(10000);
|
expect(await engine2.find('big', { table: 'big', where: { tag: 't7' } })).toHaveLength(10000);
|
||||||
await engine2.close();
|
await engine2.close();
|
||||||
}, 180000);
|
}, 600000);
|
||||||
|
|
||||||
it('10 万行 opfs 后端(含索引):完整查询(v0.6.1-perf 回归)', async () => {
|
it('10 万行 opfs 后端(含索引):完整查询(v0.6.1-perf 回归)', async () => {
|
||||||
const engine = new AriaEngine({
|
const engine = new AriaEngine({
|
||||||
@@ -362,14 +363,14 @@ describe('AriaEngine — 生产负载验证', () => {
|
|||||||
await engine.insert('big', rows);
|
await engine.insert('big', rows);
|
||||||
}
|
}
|
||||||
const insertMs = Date.now() - t0;
|
const insertMs = Date.now() - t0;
|
||||||
// 同上:CI 慢环境护栏放宽(本机 ~25s)
|
// 同上:CI 慢环境护栏放宽(本机 ~25s;悬崖回归仍会被拦截)
|
||||||
console.log(`10万行 opfs 插入耗时: ${insertMs}ms`);
|
console.log(`10万行 opfs 插入耗时: ${insertMs}ms`);
|
||||||
expect(insertMs).toBeLessThan(150000);
|
expect(insertMs).toBeLessThan(300000);
|
||||||
expect(await engine.count('big')).toBe(TOTAL);
|
expect(await engine.count('big')).toBe(TOTAL);
|
||||||
for (let t = 0; t < 10; t++) {
|
for (let t = 0; t < 10; t++) {
|
||||||
const viaIdx = await engine.find('big', { table: 'big', where: { tag: `t${t}` } });
|
const viaIdx = await engine.find('big', { table: 'big', where: { tag: `t${t}` } });
|
||||||
expect(viaIdx.length).toBe(10000);
|
expect(viaIdx.length).toBe(10000);
|
||||||
}
|
}
|
||||||
await engine.close();
|
await engine.close();
|
||||||
}, 180000);
|
}, 600000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ describe('KVStoreEngine — 10 万级压力', () => {
|
|||||||
expect(Number(rows[0].val)).toBe(Number(id.slice(1)));
|
expect(Number(rows[0].val)).toBe(Number(id.slice(1)));
|
||||||
}
|
}
|
||||||
await engine2.close();
|
await engine2.close();
|
||||||
}, 120000);
|
}, 600000);
|
||||||
|
|
||||||
it('5 万混合操作 + 崩溃模拟(不 checkpoint)→ 重开全部已确认写入可见', async () => {
|
it('5 万混合操作 + 崩溃模拟(不 checkpoint)→ 重开全部已确认写入可见', async () => {
|
||||||
const dbName = uniqueDB();
|
const dbName = uniqueDB();
|
||||||
@@ -112,7 +112,7 @@ describe('KVStoreEngine — 10 万级压力', () => {
|
|||||||
expect(rows[0].val).toBe(val);
|
expect(rows[0].val).toBe(val);
|
||||||
}
|
}
|
||||||
await engine2.close();
|
await engine2.close();
|
||||||
}, 120000);
|
}, 600000);
|
||||||
|
|
||||||
it('多次 checkpoint 循环(500 次)数据不丢', async () => {
|
it('多次 checkpoint 循环(500 次)数据不丢', async () => {
|
||||||
const dbName = uniqueDB();
|
const dbName = uniqueDB();
|
||||||
@@ -135,5 +135,5 @@ describe('KVStoreEngine — 10 万级压力', () => {
|
|||||||
const last = await engine2.find('t', { table: 't', where: { id: 'k499' } });
|
const last = await engine2.find('t', { table: 't', where: { id: 'k499' } });
|
||||||
expect(last[0].v).toBe(499);
|
expect(last[0].v).toBe(499);
|
||||||
await engine2.close();
|
await engine2.close();
|
||||||
}, 60000);
|
}, 300000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ describe('KVStore — 边界与故障', () => {
|
|||||||
expect(kv2.size()).toBe(10000);
|
expect(kv2.size()).toBe(10000);
|
||||||
expect(dec(await kv2.get('k9999'))).toBe('v9999');
|
expect(dec(await kv2.get('k9999'))).toBe('v9999');
|
||||||
await kv2.close();
|
await kv2.close();
|
||||||
}, 60000);
|
}, 300000);
|
||||||
|
|
||||||
it('写入失败后 KVStore 继续可用(错误不污染后续操作)', async () => {
|
it('写入失败后 KVStore 继续可用(错误不污染后续操作)', async () => {
|
||||||
const dbName = uniqueDB();
|
const dbName = uniqueDB();
|
||||||
@@ -322,7 +322,7 @@ describe('KVStoreEngine — 异常与一致性', () => {
|
|||||||
const remaining = await e2.find('bulk', { table: 'bulk', where: { id: { $gte: 3000 } } });
|
const remaining = await e2.find('bulk', { table: 'bulk', where: { id: { $gte: 3000 } } });
|
||||||
expect(remaining).toHaveLength(1000);
|
expect(remaining).toHaveLength(1000);
|
||||||
await e2.close();
|
await e2.close();
|
||||||
}, 60000);
|
}, 300000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ beforeEach(() => { installOPFSMock(new Map()); });
|
|||||||
|
|
||||||
describe('[v0.2.5] P0-1: 版本号统一', () => {
|
describe('[v0.2.5] P0-1: 版本号统一', () => {
|
||||||
test('VERSION 常量为当前版本(0.6.0)', () => {
|
test('VERSION 常量为当前版本(0.6.0)', () => {
|
||||||
expect(VERSION).toBe('0.7.1');
|
expect(VERSION).toBe('0.7.2');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -403,7 +403,7 @@ describe('[v0.3.3] P1-9: Savepoint + MVCC 一致性', () => {
|
|||||||
|
|
||||||
describe('[v0.3.3] 端到端', () => {
|
describe('[v0.3.3] 端到端', () => {
|
||||||
test('全部修复点可共存于 MetonaSqlark API', async () => {
|
test('全部修复点可共存于 MetonaSqlark API', async () => {
|
||||||
expect(VERSION).toBe('0.7.1');
|
expect(VERSION).toBe('0.7.2');
|
||||||
const db = new MetonaSqlark({ name: `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, mode: 'memory' });
|
const db = new MetonaSqlark({ name: `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, mode: 'memory' });
|
||||||
await db.init();
|
await db.init();
|
||||||
await db.defineTable('users', {
|
await db.defineTable('users', {
|
||||||
|
|||||||
@@ -0,0 +1,540 @@
|
|||||||
|
/**
|
||||||
|
* v0.7.2 修复回归测试
|
||||||
|
*
|
||||||
|
* 覆盖:
|
||||||
|
* - UPDATE 语句级原子性(多行更新撞唯一约束 → 整句拒绝,四引擎)
|
||||||
|
* - 批内唯一互查(两行同时更新到同一新唯一值 → 整句拒绝)
|
||||||
|
* - 事务内 DDL 拒绝(ALTER TABLE / CREATE INDEX / DROP INDEX,Memory/KVStore 对齐 Aria)
|
||||||
|
* - SET NULL 级联撞 required 列 → FOREIGN_KEY_VIOLATION(delete 与 update 主键变更,Memory/Aria)
|
||||||
|
* - bindParameters 注释感知(行注释 / 块注释中的 ? 与引号不参与绑定)
|
||||||
|
* - 未闭合字符串字面量 → PARSE_ERROR
|
||||||
|
* - 未知 where 操作符 → QUERY_ERROR(不再静默全匹配)
|
||||||
|
* - UPDATE undefined 值语义化(保留旧值,不再删除列)
|
||||||
|
* - Hybrid 磁盘写失败补偿(内存对齐磁盘状态)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MetonaSqlark } from '../src/core';
|
||||||
|
import { parse } from '../src/sql/parser';
|
||||||
|
import { bindParameters } from '../src/sql/params';
|
||||||
|
import { matchWhere } from '../src/query/where-matcher';
|
||||||
|
import { MemoryEngine } from '../src/engine/memory';
|
||||||
|
import { KVStoreEngine } from '../src/engine/kvstore_engine';
|
||||||
|
import { HybridEngine } from '../src/hybrid/index';
|
||||||
|
import { createSchema } from '../src/table/schema';
|
||||||
|
|
||||||
|
function uniqueDB(): string {
|
||||||
|
return `v072-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// UPDATE 语句级原子性
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
describe('v0.7.2 — UPDATE 语句级原子性', () => {
|
||||||
|
const modes = ['memory', 'disk', 'hybrid', 'aria'] as const;
|
||||||
|
|
||||||
|
for (const mode of modes) {
|
||||||
|
it(`${mode}: 多行更新撞唯一约束 → 整句拒绝、数据不变`, async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: uniqueDB(), mode, diskEngine: 'memory' });
|
||||||
|
await db.defineTable('t', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
email: { type: 'string', unique: true },
|
||||||
|
});
|
||||||
|
await db.table('t').insertMany([
|
||||||
|
{ id: 'a', email: 'a@x.com' },
|
||||||
|
{ id: 'b', email: 'b@x.com' },
|
||||||
|
{ id: 'c', email: 'c@x.com' },
|
||||||
|
]);
|
||||||
|
await db.table('t').update({ email: 'z@x.com' }).where({ id: 'a' }).execute();
|
||||||
|
|
||||||
|
// 一条语句同时把 b、c 改成 a 已占用的 z@x.com:b 成功改到一半,c 撞唯一
|
||||||
|
await expect(
|
||||||
|
db.table('t').update({ email: 'z@x.com' }).where({ id: 'c' }).execute(),
|
||||||
|
).rejects.toThrow(); // 单行冲突
|
||||||
|
|
||||||
|
// 关键场景:无 where 全表更新到同一值 → 第二行即冲突
|
||||||
|
await expect(
|
||||||
|
db.table('t').update({ email: 'y@x.com' }).execute(),
|
||||||
|
).rejects.toMatchObject({ code: 'UNIQUE_VIOLATION' });
|
||||||
|
|
||||||
|
// 整句拒绝:任何行都不该被改成 y@x.com
|
||||||
|
const rows = await db.query('SELECT id, email FROM t ORDER BY id') as Record<string, unknown>[];
|
||||||
|
expect(rows).toEqual([
|
||||||
|
{ id: 'a', email: 'z@x.com' },
|
||||||
|
{ id: 'b', email: 'b@x.com' },
|
||||||
|
{ id: 'c', email: 'c@x.com' },
|
||||||
|
]);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('memory: 校验失败(required 置 null)也整句拒绝', async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
|
||||||
|
await db.defineTable('t', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
name: { type: 'string', required: true },
|
||||||
|
});
|
||||||
|
await db.table('t').insertMany([
|
||||||
|
{ id: '1', name: 'A' },
|
||||||
|
{ id: '2', name: 'B' },
|
||||||
|
]);
|
||||||
|
await expect(
|
||||||
|
db.table('t').update({ name: null }).execute(),
|
||||||
|
).rejects.toMatchObject({ code: 'VALIDATION_ERROR' });
|
||||||
|
const rows = await db.query('SELECT * FROM t ORDER BY id') as Record<string, unknown>[];
|
||||||
|
expect(rows).toEqual([{ id: '1', name: 'A' }, { id: '2', name: 'B' }]);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aria: 主键变更撞已有主键整句拒绝(含 WAL 一致性)', async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'aria', diskEngine: 'memory' });
|
||||||
|
await db.defineTable('t', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
n: { type: 'number' },
|
||||||
|
});
|
||||||
|
await db.table('t').insertMany([
|
||||||
|
{ id: 'a', n: 1 }, { id: 'b', n: 2 }, { id: 'c', n: 3 },
|
||||||
|
]);
|
||||||
|
// a → c 撞已有 c;且 d 行的更新(同语句)也不得生效
|
||||||
|
await expect(
|
||||||
|
db.table('t').update({ id: 'c' }).where({ id: 'a' }).execute(),
|
||||||
|
).rejects.toMatchObject({ code: 'DUPLICATE_KEY' });
|
||||||
|
const rows = await db.query('SELECT * FROM t ORDER BY id') as Record<string, unknown>[];
|
||||||
|
expect(rows.map((r) => r.id)).toEqual(['a', 'b', 'c']);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// 批内唯一互查
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
describe('v0.7.2 — 批内唯一互查', () => {
|
||||||
|
it('两行同时更新到同一新唯一值 → 整句拒绝(memory)', async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
|
||||||
|
await db.defineTable('t', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
email: { type: 'string', unique: true },
|
||||||
|
});
|
||||||
|
await db.table('t').insertMany([
|
||||||
|
{ id: 'a', email: 'a@x.com' },
|
||||||
|
{ id: 'b', email: 'b@x.com' },
|
||||||
|
]);
|
||||||
|
await expect(
|
||||||
|
db.table('t').update({ email: 'z@x.com' }).execute(),
|
||||||
|
).rejects.toMatchObject({ code: 'UNIQUE_VIOLATION' });
|
||||||
|
const rows = await db.query('SELECT email FROM t ORDER BY id') as Record<string, unknown>[];
|
||||||
|
expect(rows).toEqual([{ email: 'a@x.com' }, { email: 'b@x.com' }]);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('两行同时更新到同一新唯一值 → 整句拒绝(aria)', async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'aria', diskEngine: 'memory' });
|
||||||
|
await db.defineTable('t', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
email: { type: 'string', unique: true },
|
||||||
|
});
|
||||||
|
await db.table('t').insertMany([
|
||||||
|
{ id: 'a', email: 'a@x.com' },
|
||||||
|
{ id: 'b', email: 'b@x.com' },
|
||||||
|
]);
|
||||||
|
await expect(
|
||||||
|
db.table('t').update({ email: 'z@x.com' }).execute(),
|
||||||
|
).rejects.toMatchObject({ code: 'UNIQUE_VIOLATION' });
|
||||||
|
const rows = await db.query('SELECT email FROM t ORDER BY id') as Record<string, unknown>[];
|
||||||
|
expect(rows).toEqual([{ email: 'a@x.com' }, { email: 'b@x.com' }]);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('唯一值不变时更新自身不误报(回归)', async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
|
||||||
|
await db.defineTable('t', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
email: { type: 'string', unique: true },
|
||||||
|
name: { type: 'string' },
|
||||||
|
});
|
||||||
|
await db.table('t').insertMany([
|
||||||
|
{ id: 'a', email: 'a@x.com', name: 'A' },
|
||||||
|
]);
|
||||||
|
const n = await db.table('t').update({ name: 'A2', email: 'a@x.com' }).execute();
|
||||||
|
expect(n).toBe(1);
|
||||||
|
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
|
||||||
|
expect(rows[0].name).toBe('A2');
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// 事务内 DDL 拒绝
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
describe('v0.7.2 — 事务内 DDL 拒绝(三引擎一致)', () => {
|
||||||
|
it('memory: 事务内 ALTER TABLE 抛 NOT_SUPPORTED,事务可继续提交', async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
|
||||||
|
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, a: { type: 'number' } });
|
||||||
|
await db.query('BEGIN');
|
||||||
|
await db.query("INSERT INTO t VALUES ('1', 10)");
|
||||||
|
await expect(db.query('ALTER TABLE t ADD COLUMN b STRING')).rejects.toMatchObject({ code: 'NOT_SUPPORTED' });
|
||||||
|
await db.query('COMMIT');
|
||||||
|
const schema = await db.getEngine().getTableSchema('t');
|
||||||
|
expect('b' in (schema!.columns)).toBe(false);
|
||||||
|
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
|
||||||
|
expect(rows).toEqual([{ id: '1', a: 10 }]);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('memory: ROLLBACK 后无 DDL 残留', async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
|
||||||
|
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
|
||||||
|
await db.query('BEGIN');
|
||||||
|
await expect(db.query('ALTER TABLE t ADD COLUMN b STRING')).rejects.toMatchObject({ code: 'NOT_SUPPORTED' });
|
||||||
|
await db.query('ROLLBACK');
|
||||||
|
const schema = await db.getEngine().getTableSchema('t');
|
||||||
|
expect(Object.keys(schema!.columns)).toEqual(['id']);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kv: 事务内 ALTER TABLE 抛 NOT_SUPPORTED', async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'disk', diskEngine: 'memory' });
|
||||||
|
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
|
||||||
|
await db.query('BEGIN');
|
||||||
|
await expect(db.query('ALTER TABLE t ADD COLUMN b STRING')).rejects.toMatchObject({ code: 'NOT_SUPPORTED' });
|
||||||
|
await db.query('ROLLBACK');
|
||||||
|
const schema = await db.getEngine().getTableSchema('t');
|
||||||
|
expect(Object.keys(schema!.columns)).toEqual(['id']);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kv: 事务内 CREATE INDEX / DROP INDEX 抛 NOT_SUPPORTED', async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'disk', diskEngine: 'memory' });
|
||||||
|
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
|
||||||
|
await db.query('BEGIN');
|
||||||
|
await expect(db.query('CREATE INDEX idx_v ON t (v)')).rejects.toMatchObject({ code: 'NOT_SUPPORTED' });
|
||||||
|
await db.query('ROLLBACK');
|
||||||
|
const schema = await db.getEngine().getTableSchema('t');
|
||||||
|
expect(schema!.columns.v.index).toBeFalsy();
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('memory: 事务内 createTable/dropTable 仍可回滚(既有行为保持)', async () => {
|
||||||
|
const eng = new MemoryEngine();
|
||||||
|
await eng.open(uniqueDB(), 1);
|
||||||
|
await eng.createTable(createSchema('t', { id: { type: 'string', primaryKey: true } }));
|
||||||
|
await eng.beginTransaction();
|
||||||
|
await eng.createTable(createSchema('tmp', { id: { type: 'string', primaryKey: true } }));
|
||||||
|
await eng.rollbackTransaction();
|
||||||
|
expect(await eng.hasTable('tmp')).toBe(false);
|
||||||
|
expect(await eng.hasTable('t')).toBe(true);
|
||||||
|
await eng.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hybrid: 事务内 ALTER TABLE 抛 NOT_SUPPORTED(内存层拒绝)', async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'hybrid', diskEngine: 'memory' });
|
||||||
|
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
|
||||||
|
await db.query('BEGIN');
|
||||||
|
await expect(db.query('ALTER TABLE t ADD COLUMN b STRING')).rejects.toMatchObject({ code: 'NOT_SUPPORTED' });
|
||||||
|
await db.query('ROLLBACK');
|
||||||
|
const schema = await db.getEngine().getTableSchema('t');
|
||||||
|
expect(Object.keys(schema!.columns)).toEqual(['id']);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// SET NULL 级联撞 required 列
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
describe('v0.7.2 — SET NULL 级联撞 required 列', () => {
|
||||||
|
it('memory: delete 级联 SET NULL 到 required 列 → FOREIGN_KEY_VIOLATION', async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
|
||||||
|
await db.defineTable('parent', { id: { type: 'string', primaryKey: true } });
|
||||||
|
await db.defineTable('child', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
pid: { type: 'string', required: true, references: 'parent.id', onDelete: 'SET NULL' },
|
||||||
|
});
|
||||||
|
await db.query("INSERT INTO parent VALUES ('p1')");
|
||||||
|
await db.query("INSERT INTO child VALUES ('c1', 'p1')");
|
||||||
|
await expect(db.query("DELETE FROM parent WHERE id = 'p1'")).rejects.toMatchObject({ code: 'FOREIGN_KEY_VIOLATION' });
|
||||||
|
// 整体拒绝:父行仍在
|
||||||
|
const rows = await db.query('SELECT * FROM parent') as Record<string, unknown>[];
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aria: delete 级联 SET NULL 到 required 列 → FOREIGN_KEY_VIOLATION', async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'aria', diskEngine: 'memory' });
|
||||||
|
await db.defineTable('parent', { id: { type: 'string', primaryKey: true } });
|
||||||
|
await db.defineTable('child', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
pid: { type: 'string', required: true, references: 'parent.id', onDelete: 'SET NULL' },
|
||||||
|
});
|
||||||
|
await db.query("INSERT INTO parent VALUES ('p1')");
|
||||||
|
await db.query("INSERT INTO child VALUES ('c1', 'p1')");
|
||||||
|
await expect(db.query("DELETE FROM parent WHERE id = 'p1'")).rejects.toMatchObject({ code: 'FOREIGN_KEY_VIOLATION' });
|
||||||
|
const rows = await db.query('SELECT * FROM parent') as Record<string, unknown>[];
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('memory: update 主键变更 SET NULL 到 required 列 → 整体拒绝', async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
|
||||||
|
await db.defineTable('parent', { id: { type: 'string', primaryKey: true } });
|
||||||
|
await db.defineTable('child', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
pid: { type: 'string', required: true, references: 'parent.id', onUpdate: 'SET NULL' },
|
||||||
|
});
|
||||||
|
await db.query("INSERT INTO parent VALUES ('p1')");
|
||||||
|
await db.query("INSERT INTO child VALUES ('c1', 'p1')");
|
||||||
|
await expect(
|
||||||
|
db.table('parent').update({ id: 'p2' }).where({ id: 'p1' }).execute(),
|
||||||
|
).rejects.toMatchObject({ code: 'FOREIGN_KEY_VIOLATION' });
|
||||||
|
const rows = await db.query('SELECT * FROM parent') as Record<string, unknown>[];
|
||||||
|
expect(rows).toEqual([{ id: 'p1' }]);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aria: update 主键变更 SET NULL 到 required 列 → 整体拒绝', async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'aria', diskEngine: 'memory' });
|
||||||
|
await db.defineTable('parent', { id: { type: 'string', primaryKey: true } });
|
||||||
|
await db.defineTable('child', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
pid: { type: 'string', required: true, references: 'parent.id', onUpdate: 'SET NULL' },
|
||||||
|
});
|
||||||
|
await db.query("INSERT INTO parent VALUES ('p1')");
|
||||||
|
await db.query("INSERT INTO child VALUES ('c1', 'p1')");
|
||||||
|
await expect(
|
||||||
|
db.table('parent').update({ id: 'p2' }).where({ id: 'p1' }).execute(),
|
||||||
|
).rejects.toMatchObject({ code: 'FOREIGN_KEY_VIOLATION' });
|
||||||
|
const rows = await db.query('SELECT * FROM parent') as Record<string, unknown>[];
|
||||||
|
expect(rows).toEqual([{ id: 'p1' }]);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SET NULL 到非 required 列仍正常(回归)', async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
|
||||||
|
await db.defineTable('parent', { id: { type: 'string', primaryKey: true } });
|
||||||
|
await db.defineTable('child', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
pid: { type: 'string', references: 'parent.id', onDelete: 'SET NULL' },
|
||||||
|
});
|
||||||
|
await db.query("INSERT INTO parent VALUES ('p1')");
|
||||||
|
await db.query("INSERT INTO child VALUES ('c1', 'p1')");
|
||||||
|
await db.query("DELETE FROM parent WHERE id = 'p1'");
|
||||||
|
const rows = await db.query('SELECT * FROM child') as Record<string, unknown>[];
|
||||||
|
expect(rows).toEqual([{ id: 'c1', pid: null }]);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// bindParameters 注释感知
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
describe('v0.7.2 — bindParameters 注释感知', () => {
|
||||||
|
it('行注释中的 ? 不参与占位符', () => {
|
||||||
|
const sql = "SELECT * FROM t -- comment with ? inside\n WHERE id = ?";
|
||||||
|
const bound = bindParameters(sql, ['1']);
|
||||||
|
expect(bound).toContain('comment with ? inside');
|
||||||
|
expect(bound).toContain("WHERE id = '1'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('块注释中的 ? 不参与占位符', () => {
|
||||||
|
const sql = 'SELECT * FROM t /* c ? c */ WHERE id = ?';
|
||||||
|
const bound = bindParameters(sql, ['1']);
|
||||||
|
expect(bound).toContain('/* c ? c */');
|
||||||
|
expect(bound).toContain("WHERE id = '1'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("行注释中的单引号不触发未闭合字符串(-- don't)", () => {
|
||||||
|
const sql = "SELECT * FROM t WHERE id = ? -- don't touch";
|
||||||
|
const bound = bindParameters(sql, ['1']);
|
||||||
|
expect(bound).toContain("-- don't touch");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('块注释中的单引号不触发未闭合字符串', () => {
|
||||||
|
const sql = "SELECT * FROM t /* it's fine */ WHERE id = ?";
|
||||||
|
const bound = bindParameters(sql, ['1']);
|
||||||
|
expect(bound).toContain("/* it's fine */");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('占位符数量按注释外计算(注释里两个 ? 不报参数不足)', () => {
|
||||||
|
const sql = "SELECT * FROM t WHERE id = ? /* ? ? */ AND x = ? -- ?\n";
|
||||||
|
const bound = bindParameters(sql, ['1', '2']);
|
||||||
|
expect(bound).toContain("'1'");
|
||||||
|
expect(bound).toContain("'2'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SQL 注入载荷仍被安全编码(回归)', () => {
|
||||||
|
const sql = 'SELECT * FROM t WHERE name = ?';
|
||||||
|
const bound = bindParameters(sql, ["O'Brien'; DROP TABLE t; --"]);
|
||||||
|
expect(bound).toContain("'O''Brien''; DROP TABLE t; --'");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// 未闭合字符串字面量
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
describe('v0.7.2 — 未闭合字符串字面量', () => {
|
||||||
|
it("SELECT 'abc → PARSE_ERROR", () => {
|
||||||
|
expect(() => parse("SELECT 'abc")).toThrowError(/Unterminated string/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('query API 同样抛 PARSE_ERROR', async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
|
||||||
|
await expect(db.query("SELECT 'abc")).rejects.toMatchObject({ code: 'PARSE_ERROR' });
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// 未知 where 操作符
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
describe('v0.7.2 — 未知 where 操作符抛错', () => {
|
||||||
|
it('$between 未实现 → QUERY_ERROR(不再静默全匹配)', () => {
|
||||||
|
expect(() => matchWhere({ age: 3 }, { age: { $between: [1, 2] } } as never)).toThrowError(/Unknown where operator/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('query API 路径同样报错', async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
|
||||||
|
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
|
||||||
|
await db.query("INSERT INTO t VALUES ('1')");
|
||||||
|
await expect(
|
||||||
|
db.getEngine().find('t', { table: 't', where: { id: { $betwen: '1' } } as never }),
|
||||||
|
).rejects.toThrowError(/Unknown where operator/);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// UPDATE undefined 语义化
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
describe('v0.7.2 — UPDATE undefined 语义化', () => {
|
||||||
|
const modes = ['memory', 'disk', 'hybrid', 'aria'] as const;
|
||||||
|
|
||||||
|
for (const mode of modes) {
|
||||||
|
it(`${mode}: update({ col: undefined }) 保留旧值`, async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: uniqueDB(), mode, diskEngine: 'memory' });
|
||||||
|
await db.defineTable('t', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
tag: { type: 'string', default: 'def' },
|
||||||
|
});
|
||||||
|
await db.query("INSERT INTO t VALUES ('1', 'x')");
|
||||||
|
await db.table('t').update({ tag: undefined }).where({ id: '1' }).execute();
|
||||||
|
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
|
||||||
|
expect(rows[0]).toEqual({ id: '1', tag: 'x' });
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`${mode}: update({ col: null }) 仍显式置空`, async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: uniqueDB(), mode, diskEngine: 'memory' });
|
||||||
|
await db.defineTable('t', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
tag: { type: 'string' },
|
||||||
|
});
|
||||||
|
await db.query("INSERT INTO t VALUES ('1', 'x')");
|
||||||
|
await db.table('t').update({ tag: null }).where({ id: '1' }).execute();
|
||||||
|
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
|
||||||
|
expect(rows[0]).toEqual({ id: '1', tag: null });
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('主键 undefined 不触发主键变更路径(kv 引擎)', async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'disk', diskEngine: 'memory' });
|
||||||
|
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, n: { type: 'number' } });
|
||||||
|
await db.query("INSERT INTO t VALUES ('1', 1)");
|
||||||
|
const n = await db.table('t').update({ id: undefined, n: 2 }).where({ id: '1' }).execute();
|
||||||
|
expect(n).toBe(1);
|
||||||
|
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
|
||||||
|
expect(rows[0]).toEqual({ id: '1', n: 2 });
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// Hybrid 磁盘写失败补偿
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
describe('v0.7.2 — Hybrid 磁盘写失败补偿', () => {
|
||||||
|
it('insert 磁盘失败 → 错误抛出 + 内存与磁盘一致', async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'hybrid', diskEngine: 'memory' });
|
||||||
|
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
|
||||||
|
await db.query("INSERT INTO t VALUES ('1')");
|
||||||
|
|
||||||
|
const eng = db.getEngine() as HybridEngine;
|
||||||
|
const disk = (eng as unknown as { diskEngine: IStorageEngineMock }).diskEngine;
|
||||||
|
const origInsert = disk.insert.bind(disk);
|
||||||
|
let fail = true;
|
||||||
|
disk.insert = async (table: string, rows: Record<string, unknown>[]) => {
|
||||||
|
if (fail) throw new Error('disk down');
|
||||||
|
return origInsert(table, rows);
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(db.query("INSERT INTO t VALUES ('2')")).rejects.toThrow('disk down');
|
||||||
|
// 内存已对齐磁盘:'2' 不在(补偿后重载)
|
||||||
|
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
|
||||||
|
expect(rows).toEqual([{ id: '1' }]);
|
||||||
|
|
||||||
|
// 恢复后正常写入
|
||||||
|
fail = false;
|
||||||
|
await db.query("INSERT INTO t VALUES ('3')");
|
||||||
|
const rows2 = await db.query('SELECT * FROM t ORDER BY id') as Record<string, unknown>[];
|
||||||
|
expect(rows2).toEqual([{ id: '1' }, { id: '3' }]);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update 磁盘失败 → 错误抛出 + 内存对齐磁盘', async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'hybrid', diskEngine: 'memory' });
|
||||||
|
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, n: { type: 'number' } });
|
||||||
|
await db.query("INSERT INTO t VALUES ('1', 1)");
|
||||||
|
|
||||||
|
const eng = db.getEngine() as HybridEngine;
|
||||||
|
const disk = (eng as unknown as { diskEngine: IStorageEngineMock }).diskEngine;
|
||||||
|
disk.update = async () => { throw new Error('disk down'); };
|
||||||
|
|
||||||
|
await expect(db.table('t').update({ n: 99 }).where({ id: '1' }).execute()).rejects.toThrow('disk down');
|
||||||
|
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
|
||||||
|
expect(rows).toEqual([{ id: '1', n: 1 }]);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delete 磁盘失败 → 错误抛出 + 内存对齐磁盘', async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'hybrid', diskEngine: 'memory' });
|
||||||
|
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
|
||||||
|
await db.query("INSERT INTO t VALUES ('1')");
|
||||||
|
|
||||||
|
const eng = db.getEngine() as HybridEngine;
|
||||||
|
const disk = (eng as unknown as { diskEngine: IStorageEngineMock }).diskEngine;
|
||||||
|
disk.delete = async () => { throw new Error('disk down'); };
|
||||||
|
|
||||||
|
await expect(db.table('t').delete().where({ id: '1' }).execute()).rejects.toThrow('disk down');
|
||||||
|
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
|
||||||
|
expect(rows).toEqual([{ id: '1' }]);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('事务内写失败仍由快照回滚(补偿不影响事务路径)', async () => {
|
||||||
|
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'hybrid', diskEngine: 'memory' });
|
||||||
|
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
|
||||||
|
await db.query("INSERT INTO t VALUES ('1')");
|
||||||
|
await expect(db.transaction(async (trx) => {
|
||||||
|
await trx.table('t').insert({ id: '2' });
|
||||||
|
throw new Error('tx fail');
|
||||||
|
})).rejects.toThrow('tx fail');
|
||||||
|
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
|
||||||
|
expect(rows).toEqual([{ id: '1' }]);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IStorageEngineMock {
|
||||||
|
insert: (table: string, rows: Record<string, unknown>[]) => Promise<string[]>;
|
||||||
|
update: (table: string, query: never, updates: Record<string, unknown>) => Promise<number>;
|
||||||
|
delete: (table: string, query: never) => Promise<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 KVStoreEngine 也导出(引用用)
|
||||||
|
void KVStoreEngine;
|
||||||
Reference in New Issue
Block a user