fix(P0): v0.6.2 数据正确性专项 — 深度审计 6 项修复 + 22 回归
- 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:
@@ -2,6 +2,45 @@
|
||||
|
||||
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
|
||||
|
||||
### 生产可用性深度审查(异常场景)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# MetonaSqlark
|
||||
|
||||
<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/coverage-89.7%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/coverage-89.6%25-brightgreen?style=flat-square" alt="coverage">
|
||||
<img src="https://img.shields.io/badge/tests-1114%20passed-success?style=flat-square" alt="tests">
|
||||
</p>
|
||||
|
||||
> 基于 TypeScript 的**前端关系型数据库**:完整 SQL + Query Builder 双 API,
|
||||
@@ -397,7 +397,7 @@ const { data, loading, error, refresh } = useSqlarkQuery(db, 'SELECT * FROM user
|
||||
npm install # 安装依赖
|
||||
npm run dev # 开发模式(localhost:3001)
|
||||
npm run build # 生产构建(生成 dist/)
|
||||
npm test # 运行测试(1093 用例 · 70 套件)
|
||||
npm test # 运行测试(1114 用例 · 70 套件)
|
||||
npm run test:e2e # Playwright e2e(真实 Chromium + OPFS + 崩溃注入,需先 build)
|
||||
npm run lint # 代码检查
|
||||
npm run typecheck # 类型检查
|
||||
@@ -409,9 +409,9 @@ npm run typecheck # 类型检查
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| 测试用例 | 1093(+12 Playwright e2e) |
|
||||
| 测试用例 | 1114(+12 Playwright e2e) |
|
||||
| 测试套件 | 70 |
|
||||
| 行覆盖率 | 89.7% |
|
||||
| 行覆盖率 | 89.6% |
|
||||
| SQL 关键字 | 72 |
|
||||
| 存储引擎 | 5(Memory / KVStore / OPFS / Hybrid / Aria) |
|
||||
| 运行时依赖 | 0 |
|
||||
|
||||
Vendored
+205
-27
@@ -34,7 +34,7 @@ class DatabaseError extends Error {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 版本
|
||||
// ---------------------------------------------------------------------------
|
||||
const VERSION = '0.6.1';
|
||||
const VERSION = '0.6.2';
|
||||
|
||||
/**
|
||||
* metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑
|
||||
@@ -399,6 +399,10 @@ class MemoryEngine {
|
||||
this.validateRow(schema, updated);
|
||||
this.checkUniqueness(schema, updated);
|
||||
const newPk = String(updated[pkCol]);
|
||||
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据)
|
||||
if (newPk !== pk && table.has(newPk)) {
|
||||
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
|
||||
}
|
||||
// v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
|
||||
if (newPk !== pk) {
|
||||
await this.applyUpdateCascade(tableName, pk, newPk);
|
||||
@@ -2025,16 +2029,22 @@ class KVStoreEngine {
|
||||
}
|
||||
}
|
||||
else {
|
||||
// 增量重写受影响行
|
||||
for (const pk of affected) {
|
||||
const row = await this.memory.find(tableName, { table: tableName, where: { [pkCol]: pk } });
|
||||
if (row.length > 0) {
|
||||
puts[this.rowKey(tableName, pk)] = enc(JSON.stringify(row[0]));
|
||||
}
|
||||
else {
|
||||
deletes.push(this.rowKey(tableName, pk));
|
||||
// v0.6.2-fix(P0): 受影响主键经 String() 化后按 `where { [pkCol]: pk }` 回查内存行,
|
||||
// 数值型主键(123 !== "123")不命中 → 行被误判删除 → 重启丢数据。
|
||||
// 改为单次全表扫描 + 受影响集合过滤(同时消除此前 O(N×M) 逐主键回查开销)。
|
||||
const affectedSet = new Set(affected);
|
||||
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);
|
||||
}
|
||||
}
|
||||
// 剩余主键(内存中已不存在,如被级联移除)→ 删除对应 KV 行
|
||||
for (const pk of affectedSet) {
|
||||
deletes.push(this.rowKey(tableName, pk));
|
||||
}
|
||||
// 级联影响表(SET NULL/CASCADE 外键)整表 diff
|
||||
for (const t of await this.affectedTables(tableName)) {
|
||||
if (t === tableName)
|
||||
@@ -3916,6 +3926,46 @@ class LSM {
|
||||
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 预加载用) */
|
||||
findMetaById(id) {
|
||||
for (let level = 0; level < MAX_LSM_LEVELS; level++) {
|
||||
@@ -6491,11 +6541,50 @@ class AriaEngine {
|
||||
// 后台 compaction 在链上数秒时每行阻塞数秒 → 大数据量插入性能悬崖
|
||||
// (10 万行 kv 后端从 5ms/批暴跌到 8~11s/批)。批内新数据在 memtable
|
||||
// 或 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) {
|
||||
const validated = this.validateRow(schema, row);
|
||||
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
|
||||
const existing = this.currentTxnId
|
||||
? (this.txnSnapshot?.get(key) ?? this.lsm.get(key))
|
||||
@@ -6574,15 +6663,48 @@ class AriaEngine {
|
||||
const walRecords = [];
|
||||
// v0.4.2-fix: ON UPDATE 级联环路保护
|
||||
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) {
|
||||
const pkCol = this.tablePKs.get(tableName);
|
||||
const key = `${tableName}:${row[pkCol]}`;
|
||||
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
|
||||
const updated = { ...row, ...updates };
|
||||
this.validateRow(schema, updated);
|
||||
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在)
|
||||
this.checkUniqueSync(tableName, uniqueCols, updated, String(row[pkCol]));
|
||||
// v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录
|
||||
const newPk = String(updated[pkCol]);
|
||||
const pkChanged = newPk !== String(row[pkCol]);
|
||||
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY
|
||||
// (此前静默覆盖另一行丢数据;与 MemoryEngine 对齐)
|
||||
if (pkChanged) {
|
||||
const newKey = `${tableName}:${newPk}`;
|
||||
const existing = this.currentTxnId
|
||||
? (this.txnSnapshot?.get(newKey) ?? this.lsm.get(newKey))
|
||||
: this.lsm.get(newKey);
|
||||
if (existing && !existing.__txn_deleted) {
|
||||
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
|
||||
}
|
||||
}
|
||||
if (pkChanged) {
|
||||
// ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL)
|
||||
await this.applyForeignKeyUpdateRules(tableName, String(row[pkCol]), newPk, walRecords, visited);
|
||||
@@ -6617,7 +6739,9 @@ class AriaEngine {
|
||||
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);
|
||||
@@ -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) {
|
||||
const schema = this.schemas.get(tableName);
|
||||
@@ -7531,15 +7689,25 @@ class AriaEngine {
|
||||
continue;
|
||||
// $eq → 精确查找
|
||||
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));
|
||||
}
|
||||
const c = condition;
|
||||
if ('$eq' in c) {
|
||||
// v0.6.2-fix(P1): 同上,$eq: null(IS NULL)不走索引
|
||||
if (c.$eq === null)
|
||||
continue;
|
||||
const v = String(c.$eq);
|
||||
return this.indexScanToRows(tableName, pkCol, idxLsm, v, v);
|
||||
}
|
||||
// $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 seenPks = new Set(); // v0.4.1: IN 值可能重复,按 pk 去重
|
||||
for (const val of c.$in) {
|
||||
@@ -7556,17 +7724,12 @@ class AriaEngine {
|
||||
}
|
||||
// $gt / $gte / $lt / $lte → 范围扫描
|
||||
if ('$gt' in c || '$gte' in c || '$lt' in c || '$lte' in c) {
|
||||
let startKey = '';
|
||||
let endKey = '\uffff';
|
||||
if (c.$gt !== undefined)
|
||||
startKey = `${String(Number(c.$gt) + 1)}:`;
|
||||
else if (c.$gte !== undefined)
|
||||
startKey = `${String(c.$gte)}:`;
|
||||
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);
|
||||
// v0.6.2-fix(P1): 此前用 Number(v)±1 构造边界 key —— 小数数值
|
||||
// ($gt:2 → "3:",漏 2.5)与字符串("NaN:" 前缀错位,数字/大写开头值被漏)
|
||||
// 静默丢数据。改为全索引扫描 + 行级 matchWhere 过滤(与主键范围路径同方案),
|
||||
// 边界语义与 where-matcher 完全一致。
|
||||
const rows = await this.indexScanToRows(tableName, pkCol, idxLsm, '', '\uffff');
|
||||
return rows.filter((row) => matchWhere(row, { [col]: condition }));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -10010,12 +10173,27 @@ class QueryExecutor {
|
||||
async executeExplain(stmt) {
|
||||
const startTime = Date.now();
|
||||
let result = null;
|
||||
try {
|
||||
result = await this.execute(stmt.query);
|
||||
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 {
|
||||
result = await this.execute(stmt.query);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
catch { /* explain 即使执行失败也返回计划 */ }
|
||||
const elapsed = Date.now() - startTime;
|
||||
const rows = Array.isArray(result) ? result.length : 0;
|
||||
// v0.5.1: 仅 SELECT/DELETE/UPDATE 有引擎查询计划;其他语句输出基本信息
|
||||
let plan = null;
|
||||
try {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+9
-1
@@ -164,7 +164,7 @@ interface MetonaPlugin {
|
||||
/** 销毁 */
|
||||
destroy(): void;
|
||||
}
|
||||
declare const VERSION = "0.6.1";
|
||||
declare const VERSION = "0.6.2";
|
||||
|
||||
/**
|
||||
* metona-sqlark Plugin — 插件系统
|
||||
@@ -1075,6 +1075,14 @@ declare class AriaEngine implements IStorageEngine {
|
||||
* 导致崩溃后"已删除的表和数据复活"(实证 P0 bug)。
|
||||
*/
|
||||
private applyDropTableRecovery;
|
||||
/** v0.6.2: 表中有 unique 约束且索引 LSM 已建的列(唯一性检查范围) */
|
||||
private uniqueColumns;
|
||||
/**
|
||||
* v0.6.2: 同步唯一性检查(须在批次级 prefetchPrefixRanges 之后调用,循环内无 await)。
|
||||
* 索引不含 null 条目(null 值不受唯一约束,与 MemoryEngine 语义一致)。
|
||||
* @param currentPk 当前行主键(更新路径用于排除自身旧索引条目;插入路径无自身条目)
|
||||
*/
|
||||
private checkUniqueSync;
|
||||
/** 更新行的二级索引条目 */
|
||||
private updateSecondaryIndexes;
|
||||
/** 通过二级索引快速查找 */
|
||||
|
||||
Vendored
+205
-27
@@ -30,7 +30,7 @@ class DatabaseError extends Error {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 版本
|
||||
// ---------------------------------------------------------------------------
|
||||
const VERSION = '0.6.1';
|
||||
const VERSION = '0.6.2';
|
||||
|
||||
/**
|
||||
* metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑
|
||||
@@ -395,6 +395,10 @@ class MemoryEngine {
|
||||
this.validateRow(schema, updated);
|
||||
this.checkUniqueness(schema, updated);
|
||||
const newPk = String(updated[pkCol]);
|
||||
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据)
|
||||
if (newPk !== pk && table.has(newPk)) {
|
||||
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
|
||||
}
|
||||
// v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
|
||||
if (newPk !== pk) {
|
||||
await this.applyUpdateCascade(tableName, pk, newPk);
|
||||
@@ -2021,16 +2025,22 @@ class KVStoreEngine {
|
||||
}
|
||||
}
|
||||
else {
|
||||
// 增量重写受影响行
|
||||
for (const pk of affected) {
|
||||
const row = await this.memory.find(tableName, { table: tableName, where: { [pkCol]: pk } });
|
||||
if (row.length > 0) {
|
||||
puts[this.rowKey(tableName, pk)] = enc(JSON.stringify(row[0]));
|
||||
}
|
||||
else {
|
||||
deletes.push(this.rowKey(tableName, pk));
|
||||
// v0.6.2-fix(P0): 受影响主键经 String() 化后按 `where { [pkCol]: pk }` 回查内存行,
|
||||
// 数值型主键(123 !== "123")不命中 → 行被误判删除 → 重启丢数据。
|
||||
// 改为单次全表扫描 + 受影响集合过滤(同时消除此前 O(N×M) 逐主键回查开销)。
|
||||
const affectedSet = new Set(affected);
|
||||
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);
|
||||
}
|
||||
}
|
||||
// 剩余主键(内存中已不存在,如被级联移除)→ 删除对应 KV 行
|
||||
for (const pk of affectedSet) {
|
||||
deletes.push(this.rowKey(tableName, pk));
|
||||
}
|
||||
// 级联影响表(SET NULL/CASCADE 外键)整表 diff
|
||||
for (const t of await this.affectedTables(tableName)) {
|
||||
if (t === tableName)
|
||||
@@ -3912,6 +3922,46 @@ class LSM {
|
||||
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 预加载用) */
|
||||
findMetaById(id) {
|
||||
for (let level = 0; level < MAX_LSM_LEVELS; level++) {
|
||||
@@ -6487,11 +6537,50 @@ class AriaEngine {
|
||||
// 后台 compaction 在链上数秒时每行阻塞数秒 → 大数据量插入性能悬崖
|
||||
// (10 万行 kv 后端从 5ms/批暴跌到 8~11s/批)。批内新数据在 memtable
|
||||
// 或 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) {
|
||||
const validated = this.validateRow(schema, row);
|
||||
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
|
||||
const existing = this.currentTxnId
|
||||
? (this.txnSnapshot?.get(key) ?? this.lsm.get(key))
|
||||
@@ -6570,15 +6659,48 @@ class AriaEngine {
|
||||
const walRecords = [];
|
||||
// v0.4.2-fix: ON UPDATE 级联环路保护
|
||||
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) {
|
||||
const pkCol = this.tablePKs.get(tableName);
|
||||
const key = `${tableName}:${row[pkCol]}`;
|
||||
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
|
||||
const updated = { ...row, ...updates };
|
||||
this.validateRow(schema, updated);
|
||||
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在)
|
||||
this.checkUniqueSync(tableName, uniqueCols, updated, String(row[pkCol]));
|
||||
// v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录
|
||||
const newPk = String(updated[pkCol]);
|
||||
const pkChanged = newPk !== String(row[pkCol]);
|
||||
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY
|
||||
// (此前静默覆盖另一行丢数据;与 MemoryEngine 对齐)
|
||||
if (pkChanged) {
|
||||
const newKey = `${tableName}:${newPk}`;
|
||||
const existing = this.currentTxnId
|
||||
? (this.txnSnapshot?.get(newKey) ?? this.lsm.get(newKey))
|
||||
: this.lsm.get(newKey);
|
||||
if (existing && !existing.__txn_deleted) {
|
||||
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
|
||||
}
|
||||
}
|
||||
if (pkChanged) {
|
||||
// ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL)
|
||||
await this.applyForeignKeyUpdateRules(tableName, String(row[pkCol]), newPk, walRecords, visited);
|
||||
@@ -6613,7 +6735,9 @@ class AriaEngine {
|
||||
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);
|
||||
@@ -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) {
|
||||
const schema = this.schemas.get(tableName);
|
||||
@@ -7527,15 +7685,25 @@ class AriaEngine {
|
||||
continue;
|
||||
// $eq → 精确查找
|
||||
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));
|
||||
}
|
||||
const c = condition;
|
||||
if ('$eq' in c) {
|
||||
// v0.6.2-fix(P1): 同上,$eq: null(IS NULL)不走索引
|
||||
if (c.$eq === null)
|
||||
continue;
|
||||
const v = String(c.$eq);
|
||||
return this.indexScanToRows(tableName, pkCol, idxLsm, v, v);
|
||||
}
|
||||
// $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 seenPks = new Set(); // v0.4.1: IN 值可能重复,按 pk 去重
|
||||
for (const val of c.$in) {
|
||||
@@ -7552,17 +7720,12 @@ class AriaEngine {
|
||||
}
|
||||
// $gt / $gte / $lt / $lte → 范围扫描
|
||||
if ('$gt' in c || '$gte' in c || '$lt' in c || '$lte' in c) {
|
||||
let startKey = '';
|
||||
let endKey = '\uffff';
|
||||
if (c.$gt !== undefined)
|
||||
startKey = `${String(Number(c.$gt) + 1)}:`;
|
||||
else if (c.$gte !== undefined)
|
||||
startKey = `${String(c.$gte)}:`;
|
||||
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);
|
||||
// v0.6.2-fix(P1): 此前用 Number(v)±1 构造边界 key —— 小数数值
|
||||
// ($gt:2 → "3:",漏 2.5)与字符串("NaN:" 前缀错位,数字/大写开头值被漏)
|
||||
// 静默丢数据。改为全索引扫描 + 行级 matchWhere 过滤(与主键范围路径同方案),
|
||||
// 边界语义与 where-matcher 完全一致。
|
||||
const rows = await this.indexScanToRows(tableName, pkCol, idxLsm, '', '\uffff');
|
||||
return rows.filter((row) => matchWhere(row, { [col]: condition }));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -10006,12 +10169,27 @@ class QueryExecutor {
|
||||
async executeExplain(stmt) {
|
||||
const startTime = Date.now();
|
||||
let result = null;
|
||||
try {
|
||||
result = await this.execute(stmt.query);
|
||||
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 {
|
||||
result = await this.execute(stmt.query);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
catch { /* explain 即使执行失败也返回计划 */ }
|
||||
const elapsed = Date.now() - startTime;
|
||||
const rows = Array.isArray(result) ? result.length : 0;
|
||||
// v0.5.1: 仅 SELECT/DELETE/UPDATE 有引擎查询计划;其他语句输出基本信息
|
||||
let plan = null;
|
||||
try {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+205
-27
@@ -36,7 +36,7 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// 版本
|
||||
// ---------------------------------------------------------------------------
|
||||
const VERSION = '0.6.1';
|
||||
const VERSION = '0.6.2';
|
||||
|
||||
/**
|
||||
* metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑
|
||||
@@ -401,6 +401,10 @@
|
||||
this.validateRow(schema, updated);
|
||||
this.checkUniqueness(schema, updated);
|
||||
const newPk = String(updated[pkCol]);
|
||||
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据)
|
||||
if (newPk !== pk && table.has(newPk)) {
|
||||
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
|
||||
}
|
||||
// v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
|
||||
if (newPk !== pk) {
|
||||
await this.applyUpdateCascade(tableName, pk, newPk);
|
||||
@@ -2027,16 +2031,22 @@
|
||||
}
|
||||
}
|
||||
else {
|
||||
// 增量重写受影响行
|
||||
for (const pk of affected) {
|
||||
const row = await this.memory.find(tableName, { table: tableName, where: { [pkCol]: pk } });
|
||||
if (row.length > 0) {
|
||||
puts[this.rowKey(tableName, pk)] = enc(JSON.stringify(row[0]));
|
||||
}
|
||||
else {
|
||||
deletes.push(this.rowKey(tableName, pk));
|
||||
// v0.6.2-fix(P0): 受影响主键经 String() 化后按 `where { [pkCol]: pk }` 回查内存行,
|
||||
// 数值型主键(123 !== "123")不命中 → 行被误判删除 → 重启丢数据。
|
||||
// 改为单次全表扫描 + 受影响集合过滤(同时消除此前 O(N×M) 逐主键回查开销)。
|
||||
const affectedSet = new Set(affected);
|
||||
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);
|
||||
}
|
||||
}
|
||||
// 剩余主键(内存中已不存在,如被级联移除)→ 删除对应 KV 行
|
||||
for (const pk of affectedSet) {
|
||||
deletes.push(this.rowKey(tableName, pk));
|
||||
}
|
||||
// 级联影响表(SET NULL/CASCADE 外键)整表 diff
|
||||
for (const t of await this.affectedTables(tableName)) {
|
||||
if (t === tableName)
|
||||
@@ -3918,6 +3928,46 @@
|
||||
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 预加载用) */
|
||||
findMetaById(id) {
|
||||
for (let level = 0; level < MAX_LSM_LEVELS; level++) {
|
||||
@@ -6493,11 +6543,50 @@
|
||||
// 后台 compaction 在链上数秒时每行阻塞数秒 → 大数据量插入性能悬崖
|
||||
// (10 万行 kv 后端从 5ms/批暴跌到 8~11s/批)。批内新数据在 memtable
|
||||
// 或 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) {
|
||||
const validated = this.validateRow(schema, row);
|
||||
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
|
||||
const existing = this.currentTxnId
|
||||
? (this.txnSnapshot?.get(key) ?? this.lsm.get(key))
|
||||
@@ -6576,15 +6665,48 @@
|
||||
const walRecords = [];
|
||||
// v0.4.2-fix: ON UPDATE 级联环路保护
|
||||
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) {
|
||||
const pkCol = this.tablePKs.get(tableName);
|
||||
const key = `${tableName}:${row[pkCol]}`;
|
||||
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
|
||||
const updated = { ...row, ...updates };
|
||||
this.validateRow(schema, updated);
|
||||
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在)
|
||||
this.checkUniqueSync(tableName, uniqueCols, updated, String(row[pkCol]));
|
||||
// v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录
|
||||
const newPk = String(updated[pkCol]);
|
||||
const pkChanged = newPk !== String(row[pkCol]);
|
||||
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY
|
||||
// (此前静默覆盖另一行丢数据;与 MemoryEngine 对齐)
|
||||
if (pkChanged) {
|
||||
const newKey = `${tableName}:${newPk}`;
|
||||
const existing = this.currentTxnId
|
||||
? (this.txnSnapshot?.get(newKey) ?? this.lsm.get(newKey))
|
||||
: this.lsm.get(newKey);
|
||||
if (existing && !existing.__txn_deleted) {
|
||||
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
|
||||
}
|
||||
}
|
||||
if (pkChanged) {
|
||||
// ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL)
|
||||
await this.applyForeignKeyUpdateRules(tableName, String(row[pkCol]), newPk, walRecords, visited);
|
||||
@@ -6619,7 +6741,9 @@
|
||||
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);
|
||||
@@ -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) {
|
||||
const schema = this.schemas.get(tableName);
|
||||
@@ -7533,15 +7691,25 @@
|
||||
continue;
|
||||
// $eq → 精确查找
|
||||
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));
|
||||
}
|
||||
const c = condition;
|
||||
if ('$eq' in c) {
|
||||
// v0.6.2-fix(P1): 同上,$eq: null(IS NULL)不走索引
|
||||
if (c.$eq === null)
|
||||
continue;
|
||||
const v = String(c.$eq);
|
||||
return this.indexScanToRows(tableName, pkCol, idxLsm, v, v);
|
||||
}
|
||||
// $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 seenPks = new Set(); // v0.4.1: IN 值可能重复,按 pk 去重
|
||||
for (const val of c.$in) {
|
||||
@@ -7558,17 +7726,12 @@
|
||||
}
|
||||
// $gt / $gte / $lt / $lte → 范围扫描
|
||||
if ('$gt' in c || '$gte' in c || '$lt' in c || '$lte' in c) {
|
||||
let startKey = '';
|
||||
let endKey = '\uffff';
|
||||
if (c.$gt !== undefined)
|
||||
startKey = `${String(Number(c.$gt) + 1)}:`;
|
||||
else if (c.$gte !== undefined)
|
||||
startKey = `${String(c.$gte)}:`;
|
||||
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);
|
||||
// v0.6.2-fix(P1): 此前用 Number(v)±1 构造边界 key —— 小数数值
|
||||
// ($gt:2 → "3:",漏 2.5)与字符串("NaN:" 前缀错位,数字/大写开头值被漏)
|
||||
// 静默丢数据。改为全索引扫描 + 行级 matchWhere 过滤(与主键范围路径同方案),
|
||||
// 边界语义与 where-matcher 完全一致。
|
||||
const rows = await this.indexScanToRows(tableName, pkCol, idxLsm, '', '\uffff');
|
||||
return rows.filter((row) => matchWhere(row, { [col]: condition }));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -10012,12 +10175,27 @@
|
||||
async executeExplain(stmt) {
|
||||
const startTime = Date.now();
|
||||
let result = null;
|
||||
try {
|
||||
result = await this.execute(stmt.query);
|
||||
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 {
|
||||
result = await this.execute(stmt.query);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
catch { /* explain 即使执行失败也返回计划 */ }
|
||||
const elapsed = Date.now() - startTime;
|
||||
const rows = Array.isArray(result) ? result.length : 0;
|
||||
// v0.5.1: 仅 SELECT/DELETE/UPDATE 有引擎查询计划;其他语句输出基本信息
|
||||
let plan = null;
|
||||
try {
|
||||
|
||||
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",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.2",
|
||||
"description": "Frontend SQL database with in-memory and disk dual-mode storage",
|
||||
"type": "module",
|
||||
"main": "dist/metona-sqlark.cjs",
|
||||
|
||||
+1
-1
@@ -214,4 +214,4 @@ export class DatabaseError extends Error {
|
||||
// 版本
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const VERSION = '0.6.1';
|
||||
export const VERSION = '0.6.2';
|
||||
|
||||
+138
-11
@@ -542,13 +542,55 @@ export class AriaEngine implements IStorageEngine {
|
||||
// 后台 compaction 在链上数秒时每行阻塞数秒 → 大数据量插入性能悬崖
|
||||
// (10 万行 kv 后端从 5ms/批暴跌到 8~11s/批)。批内新数据在 memtable
|
||||
// 或 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) {
|
||||
const validated = this.validateRow(schema, row);
|
||||
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
|
||||
const existing = this.currentTxnId
|
||||
? (this.txnSnapshot?.get(key) ?? this.lsm.get(key))
|
||||
@@ -650,6 +692,25 @@ export class AriaEngine implements IStorageEngine {
|
||||
// v0.4.2-fix: ON UPDATE 级联环路保护
|
||||
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) {
|
||||
const pkCol = this.tablePKs.get(tableName)!;
|
||||
const key = `${tableName}:${row[pkCol]}`;
|
||||
@@ -658,10 +719,28 @@ export class AriaEngine implements IStorageEngine {
|
||||
const updated = { ...row, ...updates };
|
||||
this.validateRow(schema, updated);
|
||||
|
||||
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在)
|
||||
this.checkUniqueSync(tableName, uniqueCols, updated, String(row[pkCol]));
|
||||
|
||||
// v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录
|
||||
const newPk = String(updated[pkCol]);
|
||||
const pkChanged = newPk !== String(row[pkCol]);
|
||||
|
||||
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY
|
||||
// (此前静默覆盖另一行丢数据;与 MemoryEngine 对齐)
|
||||
if (pkChanged) {
|
||||
const newKey = `${tableName}:${newPk}`;
|
||||
const existing = this.currentTxnId
|
||||
? (this.txnSnapshot?.get(newKey) ?? this.lsm.get(newKey))
|
||||
: this.lsm.get(newKey);
|
||||
if (existing && !(existing as unknown as Record<string, unknown>).__txn_deleted) {
|
||||
throw new DatabaseError(
|
||||
`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`,
|
||||
'DUPLICATE_KEY',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (pkChanged) {
|
||||
// ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL)
|
||||
await this.applyForeignKeyUpdateRules(
|
||||
@@ -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(
|
||||
tableName: string, pkValue: string,
|
||||
@@ -1658,15 +1779,22 @@ export class AriaEngine implements IStorageEngine {
|
||||
|
||||
// $eq → 精确查找
|
||||
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));
|
||||
}
|
||||
const c = condition as Record<string, unknown>;
|
||||
if ('$eq' in c) {
|
||||
// v0.6.2-fix(P1): 同上,$eq: null(IS NULL)不走索引
|
||||
if (c.$eq === null) continue;
|
||||
const v = String(c.$eq);
|
||||
return this.indexScanToRows(tableName, pkCol, idxLsm, v, v);
|
||||
}
|
||||
// $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 seenPks = new Set<string>(); // v0.4.1: IN 值可能重复,按 pk 去重
|
||||
for (const val of c.$in) {
|
||||
@@ -1683,13 +1811,12 @@ export class AriaEngine implements IStorageEngine {
|
||||
}
|
||||
// $gt / $gte / $lt / $lte → 范围扫描
|
||||
if ('$gt' in c || '$gte' in c || '$lt' in c || '$lte' in c) {
|
||||
let startKey = '';
|
||||
let endKey = '\uffff';
|
||||
if (c.$gt !== undefined) startKey = `${String(Number(c.$gt) + 1)}:`;
|
||||
else if (c.$gte !== undefined) startKey = `${String(c.$gte)}:`;
|
||||
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);
|
||||
// v0.6.2-fix(P1): 此前用 Number(v)±1 构造边界 key —— 小数数值
|
||||
// ($gt:2 → "3:",漏 2.5)与字符串("NaN:" 前缀错位,数字/大写开头值被漏)
|
||||
// 静默丢数据。改为全索引扫描 + 行级 matchWhere 过滤(与主键范围路径同方案),
|
||||
// 边界语义与 where-matcher 完全一致。
|
||||
const rows = await this.indexScanToRows(tableName, pkCol, idxLsm, '', '\uffff');
|
||||
return rows.filter((row) => matchWhere(row, { [col]: condition }));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 预加载用) */
|
||||
private findMetaById(id: number): SSTableMeta | undefined {
|
||||
for (let level = 0; level < MAX_LSM_LEVELS; level++) {
|
||||
|
||||
@@ -294,15 +294,22 @@ export class KVStoreEngine implements IStorageEngine {
|
||||
deletes.push(...diff.deletes);
|
||||
}
|
||||
} else {
|
||||
// 增量重写受影响行
|
||||
for (const pk of affected) {
|
||||
const row = await this.memory.find(tableName, { table: tableName, where: { [pkCol]: pk } });
|
||||
if (row.length > 0) {
|
||||
puts[this.rowKey(tableName, pk)] = enc(JSON.stringify(row[0]));
|
||||
} else {
|
||||
deletes.push(this.rowKey(tableName, pk));
|
||||
// v0.6.2-fix(P0): 受影响主键经 String() 化后按 `where { [pkCol]: pk }` 回查内存行,
|
||||
// 数值型主键(123 !== "123")不命中 → 行被误判删除 → 重启丢数据。
|
||||
// 改为单次全表扫描 + 受影响集合过滤(同时消除此前 O(N×M) 逐主键回查开销)。
|
||||
const affectedSet = new Set(affected);
|
||||
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);
|
||||
}
|
||||
}
|
||||
// 剩余主键(内存中已不存在,如被级联移除)→ 删除对应 KV 行
|
||||
for (const pk of affectedSet) {
|
||||
deletes.push(this.rowKey(tableName, pk));
|
||||
}
|
||||
// 级联影响表(SET NULL/CASCADE 外键)整表 diff
|
||||
for (const t of await this.affectedTables(tableName)) {
|
||||
if (t === tableName) continue;
|
||||
|
||||
@@ -194,6 +194,13 @@ export class MemoryEngine implements IStorageEngine {
|
||||
this.validateRow(schema, updated);
|
||||
this.checkUniqueness(schema, updated);
|
||||
const newPk = String(updated[pkCol]);
|
||||
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据)
|
||||
if (newPk !== pk && table.has(newPk)) {
|
||||
throw new DatabaseError(
|
||||
`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`,
|
||||
'DUPLICATE_KEY',
|
||||
);
|
||||
}
|
||||
// v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
|
||||
if (newPk !== pk) {
|
||||
await this.applyUpdateCascade(tableName, pk, newPk);
|
||||
|
||||
+16
-4
@@ -183,11 +183,23 @@ export class QueryExecutor {
|
||||
private async executeExplain(stmt: { query: Statement }): Promise<Record<string, unknown>> {
|
||||
const startTime = Date.now();
|
||||
let result: unknown = null;
|
||||
try {
|
||||
result = await this.execute(stmt.query);
|
||||
} catch { /* explain 即使执行失败也返回计划 */ }
|
||||
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 {
|
||||
result = await this.execute(stmt.query);
|
||||
} 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 rows = Array.isArray(result) ? result.length : 0;
|
||||
|
||||
// v0.5.1: 仅 SELECT/DELETE/UPDATE 有引擎查询计划;其他语句输出基本信息
|
||||
let plan: import('../constants').QueryPlan | null = null;
|
||||
|
||||
@@ -28,7 +28,7 @@ beforeEach(() => { installOPFSMock(new Map()); });
|
||||
|
||||
describe('[v0.2.5] P0-1: 版本号统一', () => {
|
||||
test('VERSION 常量为当前版本(0.6.0)', () => {
|
||||
expect(VERSION).toBe('0.6.1');
|
||||
expect(VERSION).toBe('0.6.2');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -403,7 +403,7 @@ describe('[v0.3.3] P1-9: Savepoint + MVCC 一致性', () => {
|
||||
|
||||
describe('[v0.3.3] 端到端', () => {
|
||||
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' });
|
||||
await db.init();
|
||||
await db.defineTable('users', {
|
||||
|
||||
@@ -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('高层 API(disk 模式)数值主键 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user