fix(P0): v0.6.2 数据正确性专项 — 深度审计 6 项修复 + 22 回归
CI / test (22.x) (push) Successful in 17m6s
CI / test (18.x) (push) Failing after 17m45s
CI / test (20.x) (push) Successful in 18m7s
CI / test (24.x) (push) Failing after 14m40s
CI / e2e (push) Successful in 9m54s

- KVStoreEngine 数值主键 update 丢行(P0):String 化主键回查不命中 → 误删 KV 行
  (重启丢数据);改为单次全表扫描 + 受影响集合过滤(兼消 O(N×M) 回查开销)
- update 主键撞已有主键静默覆盖(P0,Memory/Aria):抛 DUPLICATE_KEY,事务路径同拦截
- Aria 二级索引范围查询边界算法错误(P1):Number(v)±1 构造 key 漏小数/字符串数据;
  改全索引扫描 + matchWhere 过滤,边界语义统一
- Aria 索引列 IS NULL 返回空(P1):null 等值/含 null 的 IN 不走索引(回退全表)
- AriaEngine unique 约束未强制(P1):insert 整批预检(批内互查+索引扫描,失败整批
  不落库)+ update 排除自身旧条目检查;新增 LSM.prefetchPrefixRanges 批量预加载
- 非主键 update 索引旧值残留:统一传旧行清理(消除唯一性误报与索引膨胀)
- EXPLAIN 写语句产生真实副作用(P2):仅 SELECT 执行,UPDATE/DELETE 用 count 估算

测试 1092 → 1114(70 套件);行覆盖率 89.6%;版本 0.6.2
This commit is contained in:
thzxx
2026-08-13 09:51:26 +08:00
parent a91ac368e2
commit ef1934a38c
20 changed files with 1284 additions and 118 deletions
+39
View File
@@ -2,6 +2,45 @@
All notable changes to MetonaSqlark will be documented in this file. All notable changes to MetonaSqlark will be documented in this file.
## [0.6.2] - 2026-08-13
### 深度审计修复(数据正确性专项)
> 全源码通读 + 针对性实验验证,修复 6 个测试盲区中的数据丢失/查询错误/约束缺失问题。
### Fixed
- **KVStoreEngine 数值主键 update 丢行(P0)** — 非主键更新路径把受影响主键 `String()` 化后按
`where { [pkCol]: pk }` 回查内存行,数值型主键(123 !== "123")不命中 → 行被误判删除 →
重启后该行永久丢失。改为单次全表扫描 + 受影响集合过滤(同时消除 O(N×M) 逐主键回查开销),
字符串/数值主键语义统一
- **update 主键变更撞已有主键静默覆盖(P0** — MemoryEngine / AriaEngine 更新主键为目标已
存在值时静默覆盖另一行(数据丢失)。现在抛 `DUPLICATE_KEY`(与 insert 语义对齐),
事务内路径(txnSnapshot + 主 LSM 双查)同样拦截
- **Aria 二级索引范围查询边界算法错误(P1)** — `$gt/$gte/$lt/$lte``Number(v)±1` 构造边界
key:小数数值($gt:2 → "3:",漏 2.5)与字符串("NaN:" 前缀错位,数字/大写开头值被漏)
静默丢数据。改为全索引扫描 + 行级 matchWhere 过滤(与主键范围路径同方案),边界语义与
where-matcher 完全一致;新增小数/字符串/大写/数字开头/整数 flush 前后一致性回归
- **Aria 索引列 IS NULL 返回空(P1** — `$eq: null` 走索引路径时 `String(null)="null"` 查找
返回空并短路全表扫描 → 索引列 IS NULL 恒空。null 等值/含 null 的 IN 列表不再走索引
(回退全表扫描),与全表扫描语义一致
- **AriaEngine unique 约束未强制(P1** — 此前 insert/update 仅检查主键重复,唯一列重复值
被静默接受(MemoryEngine 已强制)。补齐:insert 整批预检(批内互查 + 索引 LSM 前缀扫描,
失败整批不落库);update 排除自身旧索引条目后检查;null 不受唯一约束(对齐 Memory);
新增 LSM.prefetchPrefixRanges(批量前缀范围预加载,一次 drainChain 避免逐行性能悬崖)
- **非主键 update 索引旧值残留** — update 时旧行未传给 `updateSecondaryIndexes`(仅主键变更
时传),索引 LSM 旧条目累积 → 唯一性检查误报 / 索引存储膨胀。统一传旧行清理旧值
- **EXPLAIN 写语句产生真实副作用(P2)** — `EXPLAIN DELETE/UPDATE/INSERT` 此前真实执行语句
(删/改数据)。现在仅 SELECT 类执行(只读),UPDATE/DELETE 用 count 估算影响行数,
INSERT/DDL 仅输出计划
### Changed
- 测试 1092 → **1114**70 套件,+22 个 v0.6.2 回归);新增 `tests/v062-fixes.test.ts`
(数值主键持久化 ×3 / 主键碰撞 ×3 / 索引范围 ×4 / IS NULL ×3 / unique ×7 / EXPLAIN ×2
---
## [0.6.1] - 2026-08-10 ## [0.6.1] - 2026-08-10
### 生产可用性深度审查(异常场景) ### 生产可用性深度审查(异常场景)
+6 -6
View File
@@ -1,10 +1,10 @@
# MetonaSqlark # MetonaSqlark
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/version-0.6.1-blue?style=flat-square" alt="version"> <img src="https://img.shields.io/badge/version-0.6.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.7%25-brightgreen?style=flat-square" alt="coverage"> <img src="https://img.shields.io/badge/coverage-89.6%25-brightgreen?style=flat-square" alt="coverage">
<img src="https://img.shields.io/badge/tests-1093%20passed-success?style=flat-square" alt="tests"> <img src="https://img.shields.io/badge/tests-1114%20passed-success?style=flat-square" alt="tests">
</p> </p>
> 基于 TypeScript 的**前端关系型数据库**:完整 SQL + Query Builder 双 API > 基于 TypeScript 的**前端关系型数据库**:完整 SQL + Query Builder 双 API
@@ -397,7 +397,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 # 运行测试(1093 用例 · 70 套件) npm test # 运行测试(1114 用例 · 70 套件)
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 # 类型检查
@@ -409,9 +409,9 @@ npm run typecheck # 类型检查
| 指标 | 数值 | | 指标 | 数值 |
|------|------| |------|------|
| 测试用例 | 1093+12 Playwright e2e | | 测试用例 | 1114+12 Playwright e2e |
| 测试套件 | 70 | | 测试套件 | 70 |
| 行覆盖率 | 89.7% | | 行覆盖率 | 89.6% |
| SQL 关键字 | 72 | | SQL 关键字 | 72 |
| 存储引擎 | 5Memory / KVStore / OPFS / Hybrid / Aria | | 存储引擎 | 5Memory / KVStore / OPFS / Hybrid / Aria |
| 运行时依赖 | 0 | | 运行时依赖 | 0 |
+201 -23
View File
@@ -34,7 +34,7 @@ class DatabaseError extends Error {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 版本 // 版本
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const VERSION = '0.6.1'; const VERSION = '0.6.2';
/** /**
* metona-sqlark Shared WHERE Matcher 统一的条件匹配逻辑 * metona-sqlark Shared WHERE Matcher 统一的条件匹配逻辑
@@ -399,6 +399,10 @@ class MemoryEngine {
this.validateRow(schema, updated); this.validateRow(schema, updated);
this.checkUniqueness(schema, updated); this.checkUniqueness(schema, updated);
const newPk = String(updated[pkCol]); const newPk = String(updated[pkCol]);
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据)
if (newPk !== pk && table.has(newPk)) {
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
}
// v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表 // v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
if (newPk !== pk) { if (newPk !== pk) {
await this.applyUpdateCascade(tableName, pk, newPk); await this.applyUpdateCascade(tableName, pk, newPk);
@@ -2025,16 +2029,22 @@ class KVStoreEngine {
} }
} }
else { else {
// 增量重写受影响行 // v0.6.2-fix(P0): 受影响主键经 String() 化后按 `where { [pkCol]: pk }` 回查内存行,
for (const pk of affected) { // 数值型主键(123 !== "123")不命中 → 行被误判删除 → 重启丢数据。
const row = await this.memory.find(tableName, { table: tableName, where: { [pkCol]: pk } }); // 改为单次全表扫描 + 受影响集合过滤(同时消除此前 O(N×M) 逐主键回查开销)。
if (row.length > 0) { const affectedSet = new Set(affected);
puts[this.rowKey(tableName, pk)] = enc(JSON.stringify(row[0])); const allRows = await this.memory.find(tableName, { table: tableName });
for (const row of allRows) {
const pkStr = String(row[pkCol]);
if (affectedSet.has(pkStr)) {
puts[this.rowKey(tableName, pkStr)] = enc(JSON.stringify(row));
affectedSet.delete(pkStr);
} }
else { }
// 剩余主键(内存中已不存在,如被级联移除)→ 删除对应 KV 行
for (const pk of affectedSet) {
deletes.push(this.rowKey(tableName, pk)); deletes.push(this.rowKey(tableName, pk));
} }
}
// 级联影响表(SET NULL/CASCADE 外键)整表 diff // 级联影响表(SET NULL/CASCADE 外键)整表 diff
for (const t of await this.affectedTables(tableName)) { for (const t of await this.affectedTables(tableName)) {
if (t === tableName) if (t === tableName)
@@ -3916,6 +3926,46 @@ class LSM {
await this.preloadSSTable(id, meta); await this.preloadSSTable(id, meta);
} }
} }
/**
* v0.6.2: 批量预加载多个前缀范围可能命中的所有 SSTable一次 drainChain
* 唯一性检查等"每行一个窄前缀范围"场景用meta 遍历只做一次
* 避免逐行调用 prefetchRange 时每行 drainChain 的性能悬崖
*/
async prefetchPrefixRanges(ranges) {
if (ranges.length === 0)
return;
// v0.6.2: 去重(大批量插入时唯一列值可能重复),减少 meta 遍历开销
const seen = new Set();
const unique = [];
for (const r of ranges) {
const k = `${r[0]}\u0000${r[1]}`;
if (!seen.has(k)) {
seen.add(k);
unique.push(r);
}
}
if (unique.length === 0)
return;
await this.drainChain();
this.trimCache();
const toLoad = new Set();
for (let level = 0; level < MAX_LSM_LEVELS; level++) {
for (const meta of this.levels[level]) {
if (this.sstableCache.has(meta.id))
continue;
for (const [startKey, endKey] of unique) {
if (endKey < meta.minKey || startKey > meta.maxKey)
continue;
toLoad.add(meta.id);
break;
}
}
}
for (const id of toLoad) {
const meta = this.findMetaById(id);
await this.preloadSSTable(id, meta);
}
}
/** 按 id 查找 SSTable 元数据(prefetch 预加载用) */ /** 按 id 查找 SSTable 元数据(prefetch 预加载用) */
findMetaById(id) { findMetaById(id) {
for (let level = 0; level < MAX_LSM_LEVELS; level++) { for (let level = 0; level < MAX_LSM_LEVELS; level++) {
@@ -6491,11 +6541,50 @@ class AriaEngine {
// 后台 compaction 在链上数秒时每行阻塞数秒 → 大数据量插入性能悬崖 // 后台 compaction 在链上数秒时每行阻塞数秒 → 大数据量插入性能悬崖
// 10 万行 kv 后端从 5ms/批暴跌到 8~11s/批)。批内新数据在 memtable // 10 万行 kv 后端从 5ms/批暴跌到 8~11s/批)。批内新数据在 memtable
// 或 flush 产物(自动入缓存),循环内 lsm.get 始终完整。 // 或 flush 产物(自动入缓存),循环内 lsm.get 始终完整。
await this.lsm.prefetchKeys(rows.map((r) => `${tableName}:${String(r[pkCol])}`)); //
// v0.6.2: 整批预校验(验证失败整批不落库,语义更原子)+ 唯一约束检查。
const uniqueCols = this.uniqueColumns(tableName, schema);
const validatedRows = [];
for (const row of rows) { for (const row of rows) {
const validated = this.validateRow(schema, row); const validated = this.validateRow(schema, row);
const pkValue = String(validated[pkCol]); const pkValue = String(validated[pkCol]);
const key = `${tableName}:${pkValue}`; validatedRows.push({ row: validated, pkValue, key: `${tableName}:${pkValue}` });
}
await this.lsm.prefetchKeys(validatedRows.map((v) => v.key));
// v0.6.2: 唯一约束 — 批量预加载本批唯一列涉及的索引范围(一次 drainChain
for (const colName of uniqueCols) {
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`);
await idxLsm.prefetchPrefixRanges(validatedRows
.map((v) => {
const val = v.row[colName];
if (val === undefined || val === null)
return null;
const p = `${String(val)}:`;
return [p, `${p}\uffff`];
})
.filter((r) => r !== null));
}
// v0.6.2: 唯一性整批预检(批内互查 + 索引查)—— 失败整批不落库(原子语义)
const batchUnique = new Map();
for (const { row: validated, pkValue } of validatedRows) {
for (const colName of uniqueCols) {
const val = validated[colName];
if (val === undefined || val === null)
continue;
const v = String(val);
let seen = batchUnique.get(colName);
if (!seen) {
seen = new Set();
batchUnique.set(colName, seen);
}
if (seen.has(v)) {
throw new DatabaseError(`Unique constraint violation on column "${colName}" in table "${tableName}"`, 'UNIQUE_VIOLATION');
}
seen.add(v);
this.checkUniqueSync(tableName, [colName], validated, pkValue);
}
}
for (const { row: validated, pkValue, key } of validatedRows) {
// Check duplicate in LSM + transaction snapshot // Check duplicate in LSM + transaction snapshot
const existing = this.currentTxnId const existing = this.currentTxnId
? (this.txnSnapshot?.get(key) ?? this.lsm.get(key)) ? (this.txnSnapshot?.get(key) ?? this.lsm.get(key))
@@ -6574,15 +6663,48 @@ 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.6.2: 唯一约束 — 批量预加载本批更新涉及的唯一列索引范围(一次 drainChain
const uniqueCols = this.uniqueColumns(tableName, schema);
for (const colName of uniqueCols) {
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`);
const ranges = [];
if (updates[colName] !== undefined && updates[colName] !== null) {
const p = `${String(updates[colName])}:`;
ranges.push([p, `${p}\uffff`]);
}
else if (!(colName in updates)) {
for (const row of rows) {
const val = row[colName];
if (val === undefined || val === null)
continue;
const p = `${String(val)}:`;
ranges.push([p, `${p}\uffff`]);
}
}
await idxLsm.prefetchPrefixRanges(ranges);
}
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 }; const updated = { ...row, ...updates };
this.validateRow(schema, updated); this.validateRow(schema, updated);
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在)
this.checkUniqueSync(tableName, uniqueCols, updated, String(row[pkCol]));
// v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录 // v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录
const newPk = String(updated[pkCol]); const newPk = String(updated[pkCol]);
const pkChanged = newPk !== String(row[pkCol]); const pkChanged = newPk !== String(row[pkCol]);
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY
// (此前静默覆盖另一行丢数据;与 MemoryEngine 对齐)
if (pkChanged) {
const newKey = `${tableName}:${newPk}`;
const existing = this.currentTxnId
? (this.txnSnapshot?.get(newKey) ?? this.lsm.get(newKey))
: this.lsm.get(newKey);
if (existing && !existing.__txn_deleted) {
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
}
}
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, String(row[pkCol]), newPk, walRecords, visited);
@@ -6617,7 +6739,9 @@ class AriaEngine {
data: updated, data: updated,
}); });
// 更新二级索引(主键变更时旧索引条目一并清理) // 更新二级索引(主键变更时旧索引条目一并清理)
this.updateSecondaryIndexes(tableName, newPk, updated, pkChanged ? row : null); // v0.6.2-fix: 此前非主键更新不传旧行 → 旧索引条目残留
// (唯一性检查误报 / 索引存储膨胀);现在统一传旧行清理旧值
this.updateSecondaryIndexes(tableName, newPk, updated, row);
} }
} }
await this.wal.appendBatch(walRecords); await this.wal.appendBatch(walRecords);
@@ -7432,6 +7556,40 @@ class AriaEngine {
// ======================================================================= // =======================================================================
// 二级索引 // 二级索引
// ======================================================================= // =======================================================================
/** v0.6.2: 表中有 unique 约束且索引 LSM 已建的列(唯一性检查范围) */
uniqueColumns(tableName, schema) {
const cols = [];
for (const [colName, colDef] of Object.entries(schema.columns)) {
if (!colDef.unique)
continue;
if (this.secondaryIndexes.has(`${tableName}:idx:${colName}`))
cols.push(colName);
}
return cols;
}
/**
* v0.6.2: 同步唯一性检查须在批次级 prefetchPrefixRanges 之后调用循环内无 await
* 索引不含 null 条目null 值不受唯一约束 MemoryEngine 语义一致
* @param currentPk 当前行主键更新路径用于排除自身旧索引条目插入路径无自身条目
*/
checkUniqueSync(tableName, uniqueCols, row, currentPk) {
for (const colName of uniqueCols) {
const val = row[colName];
if (val === undefined || val === null)
continue;
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`);
if (!idxLsm)
continue;
const prefix = `${String(val)}:`;
const entries = idxLsm.rangeScan(prefix, `${prefix}\uffff`);
for (const [, entry] of entries) {
const pk = entry.pk;
if (pk !== undefined && pk !== currentPk) {
throw new DatabaseError(`Unique constraint violation on column "${colName}" in table "${tableName}"`, 'UNIQUE_VIOLATION');
}
}
}
}
/** 更新行的二级索引条目 */ /** 更新行的二级索引条目 */
updateSecondaryIndexes(tableName, pkValue, newRow, oldRow) { updateSecondaryIndexes(tableName, pkValue, newRow, oldRow) {
const schema = this.schemas.get(tableName); const schema = this.schemas.get(tableName);
@@ -7531,15 +7689,25 @@ class AriaEngine {
continue; continue;
// $eq → 精确查找 // $eq → 精确查找
if (typeof condition !== 'object' || condition === null) { if (typeof condition !== 'object' || condition === null) {
// v0.6.2-fix(P1): IS NULL(条件为 null)不走索引 —— 索引不含 null 条目,
// String(null)="null" 查找返回空并短路全表 → 索引列 IS NULL 恒空
if (condition === null)
continue;
return this.indexScanToRows(tableName, pkCol, idxLsm, String(condition), String(condition)); return this.indexScanToRows(tableName, pkCol, idxLsm, String(condition), String(condition));
} }
const c = condition; const c = condition;
if ('$eq' in c) { if ('$eq' in c) {
// v0.6.2-fix(P1): 同上,$eq: nullIS NULL)不走索引
if (c.$eq === null)
continue;
const v = String(c.$eq); const v = String(c.$eq);
return this.indexScanToRows(tableName, pkCol, idxLsm, v, v); return this.indexScanToRows(tableName, pkCol, idxLsm, v, v);
} }
// $in → 多次精确查找 // $in → 多次精确查找
if ('$in' in c && Array.isArray(c.$in)) { if ('$in' in c && Array.isArray(c.$in)) {
// v0.6.2-fix(P1): IN 列表含 null 不走索引(索引不含 null 条目,会漏匹配 null 行)
if (c.$in.some((v) => v === null))
continue;
const results = []; const results = [];
const seenPks = new Set(); // v0.4.1: IN 值可能重复,按 pk 去重 const seenPks = new Set(); // v0.4.1: IN 值可能重复,按 pk 去重
for (const val of c.$in) { for (const val of c.$in) {
@@ -7556,17 +7724,12 @@ class AriaEngine {
} }
// $gt / $gte / $lt / $lte → 范围扫描 // $gt / $gte / $lt / $lte → 范围扫描
if ('$gt' in c || '$gte' in c || '$lt' in c || '$lte' in c) { if ('$gt' in c || '$gte' in c || '$lt' in c || '$lte' in c) {
let startKey = ''; // v0.6.2-fix(P1): 此前用 Number(v)±1 构造边界 key —— 小数数值
let endKey = '\uffff'; // $gt:2 → "3:",漏 2.5)与字符串("NaN:" 前缀错位,数字/大写开头值被漏)
if (c.$gt !== undefined) // 静默丢数据。改为全索引扫描 + 行级 matchWhere 过滤(与主键范围路径同方案),
startKey = `${String(Number(c.$gt) + 1)}:`; // 边界语义与 where-matcher 完全一致。
else if (c.$gte !== undefined) const rows = await this.indexScanToRows(tableName, pkCol, idxLsm, '', '\uffff');
startKey = `${String(c.$gte)}:`; return rows.filter((row) => matchWhere(row, { [col]: condition }));
if (c.$lt !== undefined)
endKey = `${String(Number(c.$lt) - 1)}:\uffff`;
else if (c.$lte !== undefined)
endKey = `${String(c.$lte)}:\uffff`;
return this.indexScanToRows(tableName, pkCol, idxLsm, startKey, endKey);
} }
} }
return null; return null;
@@ -10010,12 +10173,27 @@ class QueryExecutor {
async executeExplain(stmt) { async executeExplain(stmt) {
const startTime = Date.now(); const startTime = Date.now();
let result = null; let result = null;
let rows = 0;
// v0.6.2-fix: EXPLAIN 不得真实执行写语句 —— 此前 EXPLAIN DELETE/UPDATE 会产生
// 真实副作用(删/改数据)。仅 SELECT 类语句执行(只读);UPDATE/DELETE 用
// count 估算影响行数(无副作用);INSERT/DDL 仅输出计划不执行。
if (stmt.query.type === 'SELECT' || stmt.query.type === 'SELECT_UNION') {
try { try {
result = await this.execute(stmt.query); result = await this.execute(stmt.query);
} }
catch { /* explain 即使执行失败也返回计划 */ } catch { /* explain 即使执行失败也返回计划 */ }
rows = Array.isArray(result) ? result.length : 0;
}
else if (stmt.query.type === 'UPDATE' || stmt.query.type === 'DELETE') {
try {
const plan = compileStatement(stmt.query);
rows = await this.engine.count(plan.table, plan);
}
catch {
rows = 0;
}
}
const elapsed = Date.now() - startTime; const elapsed = Date.now() - startTime;
const rows = Array.isArray(result) ? result.length : 0;
// v0.5.1: 仅 SELECT/DELETE/UPDATE 有引擎查询计划;其他语句输出基本信息 // v0.5.1: 仅 SELECT/DELETE/UPDATE 有引擎查询计划;其他语句输出基本信息
let plan = null; let plan = null;
try { try {
+1 -1
View File
File diff suppressed because one or more lines are too long
+9 -1
View File
@@ -164,7 +164,7 @@ interface MetonaPlugin {
/** 销毁 */ /** 销毁 */
destroy(): void; destroy(): void;
} }
declare const VERSION = "0.6.1"; declare const VERSION = "0.6.2";
/** /**
* metona-sqlark Plugin — 插件系统 * metona-sqlark Plugin — 插件系统
@@ -1075,6 +1075,14 @@ declare class AriaEngine implements IStorageEngine {
* 导致崩溃后"已删除的表和数据复活"(实证 P0 bug)。 * 导致崩溃后"已删除的表和数据复活"(实证 P0 bug)。
*/ */
private applyDropTableRecovery; private applyDropTableRecovery;
/** v0.6.2: 表中有 unique 约束且索引 LSM 已建的列(唯一性检查范围) */
private uniqueColumns;
/**
* v0.6.2: 同步唯一性检查(须在批次级 prefetchPrefixRanges 之后调用,循环内无 await)。
* 索引不含 null 条目(null 值不受唯一约束,与 MemoryEngine 语义一致)。
* @param currentPk 当前行主键(更新路径用于排除自身旧索引条目;插入路径无自身条目)
*/
private checkUniqueSync;
/** 更新行的二级索引条目 */ /** 更新行的二级索引条目 */
private updateSecondaryIndexes; private updateSecondaryIndexes;
/** 通过二级索引快速查找 */ /** 通过二级索引快速查找 */
+201 -23
View File
@@ -30,7 +30,7 @@ class DatabaseError extends Error {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 版本 // 版本
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const VERSION = '0.6.1'; const VERSION = '0.6.2';
/** /**
* metona-sqlark Shared WHERE Matcher 统一的条件匹配逻辑 * metona-sqlark Shared WHERE Matcher 统一的条件匹配逻辑
@@ -395,6 +395,10 @@ class MemoryEngine {
this.validateRow(schema, updated); this.validateRow(schema, updated);
this.checkUniqueness(schema, updated); this.checkUniqueness(schema, updated);
const newPk = String(updated[pkCol]); const newPk = String(updated[pkCol]);
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据)
if (newPk !== pk && table.has(newPk)) {
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
}
// v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表 // v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
if (newPk !== pk) { if (newPk !== pk) {
await this.applyUpdateCascade(tableName, pk, newPk); await this.applyUpdateCascade(tableName, pk, newPk);
@@ -2021,16 +2025,22 @@ class KVStoreEngine {
} }
} }
else { else {
// 增量重写受影响行 // v0.6.2-fix(P0): 受影响主键经 String() 化后按 `where { [pkCol]: pk }` 回查内存行,
for (const pk of affected) { // 数值型主键(123 !== "123")不命中 → 行被误判删除 → 重启丢数据。
const row = await this.memory.find(tableName, { table: tableName, where: { [pkCol]: pk } }); // 改为单次全表扫描 + 受影响集合过滤(同时消除此前 O(N×M) 逐主键回查开销)。
if (row.length > 0) { const affectedSet = new Set(affected);
puts[this.rowKey(tableName, pk)] = enc(JSON.stringify(row[0])); const allRows = await this.memory.find(tableName, { table: tableName });
for (const row of allRows) {
const pkStr = String(row[pkCol]);
if (affectedSet.has(pkStr)) {
puts[this.rowKey(tableName, pkStr)] = enc(JSON.stringify(row));
affectedSet.delete(pkStr);
} }
else { }
// 剩余主键(内存中已不存在,如被级联移除)→ 删除对应 KV 行
for (const pk of affectedSet) {
deletes.push(this.rowKey(tableName, pk)); deletes.push(this.rowKey(tableName, pk));
} }
}
// 级联影响表(SET NULL/CASCADE 外键)整表 diff // 级联影响表(SET NULL/CASCADE 外键)整表 diff
for (const t of await this.affectedTables(tableName)) { for (const t of await this.affectedTables(tableName)) {
if (t === tableName) if (t === tableName)
@@ -3912,6 +3922,46 @@ class LSM {
await this.preloadSSTable(id, meta); await this.preloadSSTable(id, meta);
} }
} }
/**
* v0.6.2: 批量预加载多个前缀范围可能命中的所有 SSTable一次 drainChain
* 唯一性检查等"每行一个窄前缀范围"场景用meta 遍历只做一次
* 避免逐行调用 prefetchRange 时每行 drainChain 的性能悬崖
*/
async prefetchPrefixRanges(ranges) {
if (ranges.length === 0)
return;
// v0.6.2: 去重(大批量插入时唯一列值可能重复),减少 meta 遍历开销
const seen = new Set();
const unique = [];
for (const r of ranges) {
const k = `${r[0]}\u0000${r[1]}`;
if (!seen.has(k)) {
seen.add(k);
unique.push(r);
}
}
if (unique.length === 0)
return;
await this.drainChain();
this.trimCache();
const toLoad = new Set();
for (let level = 0; level < MAX_LSM_LEVELS; level++) {
for (const meta of this.levels[level]) {
if (this.sstableCache.has(meta.id))
continue;
for (const [startKey, endKey] of unique) {
if (endKey < meta.minKey || startKey > meta.maxKey)
continue;
toLoad.add(meta.id);
break;
}
}
}
for (const id of toLoad) {
const meta = this.findMetaById(id);
await this.preloadSSTable(id, meta);
}
}
/** 按 id 查找 SSTable 元数据(prefetch 预加载用) */ /** 按 id 查找 SSTable 元数据(prefetch 预加载用) */
findMetaById(id) { findMetaById(id) {
for (let level = 0; level < MAX_LSM_LEVELS; level++) { for (let level = 0; level < MAX_LSM_LEVELS; level++) {
@@ -6487,11 +6537,50 @@ class AriaEngine {
// 后台 compaction 在链上数秒时每行阻塞数秒 → 大数据量插入性能悬崖 // 后台 compaction 在链上数秒时每行阻塞数秒 → 大数据量插入性能悬崖
// 10 万行 kv 后端从 5ms/批暴跌到 8~11s/批)。批内新数据在 memtable // 10 万行 kv 后端从 5ms/批暴跌到 8~11s/批)。批内新数据在 memtable
// 或 flush 产物(自动入缓存),循环内 lsm.get 始终完整。 // 或 flush 产物(自动入缓存),循环内 lsm.get 始终完整。
await this.lsm.prefetchKeys(rows.map((r) => `${tableName}:${String(r[pkCol])}`)); //
// v0.6.2: 整批预校验(验证失败整批不落库,语义更原子)+ 唯一约束检查。
const uniqueCols = this.uniqueColumns(tableName, schema);
const validatedRows = [];
for (const row of rows) { for (const row of rows) {
const validated = this.validateRow(schema, row); const validated = this.validateRow(schema, row);
const pkValue = String(validated[pkCol]); const pkValue = String(validated[pkCol]);
const key = `${tableName}:${pkValue}`; validatedRows.push({ row: validated, pkValue, key: `${tableName}:${pkValue}` });
}
await this.lsm.prefetchKeys(validatedRows.map((v) => v.key));
// v0.6.2: 唯一约束 — 批量预加载本批唯一列涉及的索引范围(一次 drainChain
for (const colName of uniqueCols) {
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`);
await idxLsm.prefetchPrefixRanges(validatedRows
.map((v) => {
const val = v.row[colName];
if (val === undefined || val === null)
return null;
const p = `${String(val)}:`;
return [p, `${p}\uffff`];
})
.filter((r) => r !== null));
}
// v0.6.2: 唯一性整批预检(批内互查 + 索引查)—— 失败整批不落库(原子语义)
const batchUnique = new Map();
for (const { row: validated, pkValue } of validatedRows) {
for (const colName of uniqueCols) {
const val = validated[colName];
if (val === undefined || val === null)
continue;
const v = String(val);
let seen = batchUnique.get(colName);
if (!seen) {
seen = new Set();
batchUnique.set(colName, seen);
}
if (seen.has(v)) {
throw new DatabaseError(`Unique constraint violation on column "${colName}" in table "${tableName}"`, 'UNIQUE_VIOLATION');
}
seen.add(v);
this.checkUniqueSync(tableName, [colName], validated, pkValue);
}
}
for (const { row: validated, pkValue, key } of validatedRows) {
// Check duplicate in LSM + transaction snapshot // Check duplicate in LSM + transaction snapshot
const existing = this.currentTxnId const existing = this.currentTxnId
? (this.txnSnapshot?.get(key) ?? this.lsm.get(key)) ? (this.txnSnapshot?.get(key) ?? this.lsm.get(key))
@@ -6570,15 +6659,48 @@ 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.6.2: 唯一约束 — 批量预加载本批更新涉及的唯一列索引范围(一次 drainChain
const uniqueCols = this.uniqueColumns(tableName, schema);
for (const colName of uniqueCols) {
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`);
const ranges = [];
if (updates[colName] !== undefined && updates[colName] !== null) {
const p = `${String(updates[colName])}:`;
ranges.push([p, `${p}\uffff`]);
}
else if (!(colName in updates)) {
for (const row of rows) {
const val = row[colName];
if (val === undefined || val === null)
continue;
const p = `${String(val)}:`;
ranges.push([p, `${p}\uffff`]);
}
}
await idxLsm.prefetchPrefixRanges(ranges);
}
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 }; const updated = { ...row, ...updates };
this.validateRow(schema, updated); this.validateRow(schema, updated);
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在)
this.checkUniqueSync(tableName, uniqueCols, updated, String(row[pkCol]));
// v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录 // v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录
const newPk = String(updated[pkCol]); const newPk = String(updated[pkCol]);
const pkChanged = newPk !== String(row[pkCol]); const pkChanged = newPk !== String(row[pkCol]);
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY
// (此前静默覆盖另一行丢数据;与 MemoryEngine 对齐)
if (pkChanged) {
const newKey = `${tableName}:${newPk}`;
const existing = this.currentTxnId
? (this.txnSnapshot?.get(newKey) ?? this.lsm.get(newKey))
: this.lsm.get(newKey);
if (existing && !existing.__txn_deleted) {
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
}
}
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, String(row[pkCol]), newPk, walRecords, visited);
@@ -6613,7 +6735,9 @@ class AriaEngine {
data: updated, data: updated,
}); });
// 更新二级索引(主键变更时旧索引条目一并清理) // 更新二级索引(主键变更时旧索引条目一并清理)
this.updateSecondaryIndexes(tableName, newPk, updated, pkChanged ? row : null); // v0.6.2-fix: 此前非主键更新不传旧行 → 旧索引条目残留
// (唯一性检查误报 / 索引存储膨胀);现在统一传旧行清理旧值
this.updateSecondaryIndexes(tableName, newPk, updated, row);
} }
} }
await this.wal.appendBatch(walRecords); await this.wal.appendBatch(walRecords);
@@ -7428,6 +7552,40 @@ class AriaEngine {
// ======================================================================= // =======================================================================
// 二级索引 // 二级索引
// ======================================================================= // =======================================================================
/** v0.6.2: 表中有 unique 约束且索引 LSM 已建的列(唯一性检查范围) */
uniqueColumns(tableName, schema) {
const cols = [];
for (const [colName, colDef] of Object.entries(schema.columns)) {
if (!colDef.unique)
continue;
if (this.secondaryIndexes.has(`${tableName}:idx:${colName}`))
cols.push(colName);
}
return cols;
}
/**
* v0.6.2: 同步唯一性检查须在批次级 prefetchPrefixRanges 之后调用循环内无 await
* 索引不含 null 条目null 值不受唯一约束 MemoryEngine 语义一致
* @param currentPk 当前行主键更新路径用于排除自身旧索引条目插入路径无自身条目
*/
checkUniqueSync(tableName, uniqueCols, row, currentPk) {
for (const colName of uniqueCols) {
const val = row[colName];
if (val === undefined || val === null)
continue;
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`);
if (!idxLsm)
continue;
const prefix = `${String(val)}:`;
const entries = idxLsm.rangeScan(prefix, `${prefix}\uffff`);
for (const [, entry] of entries) {
const pk = entry.pk;
if (pk !== undefined && pk !== currentPk) {
throw new DatabaseError(`Unique constraint violation on column "${colName}" in table "${tableName}"`, 'UNIQUE_VIOLATION');
}
}
}
}
/** 更新行的二级索引条目 */ /** 更新行的二级索引条目 */
updateSecondaryIndexes(tableName, pkValue, newRow, oldRow) { updateSecondaryIndexes(tableName, pkValue, newRow, oldRow) {
const schema = this.schemas.get(tableName); const schema = this.schemas.get(tableName);
@@ -7527,15 +7685,25 @@ class AriaEngine {
continue; continue;
// $eq → 精确查找 // $eq → 精确查找
if (typeof condition !== 'object' || condition === null) { if (typeof condition !== 'object' || condition === null) {
// v0.6.2-fix(P1): IS NULL(条件为 null)不走索引 —— 索引不含 null 条目,
// String(null)="null" 查找返回空并短路全表 → 索引列 IS NULL 恒空
if (condition === null)
continue;
return this.indexScanToRows(tableName, pkCol, idxLsm, String(condition), String(condition)); return this.indexScanToRows(tableName, pkCol, idxLsm, String(condition), String(condition));
} }
const c = condition; const c = condition;
if ('$eq' in c) { if ('$eq' in c) {
// v0.6.2-fix(P1): 同上,$eq: nullIS NULL)不走索引
if (c.$eq === null)
continue;
const v = String(c.$eq); const v = String(c.$eq);
return this.indexScanToRows(tableName, pkCol, idxLsm, v, v); return this.indexScanToRows(tableName, pkCol, idxLsm, v, v);
} }
// $in → 多次精确查找 // $in → 多次精确查找
if ('$in' in c && Array.isArray(c.$in)) { if ('$in' in c && Array.isArray(c.$in)) {
// v0.6.2-fix(P1): IN 列表含 null 不走索引(索引不含 null 条目,会漏匹配 null 行)
if (c.$in.some((v) => v === null))
continue;
const results = []; const results = [];
const seenPks = new Set(); // v0.4.1: IN 值可能重复,按 pk 去重 const seenPks = new Set(); // v0.4.1: IN 值可能重复,按 pk 去重
for (const val of c.$in) { for (const val of c.$in) {
@@ -7552,17 +7720,12 @@ class AriaEngine {
} }
// $gt / $gte / $lt / $lte → 范围扫描 // $gt / $gte / $lt / $lte → 范围扫描
if ('$gt' in c || '$gte' in c || '$lt' in c || '$lte' in c) { if ('$gt' in c || '$gte' in c || '$lt' in c || '$lte' in c) {
let startKey = ''; // v0.6.2-fix(P1): 此前用 Number(v)±1 构造边界 key —— 小数数值
let endKey = '\uffff'; // $gt:2 → "3:",漏 2.5)与字符串("NaN:" 前缀错位,数字/大写开头值被漏)
if (c.$gt !== undefined) // 静默丢数据。改为全索引扫描 + 行级 matchWhere 过滤(与主键范围路径同方案),
startKey = `${String(Number(c.$gt) + 1)}:`; // 边界语义与 where-matcher 完全一致。
else if (c.$gte !== undefined) const rows = await this.indexScanToRows(tableName, pkCol, idxLsm, '', '\uffff');
startKey = `${String(c.$gte)}:`; return rows.filter((row) => matchWhere(row, { [col]: condition }));
if (c.$lt !== undefined)
endKey = `${String(Number(c.$lt) - 1)}:\uffff`;
else if (c.$lte !== undefined)
endKey = `${String(c.$lte)}:\uffff`;
return this.indexScanToRows(tableName, pkCol, idxLsm, startKey, endKey);
} }
} }
return null; return null;
@@ -10006,12 +10169,27 @@ class QueryExecutor {
async executeExplain(stmt) { async executeExplain(stmt) {
const startTime = Date.now(); const startTime = Date.now();
let result = null; let result = null;
let rows = 0;
// v0.6.2-fix: EXPLAIN 不得真实执行写语句 —— 此前 EXPLAIN DELETE/UPDATE 会产生
// 真实副作用(删/改数据)。仅 SELECT 类语句执行(只读);UPDATE/DELETE 用
// count 估算影响行数(无副作用);INSERT/DDL 仅输出计划不执行。
if (stmt.query.type === 'SELECT' || stmt.query.type === 'SELECT_UNION') {
try { try {
result = await this.execute(stmt.query); result = await this.execute(stmt.query);
} }
catch { /* explain 即使执行失败也返回计划 */ } catch { /* explain 即使执行失败也返回计划 */ }
rows = Array.isArray(result) ? result.length : 0;
}
else if (stmt.query.type === 'UPDATE' || stmt.query.type === 'DELETE') {
try {
const plan = compileStatement(stmt.query);
rows = await this.engine.count(plan.table, plan);
}
catch {
rows = 0;
}
}
const elapsed = Date.now() - startTime; const elapsed = Date.now() - startTime;
const rows = Array.isArray(result) ? result.length : 0;
// v0.5.1: 仅 SELECT/DELETE/UPDATE 有引擎查询计划;其他语句输出基本信息 // v0.5.1: 仅 SELECT/DELETE/UPDATE 有引擎查询计划;其他语句输出基本信息
let plan = null; let plan = null;
try { try {
+1 -1
View File
File diff suppressed because one or more lines are too long
+201 -23
View File
@@ -36,7 +36,7 @@
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 版本 // 版本
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const VERSION = '0.6.1'; const VERSION = '0.6.2';
/** /**
* metona-sqlark Shared WHERE Matcher 统一的条件匹配逻辑 * metona-sqlark Shared WHERE Matcher 统一的条件匹配逻辑
@@ -401,6 +401,10 @@
this.validateRow(schema, updated); this.validateRow(schema, updated);
this.checkUniqueness(schema, updated); this.checkUniqueness(schema, updated);
const newPk = String(updated[pkCol]); const newPk = String(updated[pkCol]);
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据)
if (newPk !== pk && table.has(newPk)) {
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
}
// v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表 // v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
if (newPk !== pk) { if (newPk !== pk) {
await this.applyUpdateCascade(tableName, pk, newPk); await this.applyUpdateCascade(tableName, pk, newPk);
@@ -2027,16 +2031,22 @@
} }
} }
else { else {
// 增量重写受影响行 // v0.6.2-fix(P0): 受影响主键经 String() 化后按 `where { [pkCol]: pk }` 回查内存行,
for (const pk of affected) { // 数值型主键(123 !== "123")不命中 → 行被误判删除 → 重启丢数据。
const row = await this.memory.find(tableName, { table: tableName, where: { [pkCol]: pk } }); // 改为单次全表扫描 + 受影响集合过滤(同时消除此前 O(N×M) 逐主键回查开销)。
if (row.length > 0) { const affectedSet = new Set(affected);
puts[this.rowKey(tableName, pk)] = enc(JSON.stringify(row[0])); const allRows = await this.memory.find(tableName, { table: tableName });
for (const row of allRows) {
const pkStr = String(row[pkCol]);
if (affectedSet.has(pkStr)) {
puts[this.rowKey(tableName, pkStr)] = enc(JSON.stringify(row));
affectedSet.delete(pkStr);
} }
else { }
// 剩余主键(内存中已不存在,如被级联移除)→ 删除对应 KV 行
for (const pk of affectedSet) {
deletes.push(this.rowKey(tableName, pk)); deletes.push(this.rowKey(tableName, pk));
} }
}
// 级联影响表(SET NULL/CASCADE 外键)整表 diff // 级联影响表(SET NULL/CASCADE 外键)整表 diff
for (const t of await this.affectedTables(tableName)) { for (const t of await this.affectedTables(tableName)) {
if (t === tableName) if (t === tableName)
@@ -3918,6 +3928,46 @@
await this.preloadSSTable(id, meta); await this.preloadSSTable(id, meta);
} }
} }
/**
* v0.6.2: 批量预加载多个前缀范围可能命中的所有 SSTable一次 drainChain
* 唯一性检查等"每行一个窄前缀范围"场景用meta 遍历只做一次
* 避免逐行调用 prefetchRange 时每行 drainChain 的性能悬崖
*/
async prefetchPrefixRanges(ranges) {
if (ranges.length === 0)
return;
// v0.6.2: 去重(大批量插入时唯一列值可能重复),减少 meta 遍历开销
const seen = new Set();
const unique = [];
for (const r of ranges) {
const k = `${r[0]}\u0000${r[1]}`;
if (!seen.has(k)) {
seen.add(k);
unique.push(r);
}
}
if (unique.length === 0)
return;
await this.drainChain();
this.trimCache();
const toLoad = new Set();
for (let level = 0; level < MAX_LSM_LEVELS; level++) {
for (const meta of this.levels[level]) {
if (this.sstableCache.has(meta.id))
continue;
for (const [startKey, endKey] of unique) {
if (endKey < meta.minKey || startKey > meta.maxKey)
continue;
toLoad.add(meta.id);
break;
}
}
}
for (const id of toLoad) {
const meta = this.findMetaById(id);
await this.preloadSSTable(id, meta);
}
}
/** 按 id 查找 SSTable 元数据(prefetch 预加载用) */ /** 按 id 查找 SSTable 元数据(prefetch 预加载用) */
findMetaById(id) { findMetaById(id) {
for (let level = 0; level < MAX_LSM_LEVELS; level++) { for (let level = 0; level < MAX_LSM_LEVELS; level++) {
@@ -6493,11 +6543,50 @@
// 后台 compaction 在链上数秒时每行阻塞数秒 → 大数据量插入性能悬崖 // 后台 compaction 在链上数秒时每行阻塞数秒 → 大数据量插入性能悬崖
// 10 万行 kv 后端从 5ms/批暴跌到 8~11s/批)。批内新数据在 memtable // 10 万行 kv 后端从 5ms/批暴跌到 8~11s/批)。批内新数据在 memtable
// 或 flush 产物(自动入缓存),循环内 lsm.get 始终完整。 // 或 flush 产物(自动入缓存),循环内 lsm.get 始终完整。
await this.lsm.prefetchKeys(rows.map((r) => `${tableName}:${String(r[pkCol])}`)); //
// v0.6.2: 整批预校验(验证失败整批不落库,语义更原子)+ 唯一约束检查。
const uniqueCols = this.uniqueColumns(tableName, schema);
const validatedRows = [];
for (const row of rows) { for (const row of rows) {
const validated = this.validateRow(schema, row); const validated = this.validateRow(schema, row);
const pkValue = String(validated[pkCol]); const pkValue = String(validated[pkCol]);
const key = `${tableName}:${pkValue}`; validatedRows.push({ row: validated, pkValue, key: `${tableName}:${pkValue}` });
}
await this.lsm.prefetchKeys(validatedRows.map((v) => v.key));
// v0.6.2: 唯一约束 — 批量预加载本批唯一列涉及的索引范围(一次 drainChain
for (const colName of uniqueCols) {
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`);
await idxLsm.prefetchPrefixRanges(validatedRows
.map((v) => {
const val = v.row[colName];
if (val === undefined || val === null)
return null;
const p = `${String(val)}:`;
return [p, `${p}\uffff`];
})
.filter((r) => r !== null));
}
// v0.6.2: 唯一性整批预检(批内互查 + 索引查)—— 失败整批不落库(原子语义)
const batchUnique = new Map();
for (const { row: validated, pkValue } of validatedRows) {
for (const colName of uniqueCols) {
const val = validated[colName];
if (val === undefined || val === null)
continue;
const v = String(val);
let seen = batchUnique.get(colName);
if (!seen) {
seen = new Set();
batchUnique.set(colName, seen);
}
if (seen.has(v)) {
throw new DatabaseError(`Unique constraint violation on column "${colName}" in table "${tableName}"`, 'UNIQUE_VIOLATION');
}
seen.add(v);
this.checkUniqueSync(tableName, [colName], validated, pkValue);
}
}
for (const { row: validated, pkValue, key } of validatedRows) {
// Check duplicate in LSM + transaction snapshot // Check duplicate in LSM + transaction snapshot
const existing = this.currentTxnId const existing = this.currentTxnId
? (this.txnSnapshot?.get(key) ?? this.lsm.get(key)) ? (this.txnSnapshot?.get(key) ?? this.lsm.get(key))
@@ -6576,15 +6665,48 @@
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.6.2: 唯一约束 — 批量预加载本批更新涉及的唯一列索引范围(一次 drainChain
const uniqueCols = this.uniqueColumns(tableName, schema);
for (const colName of uniqueCols) {
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`);
const ranges = [];
if (updates[colName] !== undefined && updates[colName] !== null) {
const p = `${String(updates[colName])}:`;
ranges.push([p, `${p}\uffff`]);
}
else if (!(colName in updates)) {
for (const row of rows) {
const val = row[colName];
if (val === undefined || val === null)
continue;
const p = `${String(val)}:`;
ranges.push([p, `${p}\uffff`]);
}
}
await idxLsm.prefetchPrefixRanges(ranges);
}
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 }; const updated = { ...row, ...updates };
this.validateRow(schema, updated); this.validateRow(schema, updated);
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在)
this.checkUniqueSync(tableName, uniqueCols, updated, String(row[pkCol]));
// v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录 // v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录
const newPk = String(updated[pkCol]); const newPk = String(updated[pkCol]);
const pkChanged = newPk !== String(row[pkCol]); const pkChanged = newPk !== String(row[pkCol]);
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY
// (此前静默覆盖另一行丢数据;与 MemoryEngine 对齐)
if (pkChanged) {
const newKey = `${tableName}:${newPk}`;
const existing = this.currentTxnId
? (this.txnSnapshot?.get(newKey) ?? this.lsm.get(newKey))
: this.lsm.get(newKey);
if (existing && !existing.__txn_deleted) {
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
}
}
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, String(row[pkCol]), newPk, walRecords, visited);
@@ -6619,7 +6741,9 @@
data: updated, data: updated,
}); });
// 更新二级索引(主键变更时旧索引条目一并清理) // 更新二级索引(主键变更时旧索引条目一并清理)
this.updateSecondaryIndexes(tableName, newPk, updated, pkChanged ? row : null); // v0.6.2-fix: 此前非主键更新不传旧行 → 旧索引条目残留
// (唯一性检查误报 / 索引存储膨胀);现在统一传旧行清理旧值
this.updateSecondaryIndexes(tableName, newPk, updated, row);
} }
} }
await this.wal.appendBatch(walRecords); await this.wal.appendBatch(walRecords);
@@ -7434,6 +7558,40 @@
// ======================================================================= // =======================================================================
// 二级索引 // 二级索引
// ======================================================================= // =======================================================================
/** v0.6.2: 表中有 unique 约束且索引 LSM 已建的列(唯一性检查范围) */
uniqueColumns(tableName, schema) {
const cols = [];
for (const [colName, colDef] of Object.entries(schema.columns)) {
if (!colDef.unique)
continue;
if (this.secondaryIndexes.has(`${tableName}:idx:${colName}`))
cols.push(colName);
}
return cols;
}
/**
* v0.6.2: 同步唯一性检查须在批次级 prefetchPrefixRanges 之后调用循环内无 await
* 索引不含 null 条目null 值不受唯一约束 MemoryEngine 语义一致
* @param currentPk 当前行主键更新路径用于排除自身旧索引条目插入路径无自身条目
*/
checkUniqueSync(tableName, uniqueCols, row, currentPk) {
for (const colName of uniqueCols) {
const val = row[colName];
if (val === undefined || val === null)
continue;
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`);
if (!idxLsm)
continue;
const prefix = `${String(val)}:`;
const entries = idxLsm.rangeScan(prefix, `${prefix}\uffff`);
for (const [, entry] of entries) {
const pk = entry.pk;
if (pk !== undefined && pk !== currentPk) {
throw new DatabaseError(`Unique constraint violation on column "${colName}" in table "${tableName}"`, 'UNIQUE_VIOLATION');
}
}
}
}
/** 更新行的二级索引条目 */ /** 更新行的二级索引条目 */
updateSecondaryIndexes(tableName, pkValue, newRow, oldRow) { updateSecondaryIndexes(tableName, pkValue, newRow, oldRow) {
const schema = this.schemas.get(tableName); const schema = this.schemas.get(tableName);
@@ -7533,15 +7691,25 @@
continue; continue;
// $eq → 精确查找 // $eq → 精确查找
if (typeof condition !== 'object' || condition === null) { if (typeof condition !== 'object' || condition === null) {
// v0.6.2-fix(P1): IS NULL(条件为 null)不走索引 —— 索引不含 null 条目,
// String(null)="null" 查找返回空并短路全表 → 索引列 IS NULL 恒空
if (condition === null)
continue;
return this.indexScanToRows(tableName, pkCol, idxLsm, String(condition), String(condition)); return this.indexScanToRows(tableName, pkCol, idxLsm, String(condition), String(condition));
} }
const c = condition; const c = condition;
if ('$eq' in c) { if ('$eq' in c) {
// v0.6.2-fix(P1): 同上,$eq: nullIS NULL)不走索引
if (c.$eq === null)
continue;
const v = String(c.$eq); const v = String(c.$eq);
return this.indexScanToRows(tableName, pkCol, idxLsm, v, v); return this.indexScanToRows(tableName, pkCol, idxLsm, v, v);
} }
// $in → 多次精确查找 // $in → 多次精确查找
if ('$in' in c && Array.isArray(c.$in)) { if ('$in' in c && Array.isArray(c.$in)) {
// v0.6.2-fix(P1): IN 列表含 null 不走索引(索引不含 null 条目,会漏匹配 null 行)
if (c.$in.some((v) => v === null))
continue;
const results = []; const results = [];
const seenPks = new Set(); // v0.4.1: IN 值可能重复,按 pk 去重 const seenPks = new Set(); // v0.4.1: IN 值可能重复,按 pk 去重
for (const val of c.$in) { for (const val of c.$in) {
@@ -7558,17 +7726,12 @@
} }
// $gt / $gte / $lt / $lte → 范围扫描 // $gt / $gte / $lt / $lte → 范围扫描
if ('$gt' in c || '$gte' in c || '$lt' in c || '$lte' in c) { if ('$gt' in c || '$gte' in c || '$lt' in c || '$lte' in c) {
let startKey = ''; // v0.6.2-fix(P1): 此前用 Number(v)±1 构造边界 key —— 小数数值
let endKey = '\uffff'; // $gt:2 → "3:",漏 2.5)与字符串("NaN:" 前缀错位,数字/大写开头值被漏)
if (c.$gt !== undefined) // 静默丢数据。改为全索引扫描 + 行级 matchWhere 过滤(与主键范围路径同方案),
startKey = `${String(Number(c.$gt) + 1)}:`; // 边界语义与 where-matcher 完全一致。
else if (c.$gte !== undefined) const rows = await this.indexScanToRows(tableName, pkCol, idxLsm, '', '\uffff');
startKey = `${String(c.$gte)}:`; return rows.filter((row) => matchWhere(row, { [col]: condition }));
if (c.$lt !== undefined)
endKey = `${String(Number(c.$lt) - 1)}:\uffff`;
else if (c.$lte !== undefined)
endKey = `${String(c.$lte)}:\uffff`;
return this.indexScanToRows(tableName, pkCol, idxLsm, startKey, endKey);
} }
} }
return null; return null;
@@ -10012,12 +10175,27 @@
async executeExplain(stmt) { async executeExplain(stmt) {
const startTime = Date.now(); const startTime = Date.now();
let result = null; let result = null;
let rows = 0;
// v0.6.2-fix: EXPLAIN 不得真实执行写语句 —— 此前 EXPLAIN DELETE/UPDATE 会产生
// 真实副作用(删/改数据)。仅 SELECT 类语句执行(只读);UPDATE/DELETE 用
// count 估算影响行数(无副作用);INSERT/DDL 仅输出计划不执行。
if (stmt.query.type === 'SELECT' || stmt.query.type === 'SELECT_UNION') {
try { try {
result = await this.execute(stmt.query); result = await this.execute(stmt.query);
} }
catch { /* explain 即使执行失败也返回计划 */ } catch { /* explain 即使执行失败也返回计划 */ }
rows = Array.isArray(result) ? result.length : 0;
}
else if (stmt.query.type === 'UPDATE' || stmt.query.type === 'DELETE') {
try {
const plan = compileStatement(stmt.query);
rows = await this.engine.count(plan.table, plan);
}
catch {
rows = 0;
}
}
const elapsed = Date.now() - startTime; const elapsed = Date.now() - startTime;
const rows = Array.isArray(result) ? result.length : 0;
// v0.5.1: 仅 SELECT/DELETE/UPDATE 有引擎查询计划;其他语句输出基本信息 // v0.5.1: 仅 SELECT/DELETE/UPDATE 有引擎查询计划;其他语句输出基本信息
let plan = null; let plan = null;
try { try {
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@metona-team/metona-sqlark", "name": "@metona-team/metona-sqlark",
"version": "0.6.1", "version": "0.6.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
View File
@@ -214,4 +214,4 @@ export class DatabaseError extends Error {
// 版本 // 版本
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const VERSION = '0.6.1'; export const VERSION = '0.6.2';
+138 -11
View File
@@ -542,13 +542,55 @@ export class AriaEngine implements IStorageEngine {
// 后台 compaction 在链上数秒时每行阻塞数秒 → 大数据量插入性能悬崖 // 后台 compaction 在链上数秒时每行阻塞数秒 → 大数据量插入性能悬崖
// 10 万行 kv 后端从 5ms/批暴跌到 8~11s/批)。批内新数据在 memtable // 10 万行 kv 后端从 5ms/批暴跌到 8~11s/批)。批内新数据在 memtable
// 或 flush 产物(自动入缓存),循环内 lsm.get 始终完整。 // 或 flush 产物(自动入缓存),循环内 lsm.get 始终完整。
await this.lsm.prefetchKeys(rows.map((r) => `${tableName}:${String(r[pkCol])}`)); //
// v0.6.2: 整批预校验(验证失败整批不落库,语义更原子)+ 唯一约束检查。
const uniqueCols = this.uniqueColumns(tableName, schema);
const validatedRows: { row: Record<string, unknown>; pkValue: string; key: string }[] = [];
for (const row of rows) { for (const row of rows) {
const validated = this.validateRow(schema, row); const validated = this.validateRow(schema, row);
const pkValue = String(validated[pkCol]); const pkValue = String(validated[pkCol]);
const key = `${tableName}:${pkValue}`; validatedRows.push({ row: validated, pkValue, key: `${tableName}:${pkValue}` });
}
await this.lsm.prefetchKeys(validatedRows.map((v) => v.key));
// v0.6.2: 唯一约束 — 批量预加载本批唯一列涉及的索引范围(一次 drainChain
for (const colName of uniqueCols) {
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`)!;
await idxLsm.prefetchPrefixRanges(
validatedRows
.map((v): [string, string] | null => {
const val = v.row[colName];
if (val === undefined || val === null) return null;
const p = `${String(val)}:`;
return [p, `${p}\uffff`];
})
.filter((r): r is [string, string] => r !== null),
);
}
// v0.6.2: 唯一性整批预检(批内互查 + 索引查)—— 失败整批不落库(原子语义)
const batchUnique = new Map<string, Set<string>>();
for (const { row: validated, pkValue } of validatedRows) {
for (const colName of uniqueCols) {
const val = validated[colName];
if (val === undefined || val === null) continue;
const v = String(val);
let seen = batchUnique.get(colName);
if (!seen) {
seen = new Set<string>();
batchUnique.set(colName, seen);
}
if (seen.has(v)) {
throw new DatabaseError(
`Unique constraint violation on column "${colName}" in table "${tableName}"`,
'UNIQUE_VIOLATION',
);
}
seen.add(v);
this.checkUniqueSync(tableName, [colName], validated, pkValue);
}
}
for (const { row: validated, pkValue, key } of validatedRows) {
// Check duplicate in LSM + transaction snapshot // Check duplicate in LSM + transaction snapshot
const existing = this.currentTxnId const existing = this.currentTxnId
? (this.txnSnapshot?.get(key) ?? this.lsm.get(key)) ? (this.txnSnapshot?.get(key) ?? this.lsm.get(key))
@@ -650,6 +692,25 @@ export class AriaEngine implements IStorageEngine {
// v0.4.2-fix: ON UPDATE 级联环路保护 // v0.4.2-fix: ON UPDATE 级联环路保护
const visited = new Set<string>(); const visited = new Set<string>();
// v0.6.2: 唯一约束 — 批量预加载本批更新涉及的唯一列索引范围(一次 drainChain
const uniqueCols = this.uniqueColumns(tableName, schema);
for (const colName of uniqueCols) {
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`)!;
const ranges: [string, string][] = [];
if (updates[colName] !== undefined && updates[colName] !== null) {
const p = `${String(updates[colName])}:`;
ranges.push([p, `${p}\uffff`]);
} else if (!(colName in updates)) {
for (const row of rows) {
const val = row[colName];
if (val === undefined || val === null) continue;
const p = `${String(val)}:`;
ranges.push([p, `${p}\uffff`]);
}
}
await idxLsm.prefetchPrefixRanges(ranges);
}
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]}`;
@@ -658,10 +719,28 @@ export class AriaEngine implements IStorageEngine {
const updated = { ...row, ...updates }; const updated = { ...row, ...updates };
this.validateRow(schema, updated); this.validateRow(schema, updated);
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在)
this.checkUniqueSync(tableName, uniqueCols, updated, String(row[pkCol]));
// v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录 // v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录
const newPk = String(updated[pkCol]); const newPk = String(updated[pkCol]);
const pkChanged = newPk !== String(row[pkCol]); const pkChanged = newPk !== String(row[pkCol]);
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY
// (此前静默覆盖另一行丢数据;与 MemoryEngine 对齐)
if (pkChanged) {
const newKey = `${tableName}:${newPk}`;
const existing = this.currentTxnId
? (this.txnSnapshot?.get(newKey) ?? this.lsm.get(newKey))
: this.lsm.get(newKey);
if (existing && !(existing as unknown as Record<string, unknown>).__txn_deleted) {
throw new DatabaseError(
`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`,
'DUPLICATE_KEY',
);
}
}
if (pkChanged) { if (pkChanged) {
// ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL // ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL
await this.applyForeignKeyUpdateRules( await this.applyForeignKeyUpdateRules(
@@ -699,7 +778,9 @@ export class AriaEngine implements IStorageEngine {
}); });
// 更新二级索引(主键变更时旧索引条目一并清理) // 更新二级索引(主键变更时旧索引条目一并清理)
this.updateSecondaryIndexes(tableName, newPk, updated, pkChanged ? row : null); // v0.6.2-fix: 此前非主键更新不传旧行 → 旧索引条目残留
// (唯一性检查误报 / 索引存储膨胀);现在统一传旧行清理旧值
this.updateSecondaryIndexes(tableName, newPk, updated, row);
} }
} }
@@ -1556,6 +1637,46 @@ export class AriaEngine implements IStorageEngine {
// 二级索引 // 二级索引
// ======================================================================= // =======================================================================
/** v0.6.2: 表中有 unique 约束且索引 LSM 已建的列(唯一性检查范围) */
private uniqueColumns(tableName: string, schema: TableSchema): string[] {
const cols: string[] = [];
for (const [colName, colDef] of Object.entries(schema.columns)) {
if (!colDef.unique) continue;
if (this.secondaryIndexes.has(`${tableName}:idx:${colName}`)) cols.push(colName);
}
return cols;
}
/**
* v0.6.2: 同步唯一性检查(须在批次级 prefetchPrefixRanges 之后调用,循环内无 await)。
* 索引不含 null 条目(null 值不受唯一约束,与 MemoryEngine 语义一致)。
* @param currentPk 当前行主键(更新路径用于排除自身旧索引条目;插入路径无自身条目)
*/
private checkUniqueSync(
tableName: string,
uniqueCols: string[],
row: Record<string, unknown>,
currentPk: string,
): void {
for (const colName of uniqueCols) {
const val = row[colName];
if (val === undefined || val === null) continue;
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`);
if (!idxLsm) continue;
const prefix = `${String(val)}:`;
const entries = idxLsm.rangeScan(prefix, `${prefix}\uffff`);
for (const [, entry] of entries) {
const pk = (entry as unknown as { pk?: string }).pk;
if (pk !== undefined && pk !== currentPk) {
throw new DatabaseError(
`Unique constraint violation on column "${colName}" in table "${tableName}"`,
'UNIQUE_VIOLATION',
);
}
}
}
}
/** 更新行的二级索引条目 */ /** 更新行的二级索引条目 */
private updateSecondaryIndexes( private updateSecondaryIndexes(
tableName: string, pkValue: string, tableName: string, pkValue: string,
@@ -1658,15 +1779,22 @@ export class AriaEngine implements IStorageEngine {
// $eq → 精确查找 // $eq → 精确查找
if (typeof condition !== 'object' || condition === null) { if (typeof condition !== 'object' || condition === null) {
// v0.6.2-fix(P1): IS NULL(条件为 null)不走索引 —— 索引不含 null 条目,
// String(null)="null" 查找返回空并短路全表 → 索引列 IS NULL 恒空
if (condition === null) continue;
return this.indexScanToRows(tableName, pkCol, idxLsm, String(condition), String(condition)); return this.indexScanToRows(tableName, pkCol, idxLsm, String(condition), String(condition));
} }
const c = condition as Record<string, unknown>; const c = condition as Record<string, unknown>;
if ('$eq' in c) { if ('$eq' in c) {
// v0.6.2-fix(P1): 同上,$eq: nullIS NULL)不走索引
if (c.$eq === null) continue;
const v = String(c.$eq); const v = String(c.$eq);
return this.indexScanToRows(tableName, pkCol, idxLsm, v, v); return this.indexScanToRows(tableName, pkCol, idxLsm, v, v);
} }
// $in → 多次精确查找 // $in → 多次精确查找
if ('$in' in c && Array.isArray(c.$in)) { if ('$in' in c && Array.isArray(c.$in)) {
// v0.6.2-fix(P1): IN 列表含 null 不走索引(索引不含 null 条目,会漏匹配 null 行)
if (c.$in.some((v) => v === null)) continue;
const results: Record<string, unknown>[] = []; const results: Record<string, unknown>[] = [];
const seenPks = new Set<string>(); // v0.4.1: IN 值可能重复,按 pk 去重 const seenPks = new Set<string>(); // v0.4.1: IN 值可能重复,按 pk 去重
for (const val of c.$in) { for (const val of c.$in) {
@@ -1683,13 +1811,12 @@ export class AriaEngine implements IStorageEngine {
} }
// $gt / $gte / $lt / $lte → 范围扫描 // $gt / $gte / $lt / $lte → 范围扫描
if ('$gt' in c || '$gte' in c || '$lt' in c || '$lte' in c) { if ('$gt' in c || '$gte' in c || '$lt' in c || '$lte' in c) {
let startKey = ''; // v0.6.2-fix(P1): 此前用 Number(v)±1 构造边界 key —— 小数数值
let endKey = '\uffff'; // $gt:2 → "3:",漏 2.5)与字符串("NaN:" 前缀错位,数字/大写开头值被漏)
if (c.$gt !== undefined) startKey = `${String(Number(c.$gt) + 1)}:`; // 静默丢数据。改为全索引扫描 + 行级 matchWhere 过滤(与主键范围路径同方案),
else if (c.$gte !== undefined) startKey = `${String(c.$gte)}:`; // 边界语义与 where-matcher 完全一致。
if (c.$lt !== undefined) endKey = `${String(Number(c.$lt) - 1)}:\uffff`; const rows = await this.indexScanToRows(tableName, pkCol, idxLsm, '', '\uffff');
else if (c.$lte !== undefined) endKey = `${String(c.$lte)}:\uffff`; return rows.filter((row) => matchWhere(row, { [col]: condition }));
return this.indexScanToRows(tableName, pkCol, idxLsm, startKey, endKey);
} }
} }
+37
View File
@@ -351,6 +351,43 @@ export class LSM {
} }
} }
/**
* v0.6.2: 批量预加载多个前缀范围可能命中的所有 SSTable drainChain
* "每行一个窄前缀范围"meta
* prefetchRange drainChain
*/
async prefetchPrefixRanges(ranges: [string, string][]): Promise<void> {
if (ranges.length === 0) return;
// v0.6.2: 去重(大批量插入时唯一列值可能重复),减少 meta 遍历开销
const seen = new Set<string>();
const unique: [string, string][] = [];
for (const r of ranges) {
const k = `${r[0]}\u0000${r[1]}`;
if (!seen.has(k)) {
seen.add(k);
unique.push(r);
}
}
if (unique.length === 0) return;
await this.drainChain();
this.trimCache();
const toLoad = new Set<number>();
for (let level = 0; level < MAX_LSM_LEVELS; level++) {
for (const meta of this.levels[level]) {
if (this.sstableCache.has(meta.id)) continue;
for (const [startKey, endKey] of unique) {
if (endKey < meta.minKey || startKey > meta.maxKey) continue;
toLoad.add(meta.id);
break;
}
}
}
for (const id of toLoad) {
const meta = this.findMetaById(id);
await this.preloadSSTable(id, meta);
}
}
/** 按 id 查找 SSTable 元数据(prefetch 预加载用) */ /** 按 id 查找 SSTable 元数据(prefetch 预加载用) */
private findMetaById(id: number): SSTableMeta | undefined { private findMetaById(id: number): SSTableMeta | undefined {
for (let level = 0; level < MAX_LSM_LEVELS; level++) { for (let level = 0; level < MAX_LSM_LEVELS; level++) {
+14 -7
View File
@@ -294,15 +294,22 @@ export class KVStoreEngine implements IStorageEngine {
deletes.push(...diff.deletes); deletes.push(...diff.deletes);
} }
} else { } else {
// 增量重写受影响行 // v0.6.2-fix(P0): 受影响主键经 String() 化后按 `where { [pkCol]: pk }` 回查内存行,
for (const pk of affected) { // 数值型主键(123 !== "123")不命中 → 行被误判删除 → 重启丢数据。
const row = await this.memory.find(tableName, { table: tableName, where: { [pkCol]: pk } }); // 改为单次全表扫描 + 受影响集合过滤(同时消除此前 O(N×M) 逐主键回查开销)。
if (row.length > 0) { const affectedSet = new Set(affected);
puts[this.rowKey(tableName, pk)] = enc(JSON.stringify(row[0])); const allRows = await this.memory.find(tableName, { table: tableName });
} else { for (const row of allRows) {
deletes.push(this.rowKey(tableName, pk)); const pkStr = String(row[pkCol]);
if (affectedSet.has(pkStr)) {
puts[this.rowKey(tableName, pkStr)] = enc(JSON.stringify(row));
affectedSet.delete(pkStr);
} }
} }
// 剩余主键(内存中已不存在,如被级联移除)→ 删除对应 KV 行
for (const pk of affectedSet) {
deletes.push(this.rowKey(tableName, pk));
}
// 级联影响表(SET NULL/CASCADE 外键)整表 diff // 级联影响表(SET NULL/CASCADE 外键)整表 diff
for (const t of await this.affectedTables(tableName)) { for (const t of await this.affectedTables(tableName)) {
if (t === tableName) continue; if (t === tableName) continue;
+7
View File
@@ -194,6 +194,13 @@ export class MemoryEngine implements IStorageEngine {
this.validateRow(schema, updated); this.validateRow(schema, updated);
this.checkUniqueness(schema, updated); this.checkUniqueness(schema, updated);
const newPk = String(updated[pkCol]); const newPk = String(updated[pkCol]);
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据)
if (newPk !== pk && table.has(newPk)) {
throw new DatabaseError(
`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`,
'DUPLICATE_KEY',
);
}
// v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表 // v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
if (newPk !== pk) { if (newPk !== pk) {
await this.applyUpdateCascade(tableName, pk, newPk); await this.applyUpdateCascade(tableName, pk, newPk);
+13 -1
View File
@@ -183,11 +183,23 @@ export class QueryExecutor {
private async executeExplain(stmt: { query: Statement }): Promise<Record<string, unknown>> { private async executeExplain(stmt: { query: Statement }): Promise<Record<string, unknown>> {
const startTime = Date.now(); const startTime = Date.now();
let result: unknown = null; let result: unknown = null;
let rows = 0;
// v0.6.2-fix: EXPLAIN 不得真实执行写语句 —— 此前 EXPLAIN DELETE/UPDATE 会产生
// 真实副作用(删/改数据)。仅 SELECT 类语句执行(只读);UPDATE/DELETE 用
// count 估算影响行数(无副作用);INSERT/DDL 仅输出计划不执行。
if (stmt.query.type === 'SELECT' || stmt.query.type === 'SELECT_UNION') {
try { try {
result = await this.execute(stmt.query); result = await this.execute(stmt.query);
} catch { /* explain 即使执行失败也返回计划 */ } } catch { /* explain 即使执行失败也返回计划 */ }
rows = Array.isArray(result) ? result.length : 0;
} else if (stmt.query.type === 'UPDATE' || stmt.query.type === 'DELETE') {
try {
const plan = compileStatement(stmt.query);
rows = await this.engine.count(plan.table, plan);
} catch { rows = 0; }
}
const elapsed = Date.now() - startTime; const elapsed = Date.now() - startTime;
const rows = Array.isArray(result) ? result.length : 0;
// v0.5.1: 仅 SELECT/DELETE/UPDATE 有引擎查询计划;其他语句输出基本信息 // v0.5.1: 仅 SELECT/DELETE/UPDATE 有引擎查询计划;其他语句输出基本信息
let plan: import('../constants').QueryPlan | null = null; let plan: import('../constants').QueryPlan | null = null;
+1 -1
View File
@@ -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.6.1'); expect(VERSION).toBe('0.6.2');
}); });
}); });
+1 -1
View File
@@ -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.6.1'); expect(VERSION).toBe('0.6.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', {
+395
View File
@@ -0,0 +1,395 @@
/**
* v0.6.2 P0/P1
*
*
* - P0: KVStoreEngine update
* - P0: Memory/Aria update
* - P1: Aria /
* - P1: Aria IS NULL
* - P1: Aria unique
* - P2: EXPLAIN
* - update
*/
import { MetonaSqlark } from '../src/core';
import { MemoryEngine } from '../src/engine/memory';
import { KVStoreEngine } from '../src/engine/kvstore_engine';
import { AriaEngine } from '../src/engine/aria/index';
import { createSchema } from '../src/table/schema';
function uniqueDB(): string {
return `v062-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
/** 断言 Promise 以指定错误码拒绝 */
async function expectCode(promise: Promise<unknown>, code: string): Promise<void> {
try {
await promise;
} catch (e) {
expect((e as { code?: string }).code).toBe(code);
return;
}
throw new Error(`Expected rejection with code "${code}", but promise resolved`);
}
describe('v0.6.2 — 数值主键 update 数据丢失(KVStoreEngine', () => {
it('数值主键 update 后重开行不丢失', async () => {
const name = uniqueDB();
const eng = new KVStoreEngine();
await eng.open(name, 1);
await eng.createTable(createSchema('t', {
id: { type: 'number', primaryKey: true },
v: { type: 'string' },
}));
await eng.insert('t', [{ id: 1, v: 'a' }, { id: 2, v: 'b' }]);
await eng.update('t', { table: 't', where: { id: 1 } }, { v: 'A' });
await eng.close();
const eng2 = new KVStoreEngine();
await eng2.open(name, 1);
const rows = await eng2.find('t', { table: 't' });
expect(rows).toHaveLength(2);
const row1 = rows.find((r) => r.id === 1);
expect(row1).toBeDefined();
expect(row1!.v).toBe('A');
await eng2.close();
});
it('数值主键 delete 后重开不残留', async () => {
const name = uniqueDB();
const eng = new KVStoreEngine();
await eng.open(name, 1);
await eng.createTable(createSchema('t', {
id: { type: 'number', primaryKey: true },
}));
await eng.insert('t', [{ id: 1 }, { id: 2 }]);
await eng.delete('t', { table: 't', where: { id: 1 } });
await eng.close();
const eng2 = new KVStoreEngine();
await eng2.open(name, 1);
const rows = await eng2.find('t', { table: 't' });
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe(2);
await eng2.close();
});
it('高层 APIdisk 模式)数值主键 update 持久化正确', async () => {
const db = new MetonaSqlark({ name: uniqueDB(), mode: 'disk' });
await db.init();
await db.defineTable('t', {
id: { type: 'number', primaryKey: true },
name: { type: 'string' },
});
await db.query('INSERT INTO t VALUES (1, \'a\'), (2, \'b\')');
await db.query('UPDATE t SET name = \'A\' WHERE id = 1');
await db.close();
const db2 = new MetonaSqlark({ name: db.name, mode: 'disk' });
await db2.init();
const rows = await db2.query('SELECT * FROM t') as Record<string, unknown>[];
expect(rows).toHaveLength(2);
expect(rows.find((r) => r.id === 1)!.name).toBe('A');
await db2.close();
});
});
describe('v0.6.2 — update 主键撞已有主键(DUPLICATE_KEY', () => {
it('MemoryEngine 主键碰撞更新抛 DUPLICATE_KEY,数据不丢', async () => {
const eng = new MemoryEngine();
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', { id: { type: 'string', primaryKey: true } }));
await eng.insert('t', [{ id: '1' }, { id: '2' }]);
await expectCode(eng.update('t', { table: 't', where: { id: '1' } }, { id: '2' }), 'DUPLICATE_KEY');
const rows = await eng.find('t', { table: 't' });
expect(rows).toHaveLength(2);
});
it('AriaEngine 主键碰撞更新抛 DUPLICATE_KEY,数据不丢', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', { id: { type: 'string', primaryKey: true } }));
await eng.insert('t', [{ id: '1' }, { id: '2' }]);
await expectCode(eng.update('t', { table: 't', where: { id: '1' } }, { id: '2' }), 'DUPLICATE_KEY');
const rows = await eng.find('t', { table: 't' });
expect(rows).toHaveLength(2);
await eng.close();
});
it('事务内主键碰撞更新抛 DUPLICATE_KEY', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', { id: { type: 'string', primaryKey: true } }));
await eng.insert('t', [{ id: '1' }, { id: '2' }]);
await eng.beginTransaction();
await expectCode(eng.update('t', { table: 't', where: { id: '1' } }, { id: '2' }), 'DUPLICATE_KEY');
await eng.rollbackTransaction();
const rows = await eng.find('t', { table: 't' });
expect(rows).toHaveLength(2);
await eng.close();
});
});
describe('v0.6.2 — Aria 二级索引范围查询边界', () => {
async function makeEngine(): Promise<AriaEngine> {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
score: { type: 'number', index: true },
tag: { type: 'string', index: true },
}));
return eng;
}
it('小数数值范围查询($gt/$lt)不漏行', async () => {
const eng = await makeEngine();
await eng.insert('t', [
{ id: '1', score: 1.5, tag: 'a' }, { id: '2', score: 2.5, tag: 'b' },
{ id: '3', score: 3.5, tag: 'c' }, { id: '4', score: 2, tag: 'd' },
]);
const gt = await eng.find('t', { table: 't', where: { score: { $gt: 2 } } });
expect(gt.map((r) => r.id).sort()).toEqual(['2', '3']);
const lt = await eng.find('t', { table: 't', where: { score: { $lt: 2 } } });
expect(lt.map((r) => r.id).sort()).toEqual(['1']);
const gte = await eng.find('t', { table: 't', where: { score: { $gte: 2 } } });
expect(gte.map((r) => r.id).sort()).toEqual(['2', '3', '4']);
const lte = await eng.find('t', { table: 't', where: { score: { $lte: 2.5 } } });
expect(lte.map((r) => r.id).sort()).toEqual(['1', '2', '4']);
await eng.close();
});
it('字符串范围查询(小写/大写/数字开头)不漏行', async () => {
const eng = await makeEngine();
await eng.insert('t', [
{ id: '1', score: 1, tag: 'apple' }, { id: '2', score: 2, tag: 'Banana' },
{ id: '3', score: 3, tag: 'cherry' }, { id: '4', score: 4, tag: '1start' },
{ id: '5', score: 5, tag: 'Zebra' },
]);
const gt = await eng.find('t', { table: 't', where: { tag: { $gt: 'apple' } } });
// JS 字符串比较:'Banana' > 'apple'(大写 < 小写)→ 只有 cherry
expect(gt.map((r) => r.id).sort()).toEqual(['3']);
const lt = await eng.find('t', { table: 't', where: { tag: { $lt: 'Banana' } } });
expect(lt.map((r) => r.id).sort()).toEqual(['4']);
const gteDigit = await eng.find('t', { table: 't', where: { tag: { $gte: '1start' } } });
// JS 字符串比较:数字开头最小,全部字母值都 >= '1start'
expect(gteDigit.map((r) => r.id).sort()).toEqual(['1', '2', '3', '4', '5']);
await eng.close();
});
it('整数范围查询结果与全表扫描一致(flush 前后)', async () => {
const eng = await makeEngine();
await eng.insert('t', Array.from({ length: 50 }, (_, i) => ({ id: `${i}`, score: i * 10, tag: `t${i % 5}` })));
const byIndex = await eng.find('t', { table: 't', where: { score: { $gt: 200, $lte: 350 } } });
const fullScan = (await eng.find('t', { table: 't' }))
.filter((r) => (r.score as number) > 200 && (r.score as number) <= 350);
expect(byIndex.map((r) => r.id).sort()).toEqual(fullScan.map((r) => r.id).sort());
// flush 后(索引进 SSTable)结果一致
await (eng as any).lsm.flush();
await (eng as any).secondaryIndexes.get('t:idx:score').flush();
const after = await eng.find('t', { table: 't', where: { score: { $gt: 200, $lte: 350 } } });
expect(after.map((r) => r.id).sort()).toEqual(fullScan.map((r) => r.id).sort());
await eng.close();
});
it('SQL 层索引范围查询(GROUP BY 前过滤)一致性', async () => {
const db = new MetonaSqlark({ name: uniqueDB(), mode: 'aria', diskEngine: 'memory' });
await db.init();
await db.defineTable('t', {
id: { type: 'string', primaryKey: true },
score: { type: 'number', index: true },
});
await db.query('INSERT INTO t VALUES (\'1\', 1.5), (\'2\', 2.5), (\'3\', 3.5)');
const rows = await db.query('SELECT * FROM t WHERE score > 2') as Record<string, unknown>[];
expect(rows.map((r) => r.id).sort()).toEqual(['2', '3']);
await db.close();
});
});
describe('v0.6.2 — Aria 索引列 IS NULL', () => {
it('引擎层 $eq: null 返回 null 行', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', index: true },
}));
await eng.insert('t', [{ id: '1', email: 'a@b.c' }, { id: '2', email: null }, { id: '3', email: 'd@e.f' }]);
const rows = await eng.find('t', { table: 't', where: { email: { $eq: null } } });
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe('2');
await eng.close();
});
it('SQL 层 IS NULL / IS NOT NULL 正确', async () => {
const db = new MetonaSqlark({ name: uniqueDB(), mode: 'aria', diskEngine: 'memory' });
await db.init();
await db.defineTable('t', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', index: true },
});
await db.query('INSERT INTO t VALUES (\'1\', \'a@b.c\'), (\'2\', NULL)');
const nullRows = await db.query('SELECT * FROM t WHERE email IS NULL') as Record<string, unknown>[];
expect(nullRows).toHaveLength(1);
expect(nullRows[0].id).toBe('2');
const notNull = await db.query('SELECT * FROM t WHERE email IS NOT NULL') as Record<string, unknown>[];
expect(notNull).toHaveLength(1);
expect(notNull[0].id).toBe('1');
await db.close();
});
it('IN 列表含 null 不走索引(不漏 null 行)', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', index: true },
}));
await eng.insert('t', [{ id: '1', email: 'a@b.c' }, { id: '2', email: null }]);
const rows = await eng.find('t', { table: 't', where: { email: { $in: [null, 'a@b.c'] } } });
expect(rows).toHaveLength(2);
await eng.close();
});
});
describe('v0.6.2 — Aria unique 约束强制', () => {
it('同批重复唯一值被拦截', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', unique: true },
}));
await expectCode(eng.insert('t', [
{ id: '1', email: 'x@y' }, { id: '2', email: 'x@y' },
]), 'UNIQUE_VIOLATION');
// 验证失败整批不落库
const rows = await eng.find('t', { table: 't' });
expect(rows).toHaveLength(0);
await eng.close();
});
it('跨批重复唯一值被拦截', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', unique: true },
}));
await eng.insert('t', [{ id: '1', email: 'x@y' }]);
await expectCode(eng.insert('t', [{ id: '2', email: 'x@y' }]), 'UNIQUE_VIOLATION');
await eng.close();
});
it('flush 后(索引进 SSTable)重复唯一值仍被拦截', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', unique: true },
}));
await eng.insert('t', [{ id: '1', email: 'x@y' }]);
await (eng as any).secondaryIndexes.get('t:idx:email').flush();
await (eng as any).lsm.flush();
await expectCode(eng.insert('t', [{ id: '2', email: 'x@y' }]), 'UNIQUE_VIOLATION');
await eng.close();
});
it('update 改为已存在唯一值被拦截;改为自身旧值放行', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', unique: true },
}));
await eng.insert('t', [{ id: '1', email: 'a@y' }, { id: '2', email: 'b@y' }]);
await expectCode(eng.update('t', { table: 't', where: { id: '1' } }, { email: 'b@y' }), 'UNIQUE_VIOLATION');
// 更新为自身旧值(未变化)放行
await eng.update('t', { table: 't', where: { id: '1' } }, { email: 'a@y' });
const rows = await eng.find('t', { table: 't' });
expect(rows).toHaveLength(2);
await eng.close();
});
it('非主键 update 清理旧索引条目:旧值可被新行复用', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', unique: true },
}));
await eng.insert('t', [{ id: '1', email: 'a@y' }]);
await eng.update('t', { table: 't', where: { id: '1' } }, { email: 'b@y' });
// 旧值 a@y 应可被复用(残留索引条目否则会误报 UNIQUE_VIOLATION
await eng.insert('t', [{ id: '2', email: 'a@y' }]);
const rows = await eng.find('t', { table: 't' });
expect(rows).toHaveLength(2);
await eng.close();
});
it('null 值不受唯一约束', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', unique: true },
}));
await eng.insert('t', [{ id: '1' }, { id: '2' }]);
const rows = await eng.find('t', { table: 't' });
expect(rows).toHaveLength(2);
await eng.close();
});
it('数值唯一列 + 事务内重复拦截', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
code: { type: 'number', unique: true },
}));
await eng.insert('t', [{ id: '1', code: 100 }]);
await eng.beginTransaction();
await expectCode(eng.insert('t', [{ id: '2', code: 100 }]), 'UNIQUE_VIOLATION');
await eng.rollbackTransaction();
const rows = await eng.find('t', { table: 't' });
expect(rows).toHaveLength(1);
await eng.close();
});
});
describe('v0.6.2 — EXPLAIN 无副作用', () => {
it('EXPLAIN DELETE/UPDATE 不修改数据', async () => {
const db = new MetonaSqlark({ name: uniqueDB(), mode: 'memory' });
await db.init();
await db.defineTable('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
});
await db.query('INSERT INTO users VALUES (\'1\', \'Alice\'), (\'2\', \'Bob\')');
await db.query('EXPLAIN DELETE FROM users WHERE id = \'1\'');
let rows = await db.query('SELECT * FROM users') as Record<string, unknown>[];
expect(rows).toHaveLength(2);
await db.query('EXPLAIN UPDATE users SET name = \'X\' WHERE id = \'1\'');
rows = await db.query('SELECT * FROM users') as Record<string, unknown>[];
expect(rows).toHaveLength(2);
expect(rows.find((r) => r.id === '1')!.name).toBe('Alice');
// EXPLAIN DELETE 给出估算行数(无副作用)
const plan = await db.query('EXPLAIN DELETE FROM users WHERE id = \'1\'') as Record<string, unknown>;
expect(plan.estimatedRows).toBe(1);
await db.close();
});
it('EXPLAIN INSERT 不写入数据', async () => {
const db = new MetonaSqlark({ name: uniqueDB(), mode: 'memory' });
await db.init();
await db.defineTable('users', { id: { type: 'string', primaryKey: true } });
await db.query('EXPLAIN INSERT INTO users VALUES (\'1\')');
const rows = await db.query('SELECT * FROM users') as Record<string, unknown>[];
expect(rows).toHaveLength(0);
await db.close();
});
});