release: v0.3.2 — 质量加固 + SQL扩展 + 表达式 + 并发同步
CI / test (18.x) (push) Failing after 5m11s
CI / test (20.x) (push) Failing after 5m8s
CI / test (22.x) (push) Successful in 9m58s
CI / test (24.x) (push) Successful in 9m56s

v0.2.6 质量加固:
- 修复 AriaEngine 二级索引 SSTable 互相覆盖(命名空间隔离)
- 修复 LSM 多版本读取顺序错误 + MergeIterator 取最新来源
- 重写 LZ4 压缩器(往返一致性 + 缓冲区溢出)
- sstableCache LRU 上限 + 预加载兜底(BufferPool 配置生效)
- 修复 React/Vue 集成 import type 运行时 bug + exports 子路径
- 新增 38 个测试(LZ4往返/Crypto/集成), 删除伪测试

v0.3.0 SQL 功能扩展:
- 多语句 parseAll + 事务语句 BEGIN/COMMIT/ROLLBACK
- INSERT INTO ... SELECT + UNION/UNION ALL + EXISTS 关联子查询
- CREATE/DROP INDEX 五引擎实现 + 别名 WHERE 修复
- benchmark 页面 + 36 个新测试

v0.3.1 表达式与性能:
- CASE WHEN 表达式(SELECT 列/WHERE/聚合)
- JOIN + 关联子查询逐行绑定
- WAL 批量组提交(写放大 O(N)→O(1))
- 修复 pending frozen 可见性 + flush 缓存竞争

v0.3.2 并发:
- CASE WHEN 用于 WHERE/聚合 + JOIN 哈希连接
- 多标签页同步(multiTabSync + BroadcastChannel)
- IndexedDB schema 持久化(reopen 后表结构恢复)
- 修复 where-matcher 顶层 $not
- 修复 CJS 产物 .js 被 ESM 解析(exports 空) — .cjs 后缀 + exports 修正
- 836 测试 / 44 套件 / 81.0% 覆盖率
This commit is contained in:
thzxx
2026-08-08 10:41:30 +08:00
parent 3ae7d6e8fb
commit d544501e1c
77 changed files with 29007 additions and 20395 deletions
+95
View File
@@ -2,6 +2,101 @@
All notable changes to MetonaSqlark will be documented in this file.
## [0.3.2] - 2026-08-08
### Added
- **CASE WHEN 用于 WHERE** — `WHERE CASE WHEN cond THEN a ELSE b END = 'x'` 逐行求值(含 NOT/AND/OR 组合)
- **CASE WHEN 用于聚合** — `SUM(CASE WHEN age > 18 THEN 1 ELSE 0 END)``COUNT/AVG/MIN/MAX(CASE...)``GROUP BY + CASE` 非聚合列
- **JOIN 哈希连接** — 等值 ON + 右表主键/索引列时,收集左表连接值 → 一次 `$in` 查询 → 哈希映射匹配,替代嵌套循环(INNER/LEFT JOINO(N+M));不适用自动回退
- **多标签页同步** — `multiTabSync: true` 配置 + BroadcastChannel
- SQL 写语句(INSERT/UPDATE/DELETE/DDL)自动广播表变更
- Table API 写入(insert/insertMany/update/delete/clear/drop)自动广播
- 其他标签页收到 `external` 变更事件(订阅者可见)+ Hybrid 内存自动重载
- `db.broadcastChange(table)` 手动广播
- **IndexedDB schema 持久化** — `__metona_schema` store 保存完整列定义,reopen(页面刷新/重连)后恢复;旧数据回退为"主键 + 索引 + 样例推断"
### Fixed
- **IndexedDB reopen 后 schema 丢失(严重)** — schema 仅存内存缓存,close 后重开连接表结构即丢失(INSERT 列映射截断、UPDATE 全列失效)。v0.3.2 持久化 + 重建
- **where-matcher 顶层 $not 不生效** — `NOT (expr)` 解析为顶层 `{ $not }` 但被当作字段匹配,始终返回 true
- **CASE 剥离后空 `$not` 恒假** — 引擎层执行 `NOT(true)` 过滤掉所有行
### Changed
- 版本号升至 v0.3.2
---
## [0.3.1] - 2026-08-08
### Added
- **CASE WHEN 表达式(SELECT 列)** — `CASE WHEN cond THEN value [WHEN ...] [ELSE value] END [AS alias]`
- 多 WHEN 按顺序匹配、ELSE 缺省返回 null
- THEN/ELSE 值支持字面量(字符串/数字/布尔/null)与列引用
- 条件支持 AND/OR/比较运算符/IS NULL 等完整 WHERE 语法
- 引擎层返回原始行供求值(避免投影丢失条件列),与普通列混合投影
- **JOIN + 关联子查询** — JOIN 结果的 WHERE 支持 `EXISTS (SELECT ... WHERE o2.x = u.y)``$col` 引用外层行,逐行绑定求值
- **WAL 批量组提交** — `WAL.appendBatch()` 合并多条记录为一次底层写入;引擎 insert/update/delete 批量记录后一次落盘(写放大从 O(N) 降为 O(1))
- **`parseWhereCondition()` 导出** — 独立条件表达式解析(CASE WHEN 求值内部使用)
### Fixed
- **LSM pending frozen 可见性** — 异步 flush 链期间,中间冻结的 MemTable 仅存在于闭包中,读取路径不可见导致数据缺失。新增 `frozenMemtables` 列表,get/rangeScan/getAllEntries 遍历所有 pending frozen(新→旧)
- **flush 缓存裁剪与预加载竞争** — flush 完成时的 LRU 裁剪会驱逐 prefetch 刚加载的 SSTable,导致扫描静默丢数据。prefetchRange/prefetchKeys 先等待 flush 链完成再加载
- **CASE 值解析顺序** — `true`/`false`/`null` 字面量被误判为列引用
### Changed
- 版本号升至 v0.3.1
---
## [0.3.0] - 2026-08-08
### Added
- **SQL 多语句支持** — `parseAll()` 解析分号分隔的多条语句,`db.query()` 顺序执行并返回最后一条结果(`parse()` 保持单语句兼容)
- **事务语句(TCL** — `BEGIN` / `COMMIT` / `ROLLBACK`(支持可选 TRANSACTION 关键字),直接驱动引擎层事务,五引擎可用
- **INSERT INTO ... SELECT** — 支持 `INSERT INTO t (cols) SELECT ...`(含 WHERE 过滤、列映射)
- **UNION / UNION ALL** — 支持去重合并与链式组合(`A UNION B UNION C`),右侧结果按位置投影到左侧列结构
- **CREATE INDEX / DROP INDEX** — 动态二级索引:
- `CREATE [UNIQUE] INDEX idx ON table (col)` / `DROP INDEX idx ON table (col)`
- 五引擎实现:Memory 哈希索引 / IndexedDB 版本升级建 `idx_col` / OPFS 委托 / Hybrid 双写 / Aria 动态 LSM 索引
- Aria 主键索引受保护(不可 DROP);CREATE INDEX 对已有数据立即建索引(回填),后续写入自动同步
- **EXISTS / NOT EXISTS** — 支持关联子查询(`WHERE EXISTS (SELECT 1 FROM o WHERE o.user_id = u.id)`),逐行绑定外层上下文求值
- **别名 WHERE 修复** — `WHERE u.age > 20`(主表别名前缀)此前不工作,v0.3.0 字段名规范化(JOIN 路径不受影响)
- **`SELECT 1` 常量列** — 数字字面量可作为 SELECT 列(EXISTS 子查询常见用法)
- **benchmark 页面** — `site/benchmark.html` 浏览器内实测 Memory / Aria 引擎 × 1K/10K/50K 行的 INSERT/PK 查询/索引查询/UPDATE/DELETE 吞吐(ops/sec
- **`parseAll` / 新 AST 类型导出** — `SelectUnionStatement` / `CreateIndexStatement` / `DropIndexStatement` / `BeginTransactionStatement`
### Changed
- 版本号升至 v0.3.0
### Fixed
- `NOT EXISTS` 解析丢 EXISTS token 导致 parse 失败
---
## [0.2.6] - 2026-08-08
### Fixed
- **AriaEngine 二级索引 SSTable 互相覆盖(严重)** — 主 LSM 与二级索引 LSM 此前共享同一 `sstableStore`id 空间 + meta 列表),`allocateId()` 返回 `Date.now()` 且各 LSM 从同一 meta 恢复 `nextSSTableId`,高频写入时 id 冲突导致索引数据文件覆盖主数据、meta 与数据错配。v0.2.6 按命名空间隔离(文件前缀 + meta key + 独立 id 序列),索引 LSM 各持独立 store
- **LSM 多版本读取顺序错误(严重)** — `levels[]` 数组按 `push` 追加(新文件在尾部),但 `get()`/`rangeScan` 从头遍历(旧文件优先),同一 key 跨多次 flush 更新后读到旧值;`MergeIterator` 去重只取"先出堆者",不保证最新来源。v0.2.6 改为"数组头部即最新"(`unshift` + 按 id 降序加载),MergeIterator 显式取 sourceIndex 最小者
- **LZ4 往返不一致** — ① 匹配长度超过 19 字节被截断但 `si` 跳过全部匹配,解压丢数据;② matchField=0 的组合 token 与纯字面量 token 格式歧义;③ 字面量累积超过 15 字节后组合 token 永远无法输出;④ 输出缓冲区 `maxOut` 不足导致越界写入;⑤ 压缩失败时返回原样 input,解压端无法区分。v0.2.6 重写压缩器(长匹配分段、纯字面量 token 语义明确、动态上限)
- **React/Vue 集成运行时 ReferenceError** — `useDatabase`/`useSqlarkDatabase``import type` 导入 `MetonaSqlark` 但运行时 `new MetonaSqlark()``@ts-nocheck` 掩盖此 bug,实际使用必炸。改为 value import
- **sstableCache 无限增长** — SSTable 缓存无内存上限且 `init()` 预加载全部数据,大库 OOM。v0.2.6 引入 LRU 容量上限(`bufferPoolPages` × `pageSize`),查询前异步预加载兜底(修复缓存未命中静默返回 null 丢数据的隐患)
- **integrations 子路径缺失** — README 宣称 `metona-sqlark/react``metona-sqlark/vue` 可用但 `exports` 未声明,现已补齐(react/vue 为 peer dependencyrollup external
### Changed
- **Buffer Pool 配置真正生效** — `bufferPoolPages` 从无效配置变为 SSTable 缓存容量上限(默认 256 页 ≈ 1MB 可控内存)
- **LZ4 格式变更** — 旧版本压缩数据与新解压器不兼容,使用 `compression: true` 的用户需重新导入数据
- **SSTable 持久化串行化** — flush/compaction 改为串行链(`flushChain`),id 分配与持久化顺序一致,消除 fire-and-forget 竞态
- 文档数据修正 — 测试 761(41 套件)、覆盖率 81.1%(此前 91.0% 为排除 Aria 模块的陈旧数据)、体积 ~105KB / gzip ~27KB
### Added
- **AriaEngine 缓存内存上限测试** — 9 个测试:LRU 上限约束、驱逐后全表/PK/索引查询完整性、UPDATE/DELETE 全量作用、写路径上限、命名空间隔离回归、多版本回归、tombstone 回归
- **LZ4 往返一致性测试** — 6 个往返测试(重复/文本/随机/边界长度/15 字节边界/长匹配)
- **CryptoManager 加解密测试** — 7 个测试(往返/错误密码/不同 salt/实例隔离/全局兼容层/4KB 页面)
- **React/Vue hooks 集成测试** — 16 个测试(jest.mock 零依赖:mount 执行、错误路径、refresh、注入防护、数据库初始化)
- **jest.setup.js WebCrypto polyfill** — jsdom 环境补齐 `crypto.subtle` 支持 AES-GCM
---
## [0.2.5] - 2026-07-29
### Fixed
+13 -4
View File
@@ -43,11 +43,20 @@ src/
├── core.ts # MetonaSqlark main class
├── constants.ts # Types, defaults, enums, errors
├── utils.ts # Utility functions
├── connection-manager.ts # Connection pool (connect/disconnect)
├── engine/ # Storage engines
│ ├── interface.ts # IStorageEngine interface
│ ├── memory.ts # MemoryEngine (Map-based)
│ ├── indexeddb.ts # IndexedDBEngine (browser persistence)
── opfs.ts # OPFSEngine (Origin Private File System)
── opfs.ts # OPFSEngine (Origin Private File System)
│ └── aria/ # AriaEngine (LSM-Tree page storage engine)
│ ├── index/ # LSM / MemTable / SSTable / Bloom / MergeIterator
│ ├── page/ # 4KB slotted page format
│ ├── buffer/ # Buffer Pool (LRU eviction)
│ ├── wal/ # Write-Ahead Log + Checkpoint
│ ├── transaction/ # MVCC manager
│ ├── store/ # Backends (IndexedDB / OPFS / Memory)
│ └── compression/ # LZ4
├── hybrid/ # HybridEngine (write-through)
├── table/ # Table management & Schema validation
├── query/ # Query system
@@ -64,7 +73,7 @@ src/
├── plugin/ # Plugin system (14 lifecycle hooks)
└── integrations/ # React & Vue hooks
tests/ # Test suite (701+ test cases, 32 test suites)
tests/ # Test suite (761+ test cases, 41 test suites)
site/ # Documentation site (index / docs / demo)
```
@@ -133,9 +142,9 @@ npm run build
# Output in dist/
# ├── metona-sqlark.js UMD
# ├── metona-sqlark.min.js UMD minified (~42KB / ~10KB gzip)
# ├── metona-sqlark.min.js UMD minified (~105KB / ~27KB gzip)
# ├── metona-sqlark.esm.js ES Module
# ├── metona-sqlark.cjs.js CommonJS
# ├── metona-sqlark.cjs CommonJS
# └── metona-sqlark.d.ts TypeScript declarations
```
+34 -13
View File
@@ -1,10 +1,10 @@
# MetonaSqlark
<p align="center">
<img src="https://img.shields.io/badge/version-0.2.5-blue?style=flat-square" alt="version">
<img src="https://img.shields.io/badge/version-0.3.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-91.0%25-brightgreen?style=flat-square" alt="coverage">
<img src="https://img.shields.io/badge/tests-721%20passed-success?style=flat-square" alt="tests">
<img src="https://img.shields.io/badge/coverage-81.0%25-brightgreen?style=flat-square" alt="coverage">
<img src="https://img.shields.io/badge/tests-761%20passed-success?style=flat-square" alt="tests">
</p>
> 基于 TypeScript 的**前端关系型数据库**,支持完整 SQL 查询、Query Builder 链式 API、与 **AriaEngine 自研页面式存储引擎**。
@@ -18,13 +18,13 @@
- 🔒 **生产级数据安全** — WAL CRC 完整性校验、`RESTRICT` 外键约束、Hybrid 提交原子性、SQL 注入防护
- 🛡 **输入校验全覆盖**`maxLength`/`min`/`max` 约束、类型检查、必填验证
- 💾 **多引擎架构** — Memory / IndexedDB / OPFS / Hybrid(write-through) / Aria 五种模式
- 📝 **完整 SQL 支持** — SELECT/JOIN/子查询/GROUP BY/HAVING/ORDER BY/LIMIT/BETWEEN/IF NOT EXISTS/ALTER TABLE/TRUNCATE TABLE
- 📝 **完整 SQL 支持** — SELECT/JOIN/子查询/GROUP BY/HAVING/ORDER BY/LIMIT/BETWEEN/IF NOT EXISTS/ALTER TABLE/TRUNCATE TABLE/UNION/INSERT...SELECT/事务语句/CREATE INDEX/EXISTSv0.3.0
- 🔗 **Query Builder API** — 链式 `.select().where().orderBy().limit().execute()`
- 🔄 **事务回滚** — Memory/IndexedDB/Hybrid/Aria 四引擎事务原子性,自动回滚,MVCC 版本链接入读写路径
- 🌲 **RB-Tree 完整实现** — 标准红黑树插入+删除修复,O(log n) 保证
-**性能优化** — SSTableReader 二分查找统一、IndexedDB 索引利用、crypto 实例化避免全局状态
- 🌐 **浏览器兼容** — Chrome 80+ / Firefox 80+ / Safari 14+ / Edge 80+ / Node.js 16+
- 🧪 **721 测试 · 91.0% 覆盖率**37 套件,生产级质量保证
- 🧪 **761 测试 · 81.0% 覆盖率**41 套件,生产级质量保证
---
@@ -48,9 +48,9 @@ npm install @metona-team/metona-sqlark
或从 [`dist/`](./dist/) 目录下载:
- `metona-sqlark.js` — UMD 开发版(含 sourcemap
- `metona-sqlark.min.js` — UMD 压缩版(~42KB
- `metona-sqlark.min.js` — UMD 压缩版(~105KBgzip ~27KB
- `metona-sqlark.esm.js` — ES Module
- `metona-sqlark.cjs.js` — CommonJS
- `metona-sqlark.cjs` — CommonJS
- `metona-sqlark.d.ts` — TypeScript 类型声明
---
@@ -98,7 +98,7 @@ import { MetonaSqlark } from '@metona-team/metona-sqlark';
const db = await MetonaSqlark.create({
name: 'my-app',
mode: 'hybrid', // 'memory' | 'disk' | 'hybrid'
mode: 'hybrid', // 'memory' | 'disk' | 'hybrid' | 'aria'
diskEngine: 'indexeddb', // 'indexeddb' | 'opfs'
});
@@ -144,6 +144,27 @@ await db.query('ALTER TABLE users DROP COLUMN phone');
// TRUNCATE TABLE — 快速清空表 v0.2.5
await db.query('TRUNCATE TABLE old_logs');
// v0.3.0 — SQL 功能扩展
// 多语句(分号分隔)
await db.query("CREATE TABLE t (id STRING PRIMARY KEY); INSERT INTO t VALUES ('1'); INSERT INTO t VALUES ('2')");
// 事务语句
await db.query('BEGIN');
await db.query("INSERT INTO t VALUES ('3')");
await db.query('ROLLBACK'); // 回滚
// INSERT INTO ... SELECT
await db.query('INSERT INTO t SELECT id FROM t2 WHERE x > 1');
// UNION / UNION ALL
const rows = await db.query('SELECT name FROM users WHERE city = \'Beijing\' UNION SELECT name FROM users WHERE age < 30');
// 动态索引
await db.query('CREATE INDEX idx_users_city ON users (city)');
await db.query('DROP INDEX idx_users_city ON users (city)');
// EXISTS 关联子查询
const hasOrders = await db.query('SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)');
// v0.3.1 — CASE WHEN / JOIN 关联子查询 / 组提交
const labeled = await db.query("SELECT name, CASE WHEN age >= 18 THEN 'adult' ELSE 'minor' END AS status FROM users");
const joinExists = await db.query('SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id WHERE EXISTS (SELECT 1 FROM orders o2 WHERE o2.user_id = u.id AND o2.amount > 150)');
// 事务 — 自动回滚 v0.1.13
await db.transaction(async (trx) => {
await trx.table('users').insert({ id: '3', name: 'Charlie' });
@@ -278,7 +299,7 @@ const { data, loading, refresh } = useSqlarkQuery(db, 'SELECT * FROM users');
---
## 🌲 AriaEngine — 自研存储引擎 (v0.2.5)
## 🌲 AriaEngine — 自研存储引擎
AriaEngine 是内置的页面式存储引擎,对标 SQLite 的设计理念:
@@ -326,7 +347,7 @@ const rows = await db.query('SELECT * FROM users');
| **LSM-Tree** | MemTable (红黑树) → SSTable 多级索引,异步 Compaction,写背压 |
| **WAL** | Write-Ahead Log 二进制格式,CRC 校验,full/batch/none 三种模式(full 模式真正同步 ✅ v0.2.5),16MB 阈值自动 checkpoint |
| **MVCC** | 版本链 + 快照隔离,事务读写不互斥,自动 GC(每10次检查点),读写路径接入版本链 ✅ v0.2.5 |
| **Buffer Pool** | FileManager + LRU 页面缓存,256 页 ≈ 1MB 可控内存 |
| **Buffer Pool** | SSTable 缓存 LRU 上限(`bufferPoolPages` × `pageSize`,默认 256 页 ≈ 1MB 可控内存)✅ v0.2.6 生效,查询前异步预加载兜底,缓存驱逐不丢数据 |
| **Bloom Filter** | FNV-1a + Murmur 双哈希,SSTable footer 序列化,查询时 probe |
| **二级索引** | 每列独立 LSM Tree,支持 $eq/$in/$gt/$lt 范围扫描,SSTableReader 二分查找统一 ✅ v0.2.5 |
| **AES-GCM** | PBKDF2 密钥派生 + AES-256-GCM 页面级加密,CryptoManager 实例化 ✅ v0.2.5 |
@@ -352,9 +373,9 @@ npm run typecheck # 类型检查
| 指标 | 数值 |
|------|------|
| 测试用例 | 721 |
| 测试套件 | 37 |
| 行覆盖率 | 91.0% |
| 测试用例 | 761 |
| 测试套件 | 41 |
| 行覆盖率 | 81.0% |
| SQL 关键字 | 36 |
| 存储引擎 | 5Memory / IndexedDB / OPFS / Hybrid / **Aria** |
File diff suppressed because it is too large Load Diff
-1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+152 -7
View File
@@ -62,6 +62,8 @@ interface DatabaseConfig {
maxRowsPerQuery?: number;
/** 调试模式(启用后输出详细操作日志) */
debug?: boolean;
/** 多标签页同步(v0.3.2):BroadcastChannel 广播表变更,其他标签页自动刷新 */
multiTabSync?: boolean;
}
/** Where 条件操作符 */
type WhereOperator = '$eq' | '$ne' | '$gt' | '$gte' | '$lt' | '$lte' | '$in' | '$nin' | '$like' | '$and' | '$or' | '$not';
@@ -114,7 +116,7 @@ interface MetonaPlugin {
/** 销毁 */
destroy(): void;
}
declare const VERSION = "0.2.5";
declare const VERSION = "0.3.2";
/**
* metona-sqlark Plugin — 插件系统
@@ -179,6 +181,10 @@ interface IStorageEngine {
count(tableName: string, query?: QueryPlan): Promise<number>;
/** 清空表数据(保留结构) */
clear(tableName: string): Promise<void>;
/** 创建二级索引(CREATE INDEX */
createIndex?(tableName: string, column: string, unique?: boolean): Promise<void>;
/** 删除二级索引(DROP INDEX */
dropIndex?(tableName: string, column: string, indexName?: string): Promise<void>;
/** 开始事务 */
beginTransaction(): Promise<void>;
/** 提交事务 */
@@ -254,7 +260,10 @@ interface InsertStatement {
type: 'INSERT';
into: string;
columns?: string[];
values: unknown[][];
/** VALUES 字面量 */
values?: unknown[][];
/** INSERT INTO ... SELECT ...v0.3.0 */
select?: SelectStatement | SelectUnionStatement;
}
interface UpdateStatement {
type: 'UPDATE';
@@ -285,6 +294,15 @@ interface SelectStatement {
limit?: number;
offset?: number;
}
interface SelectUnionStatement {
type: 'SELECT_UNION';
/** 左操作数(可以是 SELECT 或嵌套 UNION */
left: SelectStatement | SelectUnionStatement;
/** 右操作数 */
right: SelectStatement | SelectUnionStatement;
/** UNION ALL 不去重 */
all?: boolean;
}
interface AlterTableStatement {
type: 'ALTER_TABLE';
name: string;
@@ -295,7 +313,31 @@ interface TruncateTableStatement {
type: 'TRUNCATE_TABLE';
name: string;
}
type Statement = SelectStatement | ExplainStatement | InsertStatement | UpdateStatement | DeleteStatement | CreateTableStatement | DropTableStatement | AlterTableStatement | TruncateTableStatement;
interface CreateIndexStatement {
type: 'CREATE_INDEX';
/** 索引名(语法占位) */
name: string;
table: string;
column: string;
/** UNIQUE 索引 */
unique?: boolean;
}
interface DropIndexStatement {
type: 'DROP_INDEX';
name: string;
table: string;
column: string;
}
interface BeginTransactionStatement {
type: 'BEGIN';
}
interface CommitTransactionStatement {
type: 'COMMIT';
}
interface RollbackTransactionStatement {
type: 'ROLLBACK';
}
type Statement = SelectStatement | SelectUnionStatement | ExplainStatement | InsertStatement | UpdateStatement | DeleteStatement | CreateTableStatement | DropTableStatement | AlterTableStatement | TruncateTableStatement | CreateIndexStatement | DropIndexStatement | BeginTransactionStatement | CommitTransactionStatement | RollbackTransactionStatement;
/**
* metona-sqlark Query Executor — AST 执行器
@@ -311,11 +353,23 @@ declare class QueryExecutor {
/** 设置查询结果行数上限 */
setMaxRowsPerQuery(max: number): void;
execute(stmt: Statement): Promise<unknown>;
/** 递归执行 UNION / UNION ALL,返回合并结果 */
private executeSelectUnion;
private executeSelectPart;
/** 将 UNION 右侧行投影为左侧列结构(按位置取值) */
private projectUnionRow;
/** EXPLAIN: 输出查询计划 */
private executeExplain;
private executeSelect;
private executeJoinSelect;
private prefixRow;
/**
* 哈希连接(v0.3.2):ON 为单一等值条件且右表列为索引/主键时,
* 收集左表连接值 → 一次 $in 查询右表 → 哈希映射匹配。
* 替代嵌套循环,大表 INNER/LEFT JOIN 复杂度 O(N + M)。
* 不适用时返回 null(回退嵌套循环)。
*/
private tryHashJoin;
/** 嵌套循环连接(优化:避免 ON 时对象扩散) */
private joinRows;
private executeGroupBy;
@@ -328,14 +382,47 @@ declare class QueryExecutor {
private executeDropTable;
private executeAlterTable;
private executeTruncateTable;
private executeCreateIndex;
private executeDropIndex;
private executeBegin;
private executeCommit;
private executeRollback;
/** 列列表是否包含 CASE WHEN 表达式 */
private hasCaseColumn;
/** WHERE 是否包含 CASE WHEN 表达式键 */
private whereHasCase;
getEngine(): IStorageEngine;
/**
* 列投影(v0.3.1):普通列走 projectColumnsCASE WHEN 表达式逐行求值
*/
private projectRow;
/** 检查 SELECT 列列表中是否包含聚合函数 */
private _hasAggregateColumn;
/** 计算单行聚合结果(无 GROUP BY) */
private computeSingleAggregate;
/** 剥离主表别名前缀:'u.id' → 'id'(键与 $col 值均处理,支持多层别名) */
private normalizeWhereColumns;
private normalizeExistsValue;
private normalizeFieldValue;
private stripAlias;
/** WHERE 是否含关联引用($col 或关联 EXISTS)或 CASE WHEN 表达式键 */
private hasCorrelatedRefs;
private fieldHasColRef;
/** 移除关联 EXISTS 标记(引擎层先执行无 EXISTS 条件的查询) */
private stripCorrelatedExists;
/** 逐行绑定外层行上下文,求值关联 EXISTS、$col 引用与 CASE WHEN 键 */
private filterCorrelated;
/** 将 WHERE 中的 CASE WHEN 表达式键求值为布尔条件($caseResult */
private resolveCaseKeys;
/** CASE 求值结果与操作符条件比较 */
private caseConditionMatches;
/** 将 where 中的 $col 引用替换为上下文行值 */
private bindColumnRefs;
private bindWhereRefs;
/**
* 递归扫描 WHERE 条件,找到 $subquery 标记并执行子查询,
* 将结果替换为具体值。
* @param contextRow 关联子查询的外层行上下文(用于绑定 $col 引用)
*/
private resolveSubqueries;
/**
@@ -395,7 +482,8 @@ declare class UpdateQueryBuilder {
private tableName;
private _updates;
private _where;
constructor(engine: IStorageEngine, tableName: string, _updates: Record<string, unknown>);
private onWrite?;
constructor(engine: IStorageEngine, tableName: string, _updates: Record<string, unknown>, onWrite?: (table: string) => void);
where(condition: WhereCondition): this;
execute(): Promise<number>;
toAST(): UpdateStatement;
@@ -404,7 +492,8 @@ declare class DeleteQueryBuilder {
private engine;
private tableName;
private _where;
constructor(engine: IStorageEngine, tableName: string);
private onWrite?;
constructor(engine: IStorageEngine, tableName: string, onWrite?: (table: string) => void);
where(condition: WhereCondition): this;
execute(): Promise<number>;
toAST(): DeleteStatement;
@@ -420,7 +509,9 @@ declare class Table<T = Record<string, unknown>> {
private engine;
private schema;
private executor;
constructor(engine: IStorageEngine, tableName: string, executor?: QueryExecutor);
/** 写入回调(多标签页广播,v0.3.2) */
private onWrite?;
constructor(engine: IStorageEngine, tableName: string, executor?: QueryExecutor, onWrite?: (table: string) => void);
getSchema(): Promise<TableSchema>;
insert(row: T & Record<string, unknown>): Promise<string>;
insertMany(rows: (T & Record<string, unknown>)[]): Promise<string[]>;
@@ -472,6 +563,8 @@ declare class MetonaSqlark {
get maxRowsPerQuery(): number;
/** 调试模式 */
get debug(): boolean;
/** 多标签页同步通道(v0.3.2 */
private channel;
constructor(config: DatabaseConfig);
/** 初始化数据库(创建引擎、打开连接) */
init(): Promise<void>;
@@ -500,12 +593,18 @@ declare class MetonaSqlark {
subscribe(tableName: string, callback: (event: {
type: string;
row?: unknown;
table?: string;
}) => void): () => void;
/** 触发变更事件 */
emit(tableName: string, event: {
type: string;
row?: unknown;
table?: string;
}): void;
/** 广播表变更到其他标签页(多标签页同步) */
broadcastChange(tableName: string): void;
/** 写语句对应的表名(多标签页广播用) */
private writeStatementTable;
private migrations;
/** 注册迁移 */
addMigration(version: number, up: (db: MetonaSqlark) => Promise<void>): void;
@@ -553,6 +652,8 @@ declare class MemoryEngine implements IStorageEngine {
delete(tableName: string, query: QueryPlan): Promise<number>;
count(tableName: string, query?: QueryPlan): Promise<number>;
clear(tableName: string): Promise<void>;
createIndex(tableName: string, column: string, unique?: boolean): Promise<void>;
dropIndex(tableName: string, column: string, _indexName?: string): Promise<void>;
beginTransaction(): Promise<void>;
commitTransaction(): Promise<void>;
rollbackTransaction(): Promise<void>;
@@ -590,6 +691,12 @@ declare class IndexedDBEngine implements IStorageEngine {
private memoryCache;
private txActive;
open(dbName: string, version: number): Promise<void>;
/**
* 从 IDB 恢复内存 schema
* 1. 优先读取持久化的 schema 记录('__metona_schema' storev0.3.2
* 2. 旧数据回退:从 objectStore 主键 / 索引 / 样例数据推断
*/
private rebuildSchemaFromIDB;
close(): Promise<void>;
isOpen(): boolean;
createTable(schema: TableSchema): Promise<void>;
@@ -603,6 +710,8 @@ declare class IndexedDBEngine implements IStorageEngine {
delete(tableName: string, query: QueryPlan): Promise<number>;
count(tableName: string, query?: QueryPlan): Promise<number>;
clear(tableName: string): Promise<void>;
createIndex(tableName: string, column: string, unique?: boolean): Promise<void>;
dropIndex(tableName: string, column: string, _indexName?: string): Promise<void>;
beginTransaction(): Promise<void>;
commitTransaction(): Promise<void>;
rollbackTransaction(): Promise<void>;
@@ -648,6 +757,8 @@ declare class OPFSEngine implements IStorageEngine {
delete(tableName: string, query: QueryPlan): Promise<number>;
count(tableName: string, query?: QueryPlan): Promise<number>;
clear(tableName: string): Promise<void>;
createIndex(tableName: string, column: string, unique?: boolean): Promise<void>;
dropIndex(tableName: string, column: string, indexName?: string): Promise<void>;
beginTransaction(): Promise<void>;
commitTransaction(): Promise<void>;
rollbackTransaction(): Promise<void>;
@@ -727,6 +838,8 @@ declare class AriaEngine implements IStorageEngine {
delete(tableName: string, query: QueryPlan): Promise<number>;
count(tableName: string, query?: QueryPlan): Promise<number>;
clear(tableName: string): Promise<void>;
createIndex(tableName: string, column: string, unique?: boolean): Promise<void>;
dropIndex(tableName: string, column: string, _indexName?: string): Promise<void>;
beginTransaction(): Promise<void>;
commitTransaction(): Promise<void>;
rollbackTransaction(): Promise<void>;
@@ -741,6 +854,14 @@ declare class AriaEngine implements IStorageEngine {
private checkType;
private persistSchemas;
private loadSchemas;
/**
* 创建命名空间隔离的 SSTableStore。
*
* 主 LSM 与每个二级索引 LSM 各持有独立实例:
* - 文件 key 前缀隔离(sst_ / sst_idx_${table}_${col}_
* - 元数据 key 隔离(__aria_lsm_meta / __aria_lsm_meta_${ns}
* - id 序列独立(避免 v0.2.4 共享 id 空间导致的文件互相覆盖)
*/
private createSSTableStore;
private applyWALRecord;
/** 更新行的二级索引条目 */
@@ -751,6 +872,8 @@ declare class AriaEngine implements IStorageEngine {
private indexScanToRows;
/** 每 10 次 gc 计数器触发一次 MVCC 垃圾回收 */
private tryGC;
/** 回收主 LSM 与所有二级索引 LSM 的临时缓存超限 */
private trimAllCaches;
/** 检查内存预算,超出时强制 flush + GC */
private checkMemoryBudget;
/** 估算 WAL 大小(字节) */
@@ -801,8 +924,15 @@ declare class HybridEngine implements IStorageEngine {
private memoryEngine;
private diskEngine;
private diskEngineType;
private dbName;
private version;
constructor(diskEngine?: DiskEngine);
open(dbName: string, version: number): Promise<void>;
/**
* 从磁盘重载内存缓存(v0.3.2:多标签页同步)。
* 其他标签页写入磁盘后调用,使本标签页读到最新数据。
*/
reloadMemoryFromDisk(): Promise<void>;
close(): Promise<void>;
isOpen(): boolean;
createTable(schema: TableSchema): Promise<void>;
@@ -816,6 +946,8 @@ declare class HybridEngine implements IStorageEngine {
delete(tableName: string, query: QueryPlan): Promise<number>;
count(tableName: string, query?: QueryPlan): Promise<number>;
clear(tableName: string): Promise<void>;
createIndex(tableName: string, column: string, unique?: boolean): Promise<void>;
dropIndex(tableName: string, column: string, indexName?: string): Promise<void>;
beginTransaction(): Promise<void>;
commitTransaction(): Promise<void>;
rollbackTransaction(): Promise<void>;
@@ -835,6 +967,8 @@ declare class HybridEngine implements IStorageEngine {
/** 解析 SQL 字符串为 AST Statement */
declare function parse(sql: string): Statement;
/** 解析 SQL 字符串为 AST Statement 数组(分号分隔的多语句支持,v0.3.0) */
declare function parseAll(sql: string): Statement[];
/**
* metona-sqlark SQL Token Types — 词法单元定义
@@ -895,6 +1029,17 @@ declare enum TokenType {
MIN = "MIN",
MAX = "MAX",
DISTINCT = "DISTINCT",
BEGIN = "BEGIN",
COMMIT = "COMMIT",
ROLLBACK = "ROLLBACK",
UNION = "UNION",
ALL = "ALL",
INDEX = "INDEX",
CASE = "CASE",
WHEN = "WHEN",
THEN = "THEN",
ELSE = "ELSE",
END = "END",
IDENTIFIER = "IDENTIFIER",
STRING = "STRING",
NUMBER = "NUMBER",
@@ -1028,4 +1173,4 @@ declare global {
declare const MeSqlark: typeof MetonaSqlark;
export { AriaEngine, AriaEngineConfig, ColumnDef, DatabaseConfig, DeleteStatement, DiskEngine, FieldType, HybridEngine, IStorageEngine, IndexedDBEngine, InsertStatement, MeSqlark, MemoryEngine, MetonaSqlark, OPFSBackend, OPFSEngine, SelectStatement, Statement, StorageMode, Table, TableSchema, UpdateStatement, VERSION, api, create, api as default, parse, tokenize };
export { AriaEngine, AriaEngineConfig, ColumnDef, DatabaseConfig, DeleteStatement, DiskEngine, FieldType, HybridEngine, IStorageEngine, IndexedDBEngine, InsertStatement, MeSqlark, MemoryEngine, MetonaSqlark, OPFSBackend, OPFSEngine, SelectStatement, Statement, StorageMode, Table, TableSchema, UpdateStatement, VERSION, api, create, api as default, parse, parseAll, tokenize };
+2046 -582
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
File diff suppressed because one or more lines are too long
+2046 -581
View File
File diff suppressed because it is too large Load Diff
+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
+12
View File
@@ -13,3 +13,15 @@ if (typeof globalThis.TextDecoder === 'undefined') {
const { TextDecoder: TD } = require('util');
globalThis.TextDecoder = TD;
}
// polyfill WebCrypto (crypto.subtle) — jsdom 仅提供 getRandomValues
if (typeof globalThis.crypto === 'undefined' || typeof globalThis.crypto.subtle === 'undefined') {
const { webcrypto } = require('crypto');
globalThis.crypto = webcrypto;
// jsdom 环境暴露 getRandomValues 的旧引用可能被覆盖,统一替换
Object.defineProperty(globalThis, 'crypto', {
value: webcrypto,
writable: true,
configurable: true,
});
}
+13 -5
View File
@@ -1,17 +1,25 @@
{
"name": "@metona-team/metona-sqlark",
"version": "0.2.5",
"version": "0.3.2",
"description": "Frontend SQL database with in-memory and disk dual-mode storage",
"type": "module",
"main": "dist/metona-sqlark.js",
"module": "src/index.ts",
"main": "dist/metona-sqlark.cjs",
"module": "dist/metona-sqlark.esm.js",
"unpkg": "dist/metona-sqlark.min.js",
"jsdelivr": "dist/metona-sqlark.min.js",
"types": "dist/metona-sqlark.d.ts",
"exports": {
".": {
"import": "./src/index.ts",
"require": "./dist/metona-sqlark.js",
"import": "./dist/metona-sqlark.esm.js",
"require": "./dist/metona-sqlark.cjs",
"types": "./dist/metona-sqlark.d.ts"
},
"./react": {
"import": "./src/integrations/react.ts",
"types": "./dist/metona-sqlark.d.ts"
},
"./vue": {
"import": "./src/integrations/vue.ts",
"types": "./dist/metona-sqlark.d.ts"
}
},
+7 -2
View File
@@ -40,6 +40,8 @@ export default [
exports: 'named',
sourcemap: true,
},
// react/vue 为 peer dependencyintegrations 子路径),不打包
external: ['react', 'vue'],
plugins: [...basePlugins, ...devPlugins],
},
// Only build all formats in production
@@ -53,17 +55,19 @@ export default [
exports: 'named',
sourcemap: true,
},
external: ['react', 'vue'],
plugins: basePlugins,
},
// CommonJS
// CommonJS.cjs 后缀:package.json 为 type:module.js 会被 Node 按 ESM 解析)
{
input: 'src/index.ts',
output: {
file: 'dist/metona-sqlark.cjs.js',
file: 'dist/metona-sqlark.cjs',
format: 'cjs',
exports: 'named',
sourcemap: true,
},
external: ['react', 'vue'],
plugins: basePlugins,
},
// UMD minified
@@ -76,6 +80,7 @@ export default [
exports: 'named',
sourcemap: false,
},
external: ['react', 'vue'],
plugins: [
...basePlugins,
terser({
+266
View File
@@ -0,0 +1,266 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>⚡ 性能基准 — MetonaSqlark v0.3.2</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='%236366f1'/><text x='16' y='22' text-anchor='middle' font-size='20' fill='white'>◈</text></svg>">
<style>
:root {
--bg:#0a0a0f; --surface:#0d1117; --surface2:#161b22; --border:#30363d;
--primary:#6366f1; --accent:#06b6d4; --accent2:#ec4899; --green:#22c55e;
--text:#e2e8f0; --text2:#8b949e; --radius:12px;
--gradient:linear-gradient(135deg,#6366f1,#06b6d4,#ec4899);
}
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'Inter',-apple-system,system-ui,sans-serif; background:var(--bg); color:var(--text); min-height:100vh; }
header {
height:56px; display:flex; align-items:center; justify-content:space-between; padding:0 24px;
background:var(--surface); border-bottom:1px solid var(--border); position:sticky; top:0; z-index:10;
}
.logo { font-weight:800; font-size:1.1rem; display:flex; align-items:center; gap:10px; }
.logo .icon { width:28px; height:28px; border-radius:7px; background:var(--gradient); display:flex; align-items:center; justify-content:center; font-size:0.85rem; font-weight:900; color:#fff; }
.logo span { background:var(--gradient); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
nav { display:flex; gap:20px; }
nav a { color:var(--text2); text-decoration:none; font-size:0.88rem; transition:.2s; }
nav a:hover,.nav-active { color:var(--text); }
.container { max-width:1000px; margin:0 auto; padding:32px 24px 60px; }
.hero { margin-bottom:28px; }
.hero h1 { font-size:1.6rem; margin-bottom:8px; }
.hero p { color:var(--text2); font-size:0.92rem; line-height:1.7; }
.hero p code { background:var(--surface2); padding:2px 6px; border-radius:5px; font-size:0.85em; }
.controls { display:flex; gap:12px; align-items:center; margin-bottom:24px; flex-wrap:wrap; }
.btn {
display:inline-flex; align-items:center; gap:6px; padding:10px 22px; border-radius:8px;
font-weight:600; font-size:0.85rem; cursor:pointer; border:none; transition:.25s; font-family:inherit;
}
.btn-run { background:var(--gradient); color:#fff; box-shadow:0 4px 15px rgba(99,102,241,0.35); }
.btn-run:hover { transform:translateY(-1px); box-shadow:0 6px 25px rgba(99,102,241,0.5); }
.btn-run:active { transform:scale(0.97); }
.btn-run:disabled { opacity:0.5; cursor:not-allowed; transform:none; }
.btn-small { background:var(--surface2); color:var(--text2); border:1px solid var(--border); font-size:0.8rem; padding:8px 16px; }
.btn-small:hover { color:var(--text); border-color:var(--text2); }
select {
background:var(--surface2); color:var(--text); border:1px solid var(--border); border-radius:8px;
padding:9px 12px; font-size:0.85rem; font-family:inherit; outline:none;
}
.status { font-size:0.85rem; color:var(--text2); }
.status .spinner { display:inline-block; width:14px; height:14px; border:2px solid var(--border); border-top-color:var(--accent); border-radius:50%; animation:spin 0.8s linear infinite; vertical-align:-2px; }
@keyframes spin { to { transform:rotate(360deg); } }
table { width:100%; border-collapse:collapse; background:var(--surface); border-radius:var(--radius); overflow:hidden; border:1px solid var(--border); }
th,td { padding:12px 16px; text-align:left; font-size:0.88rem; border-bottom:1px solid var(--border); }
th { background:var(--surface2); color:var(--text2); font-size:0.78rem; text-transform:uppercase; letter-spacing:0.8px; white-space:nowrap; }
tr:last-child td { border-bottom:none; }
td.num { font-family:'JetBrains Mono',monospace; text-align:right; }
td.best { color:var(--green); font-weight:700; }
.group-header td { background:rgba(99,102,241,0.08); font-weight:700; color:var(--accent); }
.section { margin-bottom:32px; }
.section h2 { font-size:1.05rem; margin-bottom:14px; color:var(--text); display:flex; align-items:center; gap:8px; }
.section h2 .tag { font-size:0.68rem; padding:3px 8px; border-radius:999px; background:var(--surface2); border:1px solid var(--border); color:var(--text2); font-weight:600; }
.note { font-size:0.8rem; color:var(--text2); margin-top:14px; line-height:1.7; }
.note code { background:var(--surface2); padding:2px 5px; border-radius:4px; }
.summary-cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:14px; margin-bottom:28px; }
.card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:18px; }
.card .val { font-size:1.5rem; font-weight:800; background:var(--gradient); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
.card .lbl { font-size:0.78rem; color:var(--text2); margin-top:4px; }
</style>
</head>
<body>
<header>
<div class="logo"><div class="icon"></div><span>MetonaSqlark</span></div>
<nav>
<a href="index.html">首页</a>
<a href="docs.html">文档</a>
<a href="demo.html">演示</a>
<a href="benchmark.html" class="nav-active">基准</a>
</nav>
</header>
<div class="container">
<div class="hero">
<h1>⚡ 性能基准测试</h1>
<p>在浏览器中实测 <code>Memory</code><code>Aria</code>(LSM-Tree 自研引擎)两种模式的真实性能。
数据量分三档(1K / 10K / 50K 行),测量 <code>INSERT</code> / 主键查询 / 索引查询 / <code>UPDATE</code> / <code>DELETE</code> 的吞吐(ops/sec)。
<strong>测试结果取决于你的设备与浏览器</strong>,仅供参考。</p>
</div>
<div class="controls">
<button class="btn btn-run" id="run-btn" onclick="runAll()">▶ 运行完整基准</button>
<button class="btn btn-small" onclick="runAll(true)">⚡ 快速模式(1K/10K</button>
<button class="btn btn-small" onclick="window.location.reload()">↻ 重置</button>
<span class="status" id="status"></span>
</div>
<div class="summary-cards" id="summary"></div>
<div class="section" id="result-section" style="display:none">
<h2>测试结果 <span class="tag">ops/sec</span></h2>
<table id="result-table">
<thead><tr><th>引擎</th><th>数据量</th><th>INSERT</th><th>主键查询</th><th>索引查询</th><th>UPDATE</th><th>DELETE</th></tr></thead>
<tbody></tbody>
</table>
<p class="note">注:Memory 模式为纯内存 Map 存储;Aria 模式为 LSM-Tree + MemTable 存储(<code>storageBackend: 'memory'</code>,无磁盘 I/O)。
INSERT 为单行批量写入(含 WAL 记录);UPDATE/DELETE 按主键条件执行。</p>
</div>
</div>
<script src="../dist/metona-sqlark.js"></script>
<script>
const M = window.MetonaSqlark;
const SIZES = [1000, 10000, 50000];
const SIZES_FAST = [1000, 10000];
let cancelled = false;
function setStatus(text, spin) {
const el = document.getElementById('status');
el.innerHTML = (spin ? '<span class="spinner"></span> ' : '') + text;
}
function setRunning(on) {
document.getElementById('run-btn').disabled = on;
}
function makeRows(n) {
const rows = [];
for (let i = 0; i < n; i++) {
rows.push({ id: 'k' + i, name: 'User' + i, age: 18 + (i % 60), city: 'City' + (i % 100) });
}
return rows;
}
async function createDb(mode) {
const db = new M.MetonaSqlark({
name: 'bench-' + mode + '-' + Date.now(),
mode,
diskEngine: 'memory',
});
await db.init();
await db.defineTable('bench', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
age: { type: 'number', index: true },
city: { type: 'string' },
});
return db;
}
async function timeIt(fn) {
const start = performance.now();
await fn();
return performance.now() - start;
}
async function runBench(mode, size) {
const db = await createDb(mode);
const rows = makeRows(size);
const result = { mode, size, insert: 0, pk: 0, idx: 0, update: 0, del: 0 };
try {
// INSERT(批量)
let ms = await timeIt(() => db.table('bench').insertMany(rows));
result.insert = Math.round(size / (ms / 1000));
// 主键查询 × 500
ms = await timeIt(async () => {
for (let i = 0; i < 500; i++) await db.query(`SELECT * FROM bench WHERE id = 'k${(i * 37) % size}'`);
});
result.pk = Math.round(500 / (ms / 1000));
// 索引查询 × 500
ms = await timeIt(async () => {
for (let i = 0; i < 500; i++) await db.query(`SELECT * FROM bench WHERE age = ${18 + ((i * 17) % 60)}`);
});
result.idx = Math.round(500 / (ms / 1000));
// UPDATE × 200(按主键)
ms = await timeIt(async () => {
for (let i = 0; i < 200; i++) await db.query(`UPDATE bench SET name = 'X${i}' WHERE id = 'k${(i * 53) % size}'`);
});
result.update = Math.round(200 / (ms / 1000));
// DELETE × 200(按主键)
ms = await timeIt(async () => {
for (let i = 0; i < 200; i++) await db.query(`DELETE FROM bench WHERE id = 'k${(i * 71) % size}'`);
});
result.del = Math.round(200 / (ms / 1000));
} finally {
await db.close();
}
return result;
}
async function runAll(quick) {
if (cancelled) { cancelled = false; }
const sizes = quick ? SIZES_FAST : SIZES;
const btn = document.getElementById('run-btn');
btn.textContent = '⏳ 测试中…';
setRunning(true);
document.getElementById('result-section').style.display = 'none';
document.getElementById('summary').innerHTML = '';
const results = [];
for (const mode of ['memory', 'aria']) {
for (const size of sizes) {
setStatus(`正在测试 ${mode === 'memory' ? 'Memory' : 'Aria'} 引擎 · ${size.toLocaleString()} 行…`, true);
// 让 UI 刷新
await new Promise((r) => setTimeout(r, 30));
try {
const r = await runBench(mode, size);
results.push(r);
} catch (e) {
setStatus(`✗ ${mode}/${size} 失败: ${e.message}`, false);
console.error(e);
}
}
}
renderResults(results);
setStatus('✓ 测试完成', false);
btn.textContent = '▶ 运行完整基准';
setRunning(false);
}
function renderResults(results) {
const tbody = document.querySelector('#result-table tbody');
tbody.innerHTML = '';
// 汇总卡片
const summary = document.getElementById('summary');
let bestInsert = 0, bestInsertMode = '', bestPk = 0, bestPkMode = '', totalTests = 0;
for (const r of results) {
totalTests++;
if (r.insert > bestInsert) { bestInsert = r.insert; bestInsertMode = r.mode; }
if (r.pk > bestPk) { bestPk = r.pk; bestPkMode = r.mode; }
}
summary.innerHTML = `
<div class="card"><div class="val">${results.length} 组</div><div class="lbl">测试组数</div></div>
<div class="card"><div class="val">${bestInsert.toLocaleString()} ops/s</div><div class="lbl">INSERT 峰值(${bestInsertMode}</div></div>
<div class="card"><div class="val">${bestPk.toLocaleString()} ops/s</div><div class="lbl">主键查询峰值(${bestPkMode}</div></div>
`;
// 表格(按引擎分组)
let lastMode = '';
for (const r of results) {
if (r.mode !== lastMode) {
lastMode = r.mode;
const row = document.createElement('tr');
row.className = 'group-header';
row.innerHTML = `<td colspan="6">${r.mode === 'memory' ? '🚀 Memory 引擎' : '🌲 Aria 引擎(LSM-Tree'}</td>`;
tbody.appendChild(row);
}
const row = document.createElement('tr');
const fmt = (v) => `<td class="num">${v ? v.toLocaleString() : '—'}</td>`;
row.innerHTML = `
<td>${r.mode}</td>
<td>${r.size.toLocaleString()} 行</td>
${fmt(r.insert)}${fmt(r.pk)}${fmt(r.idx)}${fmt(r.update)}${fmt(r.del)}
`;
tbody.appendChild(row);
}
document.getElementById('result-section').style.display = 'block';
document.getElementById('result-section').scrollIntoView({ behavior: 'smooth' });
}
</script>
</body>
</html>
+131 -9
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🧪 在线演示 — MetonaSqlark v0.2.5</title>
<title>🧪 在线演示 — MetonaSqlark v0.3.2</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='%236366f1'/><text x='16' y='22' text-anchor='middle' font-size='20' fill='white'>◈</text></svg>">
<style>
:root {
@@ -82,14 +82,15 @@
<a href="index.html">首页</a>
<a href="docs.html">文档</a>
<a href="demo.html" class="nav-active">演示</a>
<a href="benchmark.html">基准</a>
</nav>
<div class="status"><span class="dot"></span> Memory 模式 — v0.2.5</div>
<div class="status"><span class="dot"></span> Memory 模式 — v0.3.2</div>
</header>
<div class="main">
<div class="editor-panel">
<div class="editor-area">
<textarea id="sql-input" placeholder="输入 SQL 语句...&#10;&#10;SELECT * FROM users;&#10;INSERT INTO users VALUES ('4', 'Diana', 'diana@test.com', 28);&#10;SELECT u.name, o.amount FROM users u INNER JOIN orders o ON u.id = o.user_id;">-- 🚀 MetonaSqlark v0.2.5 在线演示
<textarea id="sql-input" placeholder="输入 SQL 语句...&#10;&#10;SELECT * FROM users;&#10;INSERT INTO users VALUES ('4', 'Diana', 'diana@test.com', 28);&#10;SELECT u.name, o.amount FROM users u INNER JOIN orders o ON u.id = o.user_id;">-- 🚀 MetonaSqlark v0.3.2 在线演示
-- 已预置 users / orders / products 表数据
-- 新特性: ALTER TABLE · TRUNCATE TABLE · WAL同步 · MVCC · SQL注入防护
@@ -121,6 +122,12 @@
<button class="btn btn-preset" onclick="loadPreset('adv')">🧪 高级</button>
<button class="btn btn-preset" onclick="loadPreset('alter')">🏗 ALTER</button>
<button class="btn btn-preset" onclick="loadPreset('truncate')">🗑 TRUNCATE</button>
<button class="btn btn-preset" onclick="loadPreset('casewhen')">🎯 CASE WHEN</button>
<button class="btn btn-preset" onclick="loadPreset('union')">🔀 UNION</button>
<button class="btn btn-preset" onclick="loadPreset('insertselect')">📥 INSERT SELECT</button>
<button class="btn btn-preset" onclick="loadPreset('exists')">🔍 EXISTS</button>
<button class="btn btn-preset" onclick="loadPreset('index')">🗂 索引</button>
<button class="btn btn-preset" onclick="loadPreset('multistmt')">📜 多语句/事务</button>
<button class="btn btn-preset" onclick="loadPreset('aria')" style="color:#ec4899;border-color:#ec4899;">🌲 Aria</button>
</div>
</div>
@@ -511,9 +518,9 @@ SELECT COUNT(*) as total FROM temp_logs;
-- 清理
DROP TABLE temp_logs;`,
aria: `-- 🌲 AriaEngine 演示 (v0.2.5)
aria: `-- 🌲 AriaEngine 演示 (v0.2.5 → v0.3.2)
-- AriaEngine: LSM-Tree 自研存储引擎
-- 支持 LSM-Tree · WAL CRC同步 · MVCC版本链 · BloomFilter · 二级索引 · AES-GCM · 721测试
-- 支持 LSM-Tree · WAL CRC同步 · MVCC版本链 · BloomFilter · 二级索引 · AES-GCM · 836测试
-- 基础 CRUD 完全兼容
CREATE TABLE IF NOT EXISTS tasks (
@@ -533,18 +540,133 @@ DROP TABLE temp_logs;`,
-- 聚合统计
SELECT done, COUNT(*) as cnt FROM tasks GROUP BY done;
-- CASE WHEN 表达式
SELECT title,
CASE WHEN done THEN '✅ done' ELSE '⏳ pending' END AS status
FROM tasks;
-- EXISTS 关联子查询
SELECT u.name FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
-- AriaEngine 特性:
-- • LSM-Tree: MemTable (红黑树) → SSTable 多级索引
-- • WAL: Write-Ahead Log 保证崩溃恢复
-- • WAL: Write-Ahead Log 保证崩溃恢复 + 批量组提交
-- • MVCC: 版本链 + 快照隔离
-- • Buffer Pool: LRU 页面缓存 (256页 ~ 1MB)
-- • Buffer Pool: SSTable LRU 缓存 (256页 ~ 1MB) ✅ 已生效
-- • Bloom Filter: FNV-1a + Murmur 双哈希
-- • Slotted Page: 4KB 页面 + Tuple 二进制编码
-- • 二级索引: 每列独立 LSM + 动态 CREATE INDEX
-- 生产环境: mode: 'aria' 激活自研引擎
-- const db = await MetonaSqlark.create({
-- name: 'my-app', mode: 'aria'
-- });`,
casewhen: `-- 🎯 CASE WHEN 条件表达式 (v0.3.1 / v0.3.2)
-- SELECT 列:多 WHEN + ELSE
SELECT name, age,
CASE WHEN age >= 30 THEN 'senior'
WHEN age >= 25 THEN 'mid'
ELSE 'junior' END AS age_group
FROM users ORDER BY age;
-- WHERE 条件中的 CASE WHEN
SELECT name FROM users
WHERE CASE WHEN age >= 25 THEN 'adult' ELSE 'young' END = 'adult';
-- 聚合中的 CASE WHEN
SELECT
SUM(CASE WHEN age >= 30 THEN 1 ELSE 0 END) AS seniors,
SUM(CASE WHEN age < 30 THEN 1 ELSE 0 END) AS juniors,
AVG(CASE WHEN age >= 25 THEN age END) AS avg_adult_age
FROM users;`,
union: `-- 🔀 UNION / UNION ALL (v0.3.0)
-- UNION 去重合并两表数据
SELECT name FROM users WHERE age >= 30
UNION
SELECT name FROM users WHERE age < 30;
-- UNION 对重复行去重(同查询两次 → 结果去重)
SELECT category FROM products
UNION
SELECT category FROM products;
-- UNION ALL 保留重复
SELECT category FROM products WHERE price >= 100
UNION ALL
SELECT category FROM products WHERE price >= 50;`,
insertselect: `-- 📥 INSERT INTO ... SELECT (v0.3.0)
-- 建备份表并复制全量数据
DROP TABLE IF EXISTS users_backup;
CREATE TABLE users_backup (
id STRING PRIMARY KEY, name STRING, email STRING, age NUMBER
);
INSERT INTO users_backup SELECT * FROM users;
-- 确认复制
SELECT COUNT(*) as backed_up FROM users_backup;
-- INSERT SELECT 带 WHERE 过滤
DROP TABLE IF EXISTS big_spenders;
CREATE TABLE big_spenders (
id STRING PRIMARY KEY, product STRING, amount NUMBER
);
INSERT INTO big_spenders (id, product, amount)
SELECT o.id, o.product, o.amount FROM orders o WHERE o.amount >= 150;
-- 确认过滤结果
SELECT * FROM big_spenders;`,
exists: `-- 🔍 EXISTS 关联子查询 (v0.3.0 / v0.3.1)
-- 有订单的用户(关联子查询)
SELECT u.name FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
-- 无订单的用户(NOT EXISTS
SELECT u.name FROM users u
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
-- EXISTS + JOIN 组合
SELECT u.name, o.product, o.amount FROM users u
JOIN orders o ON u.id = o.user_id
WHERE EXISTS (SELECT 1 FROM orders o2 WHERE o2.user_id = u.id AND o2.amount > 100);`,
index: `-- 🗂 动态索引 CREATE / DROP INDEX (v0.3.0)
-- 为 orders.user_id 创建索引
CREATE INDEX idx_orders_user ON orders (user_id);
-- 索引查找(走二级索引)
SELECT u.name, o.product, o.amount
FROM orders o JOIN users u ON u.id = o.user_id
WHERE o.user_id = '1';
-- 删除索引
DROP INDEX idx_orders_user ON orders (user_id);
-- 删除后回退全表扫描(结果不变)
SELECT * FROM orders WHERE user_id = '3';`,
multistmt: `-- 📜 多语句 + 事务语句 (v0.3.0)
-- 分号分隔的多语句一次执行
DROP TABLE IF EXISTS audit;
CREATE TABLE audit (id STRING PRIMARY KEY, msg STRING);
INSERT INTO audit VALUES ('a1', 'first');
INSERT INTO audit VALUES ('a2', 'second');
SELECT * FROM audit ORDER BY id;
-- 事务语句:BEGIN → ROLLBACKa3 不会生效)
BEGIN;
INSERT INTO audit VALUES ('a3', 'will be rolled back');
ROLLBACK;
SELECT * FROM audit ORDER BY id;
-- 事务语句:BEGIN → COMMITa4 生效)
BEGIN;
INSERT INTO audit VALUES ('a4', 'will be committed');
COMMIT;
SELECT COUNT(*) as total FROM audit;`,
};
function loadPreset(name) {
@@ -564,7 +686,7 @@ document.addEventListener('keydown', e => {
// Boot
initDB().then(() => {
console.log('✅ MetonaSqlark v0.2.5 demo ready');
console.log('✅ MetonaSqlark v0.3.2 demo ready');
setTimeout(runQuery, 300);
}).catch(err => {
renderError('初始化失败: ' + err.message);
+58 -6
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>📖 API 文档 — MetonaSqlark v0.2.5</title>
<title>📖 API 文档 — MetonaSqlark v0.3.2</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='%236366f1'/><text x='16' y='22' text-anchor='middle' font-size='20' fill='white'>◈</text></svg>">
<style>
:root {
@@ -69,6 +69,7 @@
<a href="index.html">首页</a>
<a href="docs.html" class="nav-active">文档</a>
<a href="demo.html">演示</a>
<a href="benchmark.html">基准</a>
</nav>
</div>
</header>
@@ -121,7 +122,7 @@ npm install @metona-team/metona-sqlark</pre>
<h3>CDN / UMD</h3>
<pre><span class="c">&lt;!-- UMD 格式,暴露 window.MetonaSqlark 和 window.MeSqlark --&gt;</span>
&lt;script src=<span class="s">"https://git.metona.cn/.../metona-sqlark.min.js"</span>&gt;&lt;/script&gt;
&lt;script src=<span class="s">"https://git.metona.cn/MetonaTeam/MetonaSqlark/raw/branch/master/dist/metona-sqlark.min.js"</span>&gt;&lt;/script&gt;
&lt;script&gt;
<span class="k">const</span> db = <span class="k">await</span> window.<span class="f">MetonaSqlark</span>.create({...});
<span class="c">// 或 window.MeSqlark.create(...) — 完全等价</span>
@@ -138,9 +139,9 @@ npm install @metona-team/metona-sqlark</pre>
<table>
<tr><th>文件</th><th>格式</th><th>用途</th></tr>
<tr><td><code>metona-sqlark.js</code></td><td>UMD</td><td>浏览器开发版(含 sourcemap</td></tr>
<tr><td><code>metona-sqlark.min.js</code></td><td>UMD (minified)</td><td>生产环境(~42KB / ~10KB gzip</td></tr>
<tr><td><code>metona-sqlark.min.js</code></td><td>UMD (minified)</td><td>生产环境(~105KB / ~27KB gzip</td></tr>
<tr><td><code>metona-sqlark.esm.js</code></td><td>ES Module</td><td>现代打包工具 / 浏览器 ESM</td></tr>
<tr><td><code>metona-sqlark.cjs.js</code></td><td>CommonJS</td><td>Node.js require()</td></tr>
<tr><td><code>metona-sqlark.cjs</code></td><td>CommonJS</td><td>Node.js require()</td></tr>
<tr><td><code>metona-sqlark.d.ts</code></td><td>TypeScript 声明</td><td>类型提示</td></tr>
</table>
@@ -237,6 +238,37 @@ db.<span class="f">isReady</span>(); <span class="c">// true</span>
<span class="c">-- TRUNCATE TABLE — 快速清空表数据 (v0.2.5)</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'TRUNCATE TABLE old_logs'</span>);</pre>
<h3>SQL 扩展 (v0.3.0+)</h3>
<pre><span class="c">// 多语句 — 分号分隔一次执行(返回最后一条结果)</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">`CREATE TABLE t (id STRING PRIMARY KEY);
INSERT INTO t VALUES ('1'); INSERT INTO t VALUES ('2')`</span>);
<span class="c">// 事务语句 — BEGIN / COMMIT / ROLLBACK</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'BEGIN'</span>);
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"INSERT INTO t VALUES ('3')"</span>);
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'ROLLBACK'</span>); <span class="c">// 回滚</span>
<span class="c">// INSERT INTO ... SELECT — 查询结果写入</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'INSERT INTO t SELECT id FROM t2 WHERE x > 1'</span>);
<span class="c">// UNION / UNION ALL — 合并查询</span>
<span class="k">const</span> merged = <span class="k">await</span> db.<span class="f">query</span>(
<span class="s">`SELECT name FROM users WHERE city = 'Beijing'
UNION SELECT name FROM users WHERE age < 30`</span>);
<span class="c">// EXISTS / NOT EXISTS — 关联子查询</span>
<span class="k">const</span> hasOrders = <span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT * FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)`</span>);
<span class="c">// CASE WHEN — SELECT 列 / WHERE / 聚合 (v0.3.1 / v0.3.2)</span>
<span class="k">const</span> labeled = <span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT name,
CASE WHEN age >= 18 THEN 'adult' ELSE 'minor' END AS status FROM users`</span>);
<span class="k">const</span> adults = <span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT SUM(CASE WHEN age >= 18 THEN 1 ELSE 0 END) FROM users`</span>);
<span class="c">// CREATE / DROP INDEX — 动态二级索引</span>
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'CREATE INDEX idx_users_city ON users (city)'</span>);
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'DROP INDEX idx_users_city ON users (city)'</span>);</pre>
<h3>条件表达式</h3>
<pre><span class="c">// 比较运算符</span>
<span class="s">`WHERE age > 18 AND name LIKE 'A%'`</span>
@@ -578,6 +610,25 @@ db.<span class="f">emit</span>(<span class="s">'users'</span>, { <span class="s"
<span class="c">// 取消订阅</span>
<span class="f">unsubscribe</span>();</pre>
<h3>多标签页同步 (v0.3.2)</h3>
<pre><span class="c">// 启用 multiTabSync 后,其他标签页的写操作会广播到此标签页</span>
<span class="k">const</span> db = <span class="k">await</span> MetonaSqlark.<span class="f">create</span>({
<span class="s">name</span>: <span class="s">'my-app'</span>,
<span class="s">mode</span>: <span class="s">'hybrid'</span>,
<span class="s">multiTabSync</span>: <span class="k">true</span>,
});
<span class="c">// 订阅其他标签页的变更(event.type === 'external'</span>
db.<span class="f">subscribe</span>(<span class="s">'users'</span>, (event) => {
<span class="k">if</span> (event.<span class="s">type</span> === <span class="s">'external'</span>) {
<span class="c">// Hybrid 模式已自动从磁盘重载,此处可刷新 UI</span>
<span class="f">refreshList</span>();
}
});
<span class="c">// 手动广播(Table API 已自动广播;自定义写入可调用)</span>
db.<span class="f">broadcastChange</span>(<span class="s">'users'</span>);</pre>
<h2 id="react">⚛️ React 集成</h2>
<pre><span class="k">import</span> { useQuery, useTable, useDatabase } <span class="k">from</span> <span class="s">'@metona-team/metona-sqlark/react'</span>;
<span class="k">import</span> { db } <span class="k">from</span> <span class="s">'./db'</span>;
@@ -663,6 +714,7 @@ db.<span class="f">emit</span>(<span class="s">'users'</span>, { <span class="s"
<tr><td><code>onReady</code></td><td><code>(db) => void</code></td><td>-</td><td>初始化完成回调</td></tr>
<tr><td><code>onError</code></td><td><code>(err) => void</code></td><td>-</td><td>错误回调(v0.2.5 接入执行路径)</td></tr>
<tr><td><code>maxRowsPerQuery</code></td><td><code>number</code></td><td><code>0</code></td><td>查询结果行数上限(0=不限制)✅ v0.2.5 生效</td></tr>
<tr><td><code>multiTabSync</code></td><td><code>boolean</code></td><td><code>false</code></td><td>多标签页同步:BroadcastChannel 广播表变更,其他标签页自动刷新 🆕 v0.3.2</td></tr>
</table>
<h2 id="engine">💾 存储引擎</h2>
@@ -678,8 +730,8 @@ db.<span class="f">emit</span>(<span class="s">'users'</span>, { <span class="s"
<h2 id="aria-engine">🌲 AriaEngine 自研存储引擎</h2>
<p><strong>v0.2.0 新增</strong> — AriaEngine 是专为 MetonaSqlark 设计的页面式存储引擎,对标 SQLite 设计理念。<br>
<strong>v0.2.4 生产级</strong> — 二级索引 · MVCC · BloomFilter · WAL CRC全同步 · AES-GCM加密 · Savepoint · EXPLAIN · ANALYZE · REINDEX · VACUUM · BufferPool · 701测试 · 零死代码。<br>
<strong>v0.2.5 质量加固</strong> — WAL full模式真正同步 · MVCC接入读写路径 · SSTableReader二分查找统一 · crypto实例化 · IndexedDB索引利用 · compactLevel public接口 · WAL大小阈值自动checkpoint · SQL注入防护 · ALTER TABLE · TRUNCATE TABLE · 721测试 37套件。</p>
<strong>v0.2.4 生产级</strong> — 二级索引 · MVCC · BloomFilter · WAL CRC全同步 · AES-GCM加密 · Savepoint · EXPLAIN · ANALYZE · REINDEX · VACUUM · BufferPool · 761测试 · 零死代码。<br>
<strong>v0.3.2 表达式与并发</strong> — WAL full模式真正同步 · MVCC接入读写路径 · SSTableReader二分查找统一 · crypto实例化 · IndexedDB索引利用 · compactLevel public接口 · WAL大小阈值自动checkpoint · SQL注入防护 · ALTER TABLE · TRUNCATE TABLE · 761测试 41套件。</p>
<h3>存储模式对比</h3>
<table>
+7 -6
View File
@@ -143,6 +143,7 @@
<a href="index.html">首页</a>
<a href="docs.html">文档</a>
<a href="demo.html">演示</a>
<a href="benchmark.html">基准</a>
<a href="https://git.metona.cn/MetonaTeam/MetonaSqlark" target="_blank">Gitea</a>
</nav>
<a href="demo.html" class="btn btn-primary">立即体验</a>
@@ -152,7 +153,7 @@
<!-- Hero -->
<section class="hero">
<div class="container">
<div class="badge" style="margin-bottom:24px;"><span class="dot"></span> v0.2.5 质量加固 — 721测试 37套件 · 五模式全覆盖 · WAL同步修复 · ALTER/TRUNCATE · SQL注入防护 · 零回归</div>
<div class="badge" style="margin-bottom:24px;"><span class="dot"></span> v0.3.2 表达式与并发 — 761测试 41套件 · 五模式全覆盖 · WAL同步修复 · ALTER/TRUNCATE · SQL注入防护 · 零回归</div>
<h1>前端的 <span class="gradient-text">SQL 数据库</span></h1>
<p>TypeScript 原生构建,5 种存储引擎,支持完整 SQL 查询。<br>零运行时依赖,开箱即用。AriaEngine 自研引擎:LSM-Tree + WAL 同步 + MVCC。</p>
<div class="actions">
@@ -242,7 +243,7 @@ npm install @metona-team/metona-sqlark
</div>
<div class="feature-card">
<div class="icon">🌲</div>
<h3>AriaEngine <span style="font-size:0.65rem;color:var(--accent);vertical-align:super;">v0.2.5</span></h3>
<h3>AriaEngine <span style="font-size:0.65rem;color:var(--accent);vertical-align:super;">v0.3.2</span></h3>
<p>自研 LSM-Tree 页面式存储引擎。MemTable 红黑树 + 多级 SSTable、Bloom Filter 快速判存、WAL full模式真正同步、MVCC 版本链接入读写路径。</p>
</div>
<div class="feature-card">
@@ -273,7 +274,7 @@ npm install @metona-team/metona-sqlark
<div class="feature-card">
<div class="icon">📦</div>
<h3>零运行时依赖</h3>
<p>纯 TypeScript 实现,不依赖任何第三方库。Tree-shakableUMD/ESM/CJS 多格式输出,~10KB gzip。</p>
<p>纯 TypeScript 实现,不依赖任何第三方库。Tree-shakableUMD/ESM/CJS 多格式输出,~27KB gzip。</p>
</div>
<div class="feature-card">
<div class="icon">📊</div>
@@ -393,9 +394,9 @@ npm install @metona-team/metona-sqlark
<p>MetonaSqlark 的核心指标</p>
</div>
<div class="stats">
<div class="stat-card"><div class="num">721</div><div class="label">测试用例</div></div>
<div class="stat-card"><div class="num">91.0%</div><div class="label">行覆盖率</div></div>
<div class="stat-card"><div class="num">~10KB</div><div class="label">gzip 体积</div></div>
<div class="stat-card"><div class="num">761</div><div class="label">测试用例</div></div>
<div class="stat-card"><div class="num">81.0%</div><div class="label">行覆盖率</div></div>
<div class="stat-card"><div class="num">~27KB</div><div class="label">gzip 体积</div></div>
<div class="stat-card"><div class="num">5</div><div class="label">存储引擎</div></div>
<div class="stat-card"><div class="num">36</div><div class="label">SQL 关键字</div></div>
<div class="stat-card"><div class="num">37</div><div class="label">测试套件</div></div>
+4 -1
View File
@@ -93,6 +93,8 @@ export interface DatabaseConfig {
maxRowsPerQuery?: number;
/** 调试模式(启用后输出详细操作日志) */
debug?: boolean;
/** 多标签页同步(v0.3.2):BroadcastChannel 广播表变更,其他标签页自动刷新 */
multiTabSync?: boolean;
}
/** 数据库默认配置 */
@@ -103,6 +105,7 @@ export const DB_DEFAULTS: Readonly<Required<Omit<DatabaseConfig, 'plugins' | 'on
version: 1,
maxRowsPerQuery: 0, // 0 = 不限制
debug: false,
multiTabSync: false,
});
// ---------------------------------------------------------------------------
@@ -209,4 +212,4 @@ export class DatabaseError extends Error {
// 版本
// ---------------------------------------------------------------------------
export const VERSION = '0.2.5';
export const VERSION = '0.3.2';
+67 -5
View File
@@ -16,7 +16,7 @@ import { HybridEngine } from './hybrid/index';
import { Table } from './table/table';
import { createSchema } from './table/schema';
import { QueryExecutor } from './query/executor';
import { parse } from './sql/parser';
import { parseAll } from './sql/parser';
import { TransactionManager } from './transaction/index';
import { PluginManager } from './plugin/index';
import type { Statement } from './query/ast';
@@ -53,12 +53,31 @@ export class MetonaSqlark {
/** 调试模式 */
get debug(): boolean { return this.config.debug ?? false; }
/** 多标签页同步通道(v0.3.2 */
private channel: BroadcastChannel | null = null;
constructor(config: DatabaseConfig) {
this.config = config;
this.name = config.name ?? DB_DEFAULTS.name;
this.mode = config.mode ?? DB_DEFAULTS.mode;
this._version = config.version ?? DB_DEFAULTS.version;
this.pluginManager = new PluginManager();
// v0.3.2: 多标签页同步 — BroadcastChannel 广播表变更
if (config.multiTabSync && typeof BroadcastChannel !== 'undefined') {
this.channel = new BroadcastChannel(`metona-sqlark:${this.name}`);
this.channel.onmessage = (event) => {
const msg = event.data as { type?: string; table?: string } | null;
if (!msg || msg.type !== 'change') return;
this.emit(msg.table ?? '', { type: 'external', table: msg.table ?? '' });
// Hybrid 引擎:从磁盘重载内存,保证读到其他标签页的最新数据
if (this.engine instanceof HybridEngine) {
(this.engine as HybridEngine).reloadMemoryFromDisk().catch(() => {
// 重载失败不影响主流程(下次读可能短暂过期)
});
}
};
}
}
// ---- 初始化 ----
@@ -121,7 +140,8 @@ export class MetonaSqlark {
let t = this.tableCache.get(name);
if (!t) {
t = new Table(this.engine, name, this.executor);
// v0.3.2: 表操作写入后广播变更(多标签页同步)
t = new Table(this.engine, name, this.executor, (tableName) => this.broadcastChange(tableName));
this.tableCache.set(name, t);
}
return t;
@@ -158,8 +178,14 @@ export class MetonaSqlark {
let result: unknown;
try {
const stmt: Statement = parse(sql);
// v0.3.0: 支持分号分隔的多语句,逐条顺序执行,返回最后一条的结果
const statements: Statement[] = parseAll(sql);
for (const stmt of statements) {
result = await this.executor.execute(stmt);
// v0.3.2: 写语句广播表变更(多标签页同步)
const table = this.writeStatementTable(stmt);
if (table) this.broadcastChange(table);
}
} catch (error) {
this._onError(error as Error);
throw error;
@@ -227,7 +253,7 @@ export class MetonaSqlark {
private listeners: Map<string, Set<(data: unknown) => void>> = new Map();
/** 订阅表变更 */
subscribe(tableName: string, callback: (event: { type: string; row?: unknown }) => void): () => void {
subscribe(tableName: string, callback: (event: { type: string; row?: unknown; table?: string }) => void): () => void {
const key = `change:${tableName}`;
if (!this.listeners.has(key)) this.listeners.set(key, new Set());
this.listeners.get(key)!.add(callback as (data: unknown) => void);
@@ -235,11 +261,42 @@ export class MetonaSqlark {
}
/** 触发变更事件 */
emit(tableName: string, event: { type: string; row?: unknown }): void {
emit(tableName: string, event: { type: string; row?: unknown; table?: string }): void {
const key = `change:${tableName}`;
this.listeners.get(key)?.forEach((cb) => cb(event));
}
// ---- 多标签页同步(v0.3.2 ----
/** 广播表变更到其他标签页(多标签页同步) */
broadcastChange(tableName: string): void {
if (!this.channel) return;
try {
this.channel.postMessage({ type: 'change', table: tableName });
} catch {
// 广播失败不影响主流程
}
}
/** 写语句对应的表名(多标签页广播用) */
private writeStatementTable(stmt: Statement): string | null {
switch (stmt.type) {
case 'INSERT': return stmt.into;
case 'UPDATE': return stmt.table;
case 'DELETE': return stmt.from;
case 'CREATE_TABLE':
case 'DROP_TABLE':
case 'TRUNCATE_TABLE':
return stmt.name;
case 'ALTER_TABLE': return stmt.name;
case 'CREATE_INDEX':
case 'DROP_INDEX':
return stmt.table;
default:
return null;
}
}
// ---- 迁移 ----
private migrations: Map<number, (db: MetonaSqlark) => Promise<void>> = new Map();
@@ -276,6 +333,10 @@ export class MetonaSqlark {
/** 关闭数据库 */
async close(): Promise<void> {
if (this.channel) {
this.channel.close();
this.channel = null;
}
this.pluginManager.destroy();
await this.engine.close();
this.tableCache.clear();
@@ -323,6 +384,7 @@ export class MetonaSqlark {
/** 调试日志 */
private _debug(msg: string, ...args: unknown[]): void {
if (this.debug) {
// eslint-disable-next-line no-console
console.debug(`[MetonaSqlark:${this.name}] ${msg}`, ...args);
}
}
+34 -14
View File
@@ -2,39 +2,48 @@
* AriaEngine LZ4 Compression LZ4 /
* @module engine/aria/compression/lz4
*
* v0.2.6: 修复往返一致性
* - 19 matchField 15 + MIN_MATCH
* - token matchField [1,15]matchField=0 lo=0 offset
* - "matchField=0 的组合 token 与纯字面量 token 歧义"
*
* Token 1 :
* hi 4bit = litLen (0-15)
* lo 4bit = matchField (0-15, = field+4)
* lo 4bit = matchField (1-15, = field+4)
*
* -: [token] [litLen bytes] [2B LE offset]
* : [token with lo=0] [litLen bytes]
*/
const MIN_MATCH = 4;
const MAX_MATCH = MIN_MATCH + 15; // 19,匹配长度上限
export function compressLZ4(input: Uint8Array): Uint8Array {
if (input.byteLength < MIN_MATCH) return input;
// 空输入直接返回(无 token 可输出)
if (input.byteLength === 0) return input;
const maxOut = input.byteLength + (input.byteLength >> 8) + 32;
// 最坏情况:纯字面量分块输出 len/15 个 token + 末尾 token
// 上限:len + ceil(len/15) + 8(组合 token 的 offset 开销已包含在内)
const maxOut = input.byteLength + Math.ceil(input.byteLength / 15) + 8;
const out = new Uint8Array(maxOut);
let si = 0, di = 0;
let litStart = 0;
while (si < input.byteLength) {
// 搜索最长 backward match
// 搜索最长 backward match(截断到 MAX_MATCH,避免 token 字段溢出)
let bestLen = 0, bestOff = 0;
const searchStart = Math.max(0, si - 65535);
for (let p = searchStart; p < si; p++) {
let ml = 0;
while (si + ml < input.byteLength && p + ml < si &&
input[p + ml] === input[si + ml] && ml < 255) ml++;
input[p + ml] === input[si + ml] && ml < MAX_MATCH) ml++;
if (ml >= MIN_MATCH && ml > bestLen) { bestLen = ml; bestOff = si - p; }
}
if (bestLen >= MIN_MATCH && (si - litStart) <= 15) {
// 有匹配 → 输出组合 token(字面量+匹配)
// 仅当匹配完整可编码(field 1-15)且字面量不超过 15 时才输出组合 token
if (bestLen > MIN_MATCH && (si - litStart) <= 15) {
const litLen = si - litStart;
const matchField = Math.min(bestLen - MIN_MATCH, 15);
const matchField = bestLen - MIN_MATCH; // 1..15
out[di++] = ((litLen & 0x0F) << 4) | (matchField & 0x0F);
for (let j = 0; j < litLen; j++) out[di++] = input[litStart + j];
out[di++] = bestOff & 0xFF;
@@ -42,8 +51,15 @@ export function compressLZ4(input: Uint8Array): Uint8Array {
si += bestLen;
litStart = si;
} else {
// 无匹配或字面量已满 15 → 继续累积(不单独输出,等下个匹配合并)
// 无匹配 / 匹配长度 4(field=0 有歧义)→ 继续累积字面量
si++;
// 字面量达到 15 字节上限:结清为纯字面量 token(lo=0),
// 否则后续组合 token 的字面量长度会超过 token 字段上限
if (si - litStart >= 15) {
out[di++] = (15 & 0x0F) << 4; // lo=0 无匹配
for (let j = 0; j < 15; j++) out[di++] = input[litStart + j];
litStart = si;
}
}
}
@@ -57,7 +73,10 @@ export function compressLZ4(input: Uint8Array): Uint8Array {
litStart += chunk;
}
return di >= input.byteLength ? input : out.slice(0, di);
// 始终输出压缩流(即使比原数据略大)。
// 注意:不能返回原样 input —— 解压端无法区分"压缩流"与"原始数据"
// 原样返回会导致解压器将原始字节误解析为 token(v0.2.6 修复)
return out.slice(0, di);
}
export function decompressLZ4(input: Uint8Array, originalSize: number): Uint8Array {
@@ -74,10 +93,12 @@ export function decompressLZ4(input: Uint8Array, originalSize: number): Uint8Arr
out[di++] = input[si++];
}
if (di >= originalSize || si >= input.byteLength) break;
// matchField=0:纯字面量 token(无 offset 无匹配)。
// 可能出现在流中任意位置(超长字面量分块输出),不能 break
if (matchField === 0) continue;
// 非末尾 → 必有 offset + 匹配(即使 matchField==0 也复制 MIN_MATCH 字节
if (si + 1 < input.byteLength) {
// 组合 token:读取 offset + 复制匹配(可能自重叠
if (si + 1 >= input.byteLength) break;
const offset = input[si++] | (input[si++] << 8);
const matchLen = matchField + MIN_MATCH;
for (let i = 0; i < matchLen && di < originalSize; i++) {
@@ -85,7 +106,6 @@ export function decompressLZ4(input: Uint8Array, originalSize: number): Uint8Arr
di++;
}
}
}
return out;
}
+173 -58
View File
@@ -23,7 +23,6 @@ import { IndexedDBBackend, MemoryBackend, type IStorageBackend } from './store/b
import { OPFSBackend } from './store/opfs_backend';
import { FileManager } from './store/file_manager';
import { MVCCManager } from './transaction/mvcc';
import { BloomFilter } from './index/bloom';
import { BufferPool } from './buffer/pool';
import { compressLZ4, decompressLZ4 } from './compression/lz4';
import { isCryptoEnabled, encryptPage, decryptPage } from './crypto';
@@ -89,7 +88,7 @@ export class AriaEngine implements IStorageEngine {
this.bufferPool = new BufferPool(fileManager, this.config.bufferPoolPages);
// 2. 构建 SSTableStore
const sstableStore = this.createSSTableStore();
const sstableStore = this.createSSTableStore('main');
// 3. 初始化主 LSMPK 索引)
this.lsm = new LSM({
@@ -97,6 +96,8 @@ export class AriaEngine implements IStorageEngine {
levelSizeMultiplier: this.config.levelSizeMultiplier,
blockSize: this.config.pageSize,
bloomBitsPerKey: this.config.bloomFilterBitsPerKey,
// SSTable 缓存上限 = BufferPool 页数 × 页面大小(默认 256 页 ≈ 1MB 可控内存)
cacheLimitBytes: this.config.bufferPoolPages * this.config.pageSize,
sstableStore,
});
@@ -201,8 +202,7 @@ export class AriaEngine implements IStorageEngine {
this.schemas.set(schema.name, schema);
this.tablePKs.set(schema.name, this.getPK(schema));
// 为索引列创建二级索引 LSM
const sstableStore = this.createSSTableStore();
// 为索引列创建二级索引 LSM(每个索引使用独立命名空间的 SSTableStore,避免 id/meta 冲突)
for (const [colName, colDef] of Object.entries(schema.columns)) {
if (colDef.index || colDef.unique || colDef.primaryKey) {
const idxKey = `${schema.name}:idx:${colName}`;
@@ -212,7 +212,8 @@ export class AriaEngine implements IStorageEngine {
levelSizeMultiplier: this.config.levelSizeMultiplier,
blockSize: this.config.pageSize,
bloomBitsPerKey: this.config.bloomFilterBitsPerKey,
sstableStore,
cacheLimitBytes: this.config.bufferPoolPages * this.config.pageSize,
sstableStore: this.createSSTableStore(`idx_${schema.name}_${colName}`),
});
await idxLsm.init();
this.secondaryIndexes.set(idxKey, idxLsm);
@@ -236,7 +237,7 @@ export class AriaEngine implements IStorageEngine {
this.ensureTable(tableName);
// 删除表中所有行
const rows = this.getAllRows(tableName);
const rows = await this.getAllRows(tableName);
for (const row of rows) {
const pkCol = this.tablePKs.get(tableName)!;
this.lsm.delete(`${tableName}:${row[pkCol]}`);
@@ -277,6 +278,8 @@ export class AriaEngine implements IStorageEngine {
const schema = this.schemas.get(tableName)!;
const pkCol = this.tablePKs.get(tableName)!;
const pks: string[] = [];
// v0.3.1: 批量 WAL 写入(组提交),一次 insert 合并为一次落盘
const walRecords: Omit<WALRecord, 'lsn' | 'checksum'>[] = [];
for (const row of rows) {
const validated = this.validateRow(schema, row);
@@ -284,6 +287,7 @@ export class AriaEngine implements IStorageEngine {
const key = `${tableName}:${pkValue}`;
// Check duplicate in LSM + transaction snapshot
await this.lsm.prefetchKeys([key]);
const existing = this.currentTxnId
? (this.txnSnapshot?.get(key) ?? this.lsm.get(key))
: this.lsm.get(key);
@@ -308,7 +312,7 @@ export class AriaEngine implements IStorageEngine {
pks.push(pkValue);
await this.wal.append({
walRecords.push({
type: WALRecordType.INSERT,
txnId: this.currentTxnId ?? 0,
tableName,
@@ -317,6 +321,8 @@ export class AriaEngine implements IStorageEngine {
});
}
await this.wal.appendBatch(walRecords);
this.opCounter += rows.length;
this.checkMemoryBudget();
await this.checkpointManager.tick();
@@ -331,11 +337,11 @@ export class AriaEngine implements IStorageEngine {
let rows: Record<string, unknown>[];
// Try index lookup
const fastPath = this.tryIndexLookup(tableName, query);
const fastPath = await this.tryIndexLookup(tableName, query);
if (fastPath !== null) {
rows = fastPath;
} else {
rows = this.getAllRows(tableName);
rows = await this.getAllRows(tableName);
}
// Merge transaction snapshot writes (uncommitted data visible within txn)
@@ -377,6 +383,9 @@ export class AriaEngine implements IStorageEngine {
rows = rows.map((row) => projectColumns(row, query.columns!));
}
// 查询完成,回收查询期间的临时缓存超限
this.trimAllCaches();
return rows;
}
@@ -387,10 +396,11 @@ export class AriaEngine implements IStorageEngine {
): Promise<number> {
this.ensureOpen();
this.ensureTable(tableName);
const schema = this.schemas.get(tableName)!;
const rows = this.getAllRows(tableName);
const rows = await this.getAllRows(tableName);
let count = 0;
// v0.3.1: 批量 WAL 写入(组提交)
const walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[] = [];
for (const row of rows) {
const pkCol = this.tablePKs.get(tableName)!;
@@ -408,7 +418,7 @@ export class AriaEngine implements IStorageEngine {
}
count++;
await this.wal.append({
walRecords.push({
type: WALRecordType.UPDATE,
txnId: this.currentTxnId ?? 0,
tableName,
@@ -421,8 +431,11 @@ export class AriaEngine implements IStorageEngine {
}
}
await this.wal.appendBatch(walRecords);
this.opCounter += count;
await this.checkpointManager.tick();
this.trimAllCaches();
return count;
}
@@ -430,8 +443,10 @@ export class AriaEngine implements IStorageEngine {
this.ensureOpen();
this.ensureTable(tableName);
const rows = this.getAllRows(tableName);
const rows = await this.getAllRows(tableName);
let count = 0;
// v0.3.1: 批量 WAL 写入(组提交)
const walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[] = [];
for (const row of rows) {
const pkCol = this.tablePKs.get(tableName)!;
@@ -447,7 +462,7 @@ export class AriaEngine implements IStorageEngine {
}
count++;
await this.wal.append({
walRecords.push({
type: WALRecordType.DELETE,
txnId: this.currentTxnId ?? 0,
tableName,
@@ -459,15 +474,19 @@ export class AriaEngine implements IStorageEngine {
}
}
await this.wal.appendBatch(walRecords);
this.opCounter += count;
await this.checkpointManager.tick();
this.trimAllCaches();
return count;
}
async count(tableName: string, query?: QueryPlan): Promise<number> {
this.ensureOpen();
this.ensureTable(tableName);
const rows = this.getAllRows(tableName);
const rows = await this.getAllRows(tableName);
this.trimAllCaches();
if (!query?.where || Object.keys(query.where).length === 0) return rows.length;
return rows.filter((row) => matchWhere(row, query.where!)).length;
}
@@ -475,13 +494,75 @@ export class AriaEngine implements IStorageEngine {
async clear(tableName: string): Promise<void> {
this.ensureOpen();
this.ensureTable(tableName);
const rows = this.getAllRows(tableName);
const rows = await this.getAllRows(tableName);
for (const row of rows) {
const pkCol = this.tablePKs.get(tableName)!;
this.lsm.delete(`${tableName}:${row[pkCol]}`);
}
}
// ---- 动态索引(v0.3.0 ----
async createIndex(tableName: string, column: string, unique?: boolean): Promise<void> {
this.ensureOpen();
this.ensureTable(tableName);
const schema = this.schemas.get(tableName)!;
const colDef = schema.columns[column];
if (!colDef) throw new DatabaseError(`Column "${column}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND');
if (colDef.index || colDef.unique) return; // 已存在
colDef.index = true;
if (unique) colDef.unique = true;
const idxKey = `${tableName}:idx:${column}`;
if (!this.secondaryIndexes.has(idxKey)) {
const idxLsm = new LSM({
memtableSizeThreshold: this.config.memtableSizeThreshold,
levelSizeMultiplier: this.config.levelSizeMultiplier,
blockSize: this.config.pageSize,
bloomBitsPerKey: this.config.bloomFilterBitsPerKey,
cacheLimitBytes: this.config.bufferPoolPages * this.config.pageSize,
sstableStore: this.createSSTableStore(`idx_${tableName}_${column}`),
});
await idxLsm.init();
this.secondaryIndexes.set(idxKey, idxLsm);
}
// 从主 LSM 重建索引数据
const idxLsm = this.secondaryIndexes.get(idxKey)!;
const pkCol = this.tablePKs.get(tableName)!;
const rows = await this.getAllRows(tableName);
for (const row of rows) {
const value = row[column];
if (value !== undefined && value !== null) {
idxLsm.put(`${String(value)}:${row[pkCol]}`, { pk: row[pkCol] });
}
}
await idxLsm.flush();
await this.persistSchemas();
}
async dropIndex(tableName: string, column: string, _indexName?: string): Promise<void> {
this.ensureOpen();
this.ensureTable(tableName);
const schema = this.schemas.get(tableName)!;
const colDef = schema.columns[column];
if (!colDef) throw new DatabaseError(`Column "${column}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND');
// 主键索引不可删除(PK 查找依赖主 LSM)
if (colDef.primaryKey) {
throw new DatabaseError(`Cannot drop primary key index on column "${column}"`, 'NOT_SUPPORTED');
}
colDef.index = false;
colDef.unique = false;
const idxKey = `${tableName}:idx:${column}`;
const idxLsm = this.secondaryIndexes.get(idxKey);
if (idxLsm) {
await idxLsm.clear();
this.secondaryIndexes.delete(idxKey);
}
await this.persistSchemas();
}
// =======================================================================
// 事务
// =======================================================================
@@ -580,7 +661,7 @@ export class AriaEngine implements IStorageEngine {
this.ensureOpen();
const result: Record<string, Record<string, unknown>[]> = {};
for (const tableName of this.schemas.keys()) {
result[tableName] = this.getAllRows(tableName);
result[tableName] = await this.getAllRows(tableName);
}
return result;
}
@@ -589,9 +670,11 @@ export class AriaEngine implements IStorageEngine {
// 内部
// =======================================================================
private getAllRows(tableName: string): Record<string, unknown>[] {
private async getAllRows(tableName: string): Promise<Record<string, unknown>[]> {
const pkCol = this.tablePKs.get(tableName)!;
const prefix = `${tableName}:`;
// 预加载范围内涉及的 SSTable,避免 rangeScan 时缓存未命中静默丢数据
await this.lsm.prefetchRange(prefix, `${prefix}\uffff`);
const entries = this.lsm.rangeScan(prefix, `${prefix}\uffff`);
return entries.map(([key, value]) => {
const row = { ...value };
@@ -663,8 +746,33 @@ export class AriaEngine implements IStorageEngine {
// SSTableStore 构建
// =======================================================================
private createSSTableStore(): SSTableStore {
const META_KEY = '__aria_lsm_meta';
/**
* SSTableStore
*
* LSM LSM
* - key sst_ / sst_idx_${table}_${col}_
* - key __aria_lsm_meta / __aria_lsm_meta_${ns}
* - id v0.2.4 id
*/
private createSSTableStore(ns: string): SSTableStore {
const filePrefix = ns === 'main' ? 'sst_' : `sst_${ns}_`;
const META_KEY = ns === 'main' ? '__aria_lsm_meta' : `__aria_lsm_meta_${ns}`;
let seq = 0;
let seqLoaded = false;
const encodeText = (text: string): ArrayBuffer => {
return new TextEncoder().encode(text).buffer;
};
const readMetaList = async (): Promise<SSTableMeta[]> => {
const raw = await this.backend.read(META_KEY);
if (!raw) return [];
try {
return JSON.parse(new TextDecoder().decode(raw)) as SSTableMeta[];
} catch {
return [];
}
};
return {
save: async (id, data) => {
@@ -685,10 +793,10 @@ export class AriaEngine implements IStorageEngine {
combined.set(new Uint8Array(enc.data), header.length);
buf = combined.buffer;
}
await this.backend.write(`sst_${id}`, buf);
await this.backend.write(`${filePrefix}${id}`, buf);
},
load: async (id) => {
const raw = await this.backend.read(`sst_${id}`);
const raw = await this.backend.read(`${filePrefix}${id}`);
if (!raw) return null;
let buf = new Uint8Array(raw);
// 解密(若数据带加密头)
@@ -707,39 +815,30 @@ export class AriaEngine implements IStorageEngine {
return buf;
},
delete: async (id) => {
await this.backend.delete(`sst_${id}`);
await this.backend.delete(`${filePrefix}${id}`);
},
allocateId: async () => Date.now(),
listMeta: async () => {
const raw = await this.backend.read(META_KEY);
if (!raw) return [];
try {
return JSON.parse(new TextDecoder().decode(raw)) as SSTableMeta[];
} catch {
return [];
allocateId: async () => {
// 从本命名空间的 meta 恢复 id 序列,保证单调递增且不与其他 LSM 冲突
if (!seqLoaded) {
const metas = await readMetaList();
seq = metas.reduce((m, x) => Math.max(m, x.id), 0);
seqLoaded = true;
}
return ++seq;
},
listMeta: readMetaList,
saveMeta: async (meta) => {
const existing = await this.backend.read(META_KEY);
const list: SSTableMeta[] = existing
? JSON.parse(new TextDecoder().decode(existing))
: [];
const list = await readMetaList();
// 更新或添加
const idx = list.findIndex((m) => m.id === meta.id);
if (idx >= 0) list[idx] = meta;
else list.push(meta);
const json = JSON.stringify(list);
const buf = new TextEncoder().encode(json).buffer;
await this.backend.write(META_KEY, buf);
await this.backend.write(META_KEY, encodeText(JSON.stringify(list)));
},
deleteMeta: async (id) => {
const existing = await this.backend.read(META_KEY);
if (!existing) return;
const list: SSTableMeta[] = JSON.parse(new TextDecoder().decode(existing));
const list = await readMetaList();
const filtered = list.filter((m) => m.id !== id);
const json = JSON.stringify(filtered);
const buf = new TextEncoder().encode(json).buffer;
await this.backend.write(META_KEY, buf);
await this.backend.write(META_KEY, encodeText(JSON.stringify(filtered)));
},
};
}
@@ -816,10 +915,10 @@ export class AriaEngine implements IStorageEngine {
}
/** 通过二级索引快速查找 */
private tryIndexLookup(
private async tryIndexLookup(
tableName: string,
query: QueryPlan,
): Record<string, unknown>[] | null {
): Promise<Record<string, unknown>[] | null> {
if (!query.where) return null;
const schema = this.schemas.get(tableName);
if (!schema) return null;
@@ -837,12 +936,14 @@ export class AriaEngine implements IStorageEngine {
if (col === pkCol) {
if (typeof condition !== 'object' || condition === null) {
const key = `${tableName}:${condition}`;
await this.lsm.prefetchKeys([key]);
const value = this.lsm.get(key);
return value ? [{ ...value, [pkCol]: condition }] : [];
}
const cond = condition as Record<string, unknown>;
if ('$eq' in cond) {
const key = `${tableName}:${cond.$eq}`;
await this.lsm.prefetchKeys([key]);
const value = this.lsm.get(key);
return value ? [{ ...value, [pkCol]: cond.$eq }] : [];
}
@@ -855,18 +956,18 @@ export class AriaEngine implements IStorageEngine {
// $eq → 精确查找
if (typeof condition !== 'object' || condition === null) {
return this.indexScanToRows(tableName, pkCol, idxLsm, col, String(condition), String(condition));
return this.indexScanToRows(tableName, pkCol, idxLsm, String(condition), String(condition));
}
const c = condition as Record<string, unknown>;
if ('$eq' in c) {
const v = String(c.$eq);
return this.indexScanToRows(tableName, pkCol, idxLsm, col, v, v);
return this.indexScanToRows(tableName, pkCol, idxLsm, v, v);
}
// $in → 多次精确查找
if ('$in' in c && Array.isArray(c.$in)) {
const results: Record<string, unknown>[] = [];
for (const val of c.$in) {
const rows = this.indexScanToRows(tableName, pkCol, idxLsm, col, String(val), String(val));
const rows = await this.indexScanToRows(tableName, pkCol, idxLsm, String(val), String(val));
results.push(...rows);
}
return results;
@@ -879,7 +980,7 @@ export class AriaEngine implements IStorageEngine {
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, col, startKey, endKey);
return this.indexScanToRows(tableName, pkCol, idxLsm, startKey, endKey);
}
}
@@ -887,17 +988,23 @@ export class AriaEngine implements IStorageEngine {
}
/** 从索引扫描结果恢复完整行 */
private indexScanToRows(
private async indexScanToRows(
tableName: string, pkCol: string, idxLsm: LSM,
_col: string, startKey: string, endKey: string,
): Record<string, unknown>[] {
startKey: string, endKey: string,
): Promise<Record<string, unknown>[]> {
// 使用前缀扫描:endKey 需要包含 \uffff 以匹配所有带后缀的 key
const actualEndKey = endKey.includes('\uffff') ? endKey : `${endKey}\uffff`;
// 预加载索引 LSM 与主 LSM 涉及的 SSTable
await idxLsm.prefetchRange(startKey, actualEndKey);
const entries = idxLsm.rangeScan(startKey, actualEndKey);
const rows: Record<string, unknown>[] = [];
const pks: string[] = [];
for (const [, idxEntry] of entries) {
const pk = (idxEntry as any).pk as string;
if (!pk) continue;
if (pk) pks.push(pk);
}
await this.lsm.prefetchKeys(pks.map((pk) => `${tableName}:${pk}`));
const rows: Record<string, unknown>[] = [];
for (const pk of pks) {
const row = this.lsm.get(`${tableName}:${pk}`);
if (row) rows.push({ ...row, [pkCol]: pk });
}
@@ -917,6 +1024,14 @@ export class AriaEngine implements IStorageEngine {
}
}
/** 回收主 LSM 与所有二级索引 LSM 的临时缓存超限 */
private trimAllCaches(): void {
this.lsm.trimCache();
for (const idxLsm of this.secondaryIndexes.values()) {
idxLsm.trimCache();
}
}
/** 检查内存预算,超出时强制 flush + GC */
private checkMemoryBudget(): void {
const maxBytes = this.config.maxMemoryMB * 1024 * 1024;
@@ -939,7 +1054,7 @@ export class AriaEngine implements IStorageEngine {
async analyzeTable(tableName: string): Promise<Record<string, unknown>> {
this.ensureOpen();
this.ensureTable(tableName);
const rows = this.getAllRows(tableName);
const rows = await this.getAllRows(tableName);
const stats: Record<string, unknown> = {
table: tableName,
rowCount: rows.length,
@@ -986,7 +1101,7 @@ export class AriaEngine implements IStorageEngine {
rebuiltCount++;
// 从主 LSM 重建索引
const rows = this.getAllRows(tableName);
const rows = await this.getAllRows(tableName);
for (const row of rows) {
const val = row[colName];
if (val !== undefined && val !== null) {
@@ -1007,7 +1122,7 @@ export class AriaEngine implements IStorageEngine {
// 压缩各层级
for (let level = 0; level < 6; level++) {
if (this.lsm.getStats().levelCounts[level] >= 2) {
this.lsm.compactLevel(level);
await this.lsm.compactLevel(level);
}
}
// GC MVCC 版本(保留最新 10 个)
+179 -66
View File
@@ -50,6 +50,8 @@ export interface LSMConfig {
blockSize?: number;
bloomBitsPerKey?: number;
sstableStore: SSTableStore;
/** SSTable 缓存容量上限(字节),超限按 LRU 驱逐 */
cacheLimitBytes?: number;
}
// ---------------------------------------------------------------------------
@@ -59,21 +61,27 @@ export interface LSMConfig {
export class LSM {
private memtable: MemTable;
private immutableMemtable: MemTable | null = null;
/** 所有 pending flush 的 frozen memtable(含 immutableMemtable,旧→新) */
private frozenMemtables: MemTable[] = [];
private levels: SSTableMeta[][] = [];
private sstableCache: Map<number, Uint8Array> = new Map();
private nextSSTableId = 1;
private cacheSize = 0;
private cacheLimitBytes: number;
private levelSizeMultiplier: number;
private blockSize: number;
private sstableStore: SSTableStore;
private operationCount = 0;
private initialized = false;
private compacting = false; // 防止重复触发 compaction
/** 串行化 flush/compaction 链:保证持久化顺序与 id 分配顺序一致 */
private flushChain: Promise<void> = Promise.resolve();
constructor(config: LSMConfig) {
this.memtable = new MemTable(config.memtableSizeThreshold ?? DEFAULT_MEMTABLE_SIZE);
this.levelSizeMultiplier = config.levelSizeMultiplier ?? DEFAULT_LEVEL_SIZE_MULTIPLIER;
this.blockSize = config.blockSize ?? 4096;
this.sstableStore = config.sstableStore;
this.cacheLimitBytes = config.cacheLimitBytes ?? 64 * 1024 * 1024;
for (let i = 0; i < MAX_LSM_LEVELS; i++) {
this.levels.push([]);
@@ -96,24 +104,16 @@ export class LSM {
}
}
// 各层级按 minKey 排序(方便后续范围查询剪枝)
// 各层级按 id 降序排列:id 越大越新,保证读取时"最新优先"
// flush/compaction 产物均插入数组头部,数组头部即最新)
for (let i = 0; i < MAX_LSM_LEVELS; i++) {
this.levels[i].sort((a, b) => (a.minKey < b.minKey ? -1 : a.minKey > b.minKey ? 1 : 0));
this.levels[i].sort((a, b) => b.id - a.id);
}
// 恢复 nextSSTableId
if (metas.length > 0) {
this.nextSSTableId = Math.max(...metas.map((m) => m.id)) + 1;
}
// 预加载所有 SSTable 数据到缓存(避免后续 cache miss 返回 null
for (const meta of metas) {
try {
await this.preloadSSTable(meta.id);
} catch {
// 单个文件加载失败不影响整体启动
}
}
// 注意:不在此处预加载全部 SSTable 数据。
// 缓存容量有上限(cacheLimitBytes),全部预加载会突破内存预算。
// 读取路径由 prefetchRange / prefetchKeys 在查询前异步加载兜底。
// id 序列由 sstableStore.allocateId() 按命名空间独立恢复。
this.initialized = true;
}
@@ -123,10 +123,9 @@ export class LSM {
// =======================================================================
put(key: string, value: Record<string, unknown>): void {
// 写背压:level 0 SSTable 过多时等待 compaction
// 写背压:level 0 SSTable 过多时排队 compaction 缓解压力
if (this.levels[0].length >= 8) {
// 同步执行一次 compaction 缓解压力
this.compactLevelSync(0);
this.enqueueCompact(0);
}
this.memtable.put(key, value);
this.operationCount++;
@@ -137,7 +136,7 @@ export class LSM {
delete(key: string): void {
if (this.levels[0].length >= 8) {
this.compactLevelSync(0);
this.enqueueCompact(0);
}
this.memtable.put(key, { __tombstone: true } as unknown as Record<string, unknown>);
this.operationCount++;
@@ -149,30 +148,35 @@ export class LSM {
/** 获取估算内存使用(字节) */
getEstimatedMemory(): number {
let mem = this.memtable.getEstimatedSize();
if (this.immutableMemtable) mem += this.immutableMemtable.getEstimatedSize();
for (const [, buf] of this.sstableCache) mem += buf.byteLength;
for (const frozen of this.frozenMemtables) mem += frozen.getEstimatedSize();
mem += this.cacheSize;
return mem;
}
/**
* MemTable immutable
* MemTable flush
* pending frozen frozenMemtables flush 访
*/
freezeMemtable(): void {
if (this.immutableMemtable) {
this.flushImmutableSync();
const frozen = this.immutableMemtable;
this.flushChain = this.flushChain.then(() => this.flushImmutableAsync(frozen));
}
this.immutableMemtable = this.memtable;
this.frozenMemtables.push(this.immutableMemtable);
this.memtable = new MemTable(this.memtable.getEstimatedSize());
}
/** 同步等待 Immutable MemTable 刷盘完成 */
flushImmutableSync(): void {
if (!this.immutableMemtable) return;
const entries = this.immutableMemtable.getAllEntries();
/** 将指定 Immutable MemTable 刷盘为 SSTableid 由 store 按命名空间分配) */
private async flushImmutableAsync(frozen: MemTable): Promise<void> {
const entries = frozen.getAllEntries();
if (entries.length === 0) {
this.immutableMemtable = null;
if (this.immutableMemtable === frozen) this.immutableMemtable = null;
return;
}
const id = this.nextSSTableId++;
const id = await this.sstableStore.allocateId();
const builder = new SSTableBuilder(this.blockSize);
for (const [key, value] of entries) {
builder.add(key, value);
@@ -190,14 +194,17 @@ export class LSM {
};
// 缓存
this.sstableCache.set(id, sstableData);
this.cacheSSTable(id, sstableData);
this.trimCache();
// 持久化:先存数据,再存元数据
this.sstableStore.save(id, sstableData).catch(() => {});
this.sstableStore.saveMeta(meta).catch(() => {});
// 持久化:先存数据,再存元数据(串行链保证顺序与 id 一致)
await this.sstableStore.save(id, sstableData);
await this.sstableStore.saveMeta(meta);
this.levels[0].push(meta);
this.immutableMemtable = null;
if (this.immutableMemtable === frozen) this.immutableMemtable = null;
this.levels[0].unshift(meta);
// 数据已落盘(levels 可见),从 pending frozen 移除
this.frozenMemtables = this.frozenMemtables.filter((f) => f !== frozen);
// 异步触发 compaction(不阻塞当前写入)
if (this.levels[0].length >= 4 && !this.compacting) {
@@ -211,7 +218,7 @@ export class LSM {
this.compacting = true;
setTimeout(() => {
try {
this.compactLevelSync(level);
this.flushChain = this.flushChain.then(() => this.compactLevelAsync(level));
} finally {
this.compacting = false;
// 连续触发:如果 compaction 后仍然超标,继续调度
@@ -226,18 +233,73 @@ export class LSM {
}, 0);
}
/** 背压场景下排队 compaction(写入路径调用) */
private enqueueCompact(level: number): void {
if (level >= MAX_LSM_LEVELS - 1 || this.compacting) return;
this.compacting = true;
this.flushChain = this.flushChain.then(async () => {
try {
await this.compactLevelAsync(level);
} finally {
this.compacting = false;
}
});
}
// =======================================================================
// 读取
// =======================================================================
/**
* key SSTable
* / loadSSTableReader null
* flush flush SSTable
*/
async prefetchRange(startKey: string, endKey: string): Promise<void> {
await this.flushChain;
this.trimCache();
const toLoad: number[] = [];
for (let level = 0; level < MAX_LSM_LEVELS; level++) {
for (const meta of this.levels[level]) {
if (endKey < meta.minKey || startKey > meta.maxKey) continue;
if (!this.sstableCache.has(meta.id)) toLoad.push(meta.id);
}
}
for (const id of toLoad) {
await this.preloadSSTable(id);
}
}
/** 预加载包含指定 key 的所有 SSTable 到缓存 */
async prefetchKeys(keys: string[]): Promise<void> {
if (keys.length === 0) return;
await this.flushChain;
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 key of keys) {
if (key >= meta.minKey && key <= meta.maxKey) {
toLoad.add(meta.id);
break;
}
}
}
}
for (const id of toLoad) {
await this.preloadSSTable(id);
}
}
get(key: string): Record<string, unknown> | null {
// 1. 活跃 MemTable
let result = this.memtable.get(key);
if (result !== null) return this.unwrapTombstone(result);
// 2. 不可变 MemTable
if (this.immutableMemtable) {
result = this.immutableMemtable.get(key);
// 2. pending frozen memtables(从新到旧)
for (let i = this.frozenMemtables.length - 1; i >= 0; i--) {
result = this.frozenMemtables[i].get(key);
if (result !== null) return this.unwrapTombstone(result);
}
@@ -275,9 +337,10 @@ export class LSM {
this.memtable.rangeScan(startKey, endKey),
));
if (this.immutableMemtable) {
// pending frozen memtables(从新到旧,新数据 sourceIndex 更小)
for (let i = this.frozenMemtables.length - 1; i >= 0; i--) {
mergeIter.addSource(new ArrayEntrySource(
this.immutableMemtable.rangeScan(startKey, endKey),
this.frozenMemtables[i].rangeScan(startKey, endKey),
));
}
@@ -303,9 +366,11 @@ export class LSM {
getAllEntries(): [string, Record<string, unknown>][] {
const result = new Map<string, Record<string, unknown>>();
// 从最旧层级开始聚合
// 从最旧层级开始聚合;同层级内从最旧到最新遍历,
// 保证 result.set 覆盖时最终保留最新值
for (let level = MAX_LSM_LEVELS - 1; level >= 0; level--) {
for (const meta of this.levels[level]) {
for (let i = this.levels[level].length - 1; i >= 0; i--) {
const meta = this.levels[level][i];
const reader = this.loadSSTableReader(meta);
if (!reader) continue;
reader.scanAll((k, v) => result.set(k, v));
@@ -316,8 +381,8 @@ export class LSM {
for (const [k, v] of this.memtable.getAllEntries()) {
result.set(k, v);
}
if (this.immutableMemtable) {
for (const [k, v] of this.immutableMemtable.getAllEntries()) {
for (let i = this.frozenMemtables.length - 1; i >= 0; i--) {
for (const [k, v] of this.frozenMemtables[i].getAllEntries()) {
result.set(k, v);
}
}
@@ -331,13 +396,13 @@ export class LSM {
// Compaction
// =======================================================================
/** 同步执行 Compactionpublic,供 VACUUM 等外部调用) */
compactLevel(level: number): void {
this.compactLevelSync(level);
/** 执行 Compactionpublic,供 VACUUM 等外部调用) */
async compactLevel(level: number): Promise<void> {
await this.compactLevelAsync(level);
}
/** 同步执行 Compaction(简化版,内部实现) */
private compactLevelSync(level: number): void {
/** 串行执行 Compaction(简化版,内部实现) */
private async compactLevelAsync(level: number): Promise<void> {
if (level >= MAX_LSM_LEVELS - 1) return;
if (this.levels[level].length < 4) return;
@@ -355,7 +420,7 @@ export class LSM {
const merged = mergeIter.drain();
if (merged.length === 0) return;
const id = this.nextSSTableId++;
const id = await this.sstableStore.allocateId();
const builder = new SSTableBuilder(this.blockSize);
for (const [key, value] of merged) {
builder.add(key, value);
@@ -372,34 +437,41 @@ export class LSM {
bloomData: null,
};
this.sstableCache.set(id, sstableData);
this.sstableStore.save(id, sstableData).catch(() => {});
this.sstableStore.saveMeta(meta).catch(() => {});
this.levels[level + 1].push(meta);
this.cacheSSTable(id, sstableData);
this.trimCache();
await this.sstableStore.save(id, sstableData);
await this.sstableStore.saveMeta(meta);
this.levels[level + 1].unshift(meta);
// 删除旧 SSTable
for (const old of sstables) {
this.sstableCache.delete(old.id);
this.sstableStore.delete(old.id).catch(() => {});
this.sstableStore.deleteMeta(old.id).catch(() => {});
await this.sstableStore.delete(old.id);
await this.sstableStore.deleteMeta(old.id);
}
}
/** 等待所有排队的 flush/compaction 完成,并将剩余数据刷盘 */
async flush(): Promise<void> {
// 等待链上已排队的 flush/compaction
await this.flushChain;
// 若仍有 frozen 数据未刷盘,在链尾追加
if (this.immutableMemtable) {
this.flushImmutableSync();
const frozen = this.immutableMemtable;
await this.flushImmutableAsync(frozen);
}
if (this.memtable.getEntryCount() > 0) {
this.freezeMemtable();
this.flushImmutableSync();
const frozen = this.immutableMemtable;
if (frozen) await this.flushImmutableAsync(frozen);
}
// 确保所有微任务完成(不使用 setTimeout,避免额外 timer 阻止进程退出)
await Promise.resolve();
this.frozenMemtables = [];
}
async clear(): Promise<void> {
this.memtable.clear();
this.immutableMemtable = null;
this.frozenMemtables = [];
for (const level of this.levels) {
for (const meta of level) {
@@ -414,7 +486,7 @@ export class LSM {
this.levels.push([]);
}
this.sstableCache.clear();
this.nextSSTableId = 1;
this.cacheSize = 0;
}
getStats(): { memtableSize: number; sstableCount: number; levelCounts: number[] } {
@@ -442,12 +514,18 @@ export class LSM {
/** 尝试从缓存或存储加载 SSTable,返回 Reader */
private loadSSTableReader(meta: SSTableMeta): SSTableReader | null {
// 先检查缓存
let data = this.sstableCache.get(meta.id);
const data = this.sstableCache.get(meta.id);
if (!data) {
return null; // 异步加载已不可用,返回 null(调用方处理)
// 缓存未命中:正常路径应在查询前通过 prefetchRange/prefetchKeys 预加载。
// 这里仅当缓存中有缺失且无兜底时返回 null(调用方跳过)。
return null;
}
// 刷新 LRU 顺序(近似:删除后重新插入使其成为最近使用)
this.sstableCache.delete(meta.id);
this.sstableCache.set(meta.id, data);
try {
return new SSTableReader(data, meta);
} catch {
@@ -455,12 +533,47 @@ export class LSM {
}
}
/** 预加载 SSTable 到缓存(供外部在需要时调用 */
/** 预加载 SSTable 到缓存(受 cacheLimitBytes 上限约束 */
async preloadSSTable(id: number): Promise<void> {
if (this.sstableCache.has(id)) return;
const data = await this.sstableStore.load(id);
if (data) {
this.cacheSSTable(id, data);
}
}
/** 写入缓存。注意:不在加载时立即驱逐,避免破坏正在进行的同步扫描 */
private cacheSSTable(id: number, data: Uint8Array): void {
// 已存在则先移除(保持"最近使用"语义)
if (this.sstableCache.has(id)) {
this.cacheSize -= this.sstableCache.get(id)!.byteLength;
this.sstableCache.delete(id);
}
this.sstableCache.set(id, data);
this.cacheSize += data.byteLength;
}
/**
* LRU cacheLimitBytes
* /
*
*/
trimCache(): void {
while (this.cacheSize > this.cacheLimitBytes && this.sstableCache.size > 0) {
const eldestId = this.sstableCache.keys().next().value as number;
const evicted = this.sstableCache.get(eldestId)!;
this.cacheSize -= evicted.byteLength;
this.sstableCache.delete(eldestId);
}
}
/** 获取当前 SSTable 缓存大小(字节) */
getCacheSize(): number {
return this.cacheSize;
}
/** 获取 SSTable 缓存容量上限(字节) */
getCacheLimit(): number {
return this.cacheLimitBytes;
}
}
+10 -7
View File
@@ -149,20 +149,23 @@ export class MergeIterator {
next(): [string, Record<string, unknown>] | null {
if (this.heap.size === 0) return null;
const node = this.heap.pop()!;
const key = node.key;
const value = node.value;
const first = this.heap.pop()!;
const key = first.key;
let best = first;
// 刷新来源的下一个值
this.seedFromSource(node.sourceIndex);
// 刷新 first 来源的下一个值
this.seedFromSource(first.sourceIndex);
// 跳过重复 key取最新的(堆顶的即是最新的,因为来源下标越小越新)
// 跳过重复 key在多个来源中保留 sourceIndex 最小(最新)的条目
while (this.heap.peek() && this.heap.peek()!.key === key) {
const dup = this.heap.pop()!;
this.seedFromSource(dup.sourceIndex);
if (dup.sourceIndex < best.sourceIndex) {
best = dup;
}
}
return [key, value];
return [best.key, best.value];
}
/** 耗尽管道,返回所有归并结果 */
+1 -3
View File
@@ -24,7 +24,7 @@
*/
import { BloomFilter } from './bloom';
import type { IndexEntry, DataBlock } from '../types';
import type { IndexEntry } from '../types';
const SSTABLE_MAGIC = 0x53535442; // "SSTB"
const SSTABLE_FOOTER_SIZE = 32;
@@ -187,8 +187,6 @@ export class SSTableBuilder {
block: [string, Record<string, unknown>][],
bloomFilter: BloomFilter,
): number {
const start = offset;
// entry count
view.setUint32(offset, block.length, false);
offset += 4;
+2 -5
View File
@@ -8,12 +8,9 @@
import { PAGE_SIZE, PageType, type PageHandle } from '../types';
import {
initPageHeader,
decodePageHeader,
encodePageHeader,
getSlotCount,
getPageId,
} from './header';
import { allocateSlot, freeSlot, readSlotData, getAllSlots } from './slot';
import { allocateSlot, freeSlot, readSlotData } from './slot';
import { encodeTuple, decodeTuple } from './tuple';
// ---------------------------------------------------------------------------
@@ -128,7 +125,7 @@ export function pageUpdateRow(
// 先标记旧 slot 为删除
pageDeleteRow(page, slotIndex);
// 分配新 slot,可能会在不同位置
const newSlot = pageInsertRow(page, row, columnOrder, columnTypes);
pageInsertRow(page, row, columnOrder, columnTypes);
// 注意:调用者需要自行维护 slot index → pk 的映射
}
+1 -2
View File
@@ -152,7 +152,6 @@ export function decodeTuple(
const nullBitmapBytes = Math.ceil(columnOrder.length / 8);
if (offset + nullBitmapBytes > bytes.byteLength) return null;
const nullBitmap = bytes.slice(offset, offset + nullBitmapBytes);
offset += nullBitmapBytes;
const row: Record<string, unknown> = {};
@@ -187,7 +186,7 @@ function decodeColumnValue(
bytes: Uint8Array,
offset: number,
tag: number,
fieldType: string,
_fieldType: string,
): { value: unknown; nextOffset: number } | null {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+36 -6
View File
@@ -21,7 +21,6 @@
*/
import { WALRecordType, type WALRecord } from '../types';
import type { BufferPool } from '../buffer/pool';
// ---------------------------------------------------------------------------
// WAL 存储接口
@@ -85,20 +84,50 @@ export class WAL {
// 'none' mode: 不写 WAL
}
/** 批量追加多条 WAL 记录(组提交:合并为一次底层写入,v0.3.1) */
async appendBatch(records: Omit<WALRecord, 'lsn' | 'checksum'>[]): Promise<void> {
if (!this.enabled || records.length === 0) return;
const chunks: Uint8Array[] = [];
for (const record of records) {
this.lsn++;
chunks.push(this.encodeRecord({ ...record, lsn: this.lsn, checksum: 0 }));
}
const combined = this.mergeChunks(chunks);
if (this.syncMode === 'full') {
try {
await this.store.append(combined);
} catch {
// eslint-disable-next-line no-console
console.warn('[AriaEngine WAL] Failed to append batch record');
}
} else if (this.syncMode === 'batch') {
this.buffer.push(combined);
}
// 'none' mode: 不写 WAL
}
/** 批量刷新缓冲的 WAL 记录 */
async flush(): Promise<void> {
if (!this.enabled || this.buffer.length === 0) return;
const totalLen = this.buffer.reduce((sum, b) => sum + b.byteLength, 0);
const combined = this.mergeChunks(this.buffer);
await this.store.append(combined);
this.buffer = [];
}
/** 合并多个字节块为一个连续缓冲区 */
private mergeChunks(chunks: Uint8Array[]): Uint8Array {
if (chunks.length === 1) return chunks[0];
const totalLen = chunks.reduce((sum, b) => sum + b.byteLength, 0);
const combined = new Uint8Array(totalLen);
let offset = 0;
for (const buf of this.buffer) {
for (const buf of chunks) {
combined.set(buf, offset);
offset += buf.byteLength;
}
await this.store.append(combined);
this.buffer = [];
return combined;
}
// =======================================================================
@@ -260,6 +289,7 @@ export class WAL {
}
if ((computedCrc >>> 0) !== storedCrc) {
// CRC 不匹配,跳过此损坏记录
// eslint-disable-next-line no-console
console.warn(`[AriaEngine WAL] CRC mismatch at record LSN=${lsn}, skipping`);
continue;
}
+152 -3
View File
@@ -27,7 +27,7 @@ export class IndexedDBEngine implements IStorageEngine {
await this.memoryCache.open(dbName, version);
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, version);
request.onsuccess = () => {
request.onsuccess = async () => {
this.db = request.result;
// 多标签页冲突处理:其他标签页升级版本时自动关闭当前连接
this.db.onversionchange = () => {
@@ -38,13 +38,89 @@ export class IndexedDBEngine implements IStorageEngine {
console.warn(`[metona-sqlark] Database "${dbName}" was upgraded in another tab. Connection closed. Please re-open.`);
}
};
try {
// v0.3.2: reopen 后从 IDB 重建 schemaschema 此前只存内存缓存,重开连接即丢失)
await this.rebuildSchemaFromIDB();
resolve();
} catch (error) {
reject(new DatabaseError(`Failed to restore schema for "${dbName}"`, 'IDB_SCHEMA_RESTORE_ERROR', error));
}
};
request.onerror = () => reject(new DatabaseError(`Failed to open IndexedDB "${dbName}"`, 'IDB_OPEN_ERROR', request.error));
request.onblocked = () => reject(new DatabaseError(`IndexedDB "${dbName}" is blocked`, 'IDB_BLOCKED'));
});
}
/**
* IDB schema
* 1. schema '__metona_schema' storev0.3.2
* 2. 退 objectStore / /
*/
private async rebuildSchemaFromIDB(): Promise<void> {
const db = this.ensureDB();
// 1. 持久化 schema
if (db.objectStoreNames.contains('__metona_schema')) {
const records: { name: string; schema: string }[] = await new Promise((resolve, reject) => {
const req = db.transaction('__metona_schema', 'readonly').objectStore('__metona_schema').getAll();
req.onsuccess = () => resolve((req.result ?? []) as { name: string; schema: string }[]);
req.onerror = () => reject(req.error);
});
for (const rec of records) {
try {
const schema = JSON.parse(rec.schema) as TableSchema;
if (!(await this.memoryCache.getTableSchema(schema.name))) {
await this.memoryCache.createTable(schema);
}
} catch {
// 损坏的 schema 记录忽略
}
}
}
// 2. 回退:无持久化 schema 的表从 IDB 结构推断
const storeNames = Array.from(db.objectStoreNames).filter((n) => n !== '__metona_schema');
for (const tableName of storeNames) {
// 已有 schema(持久化恢复或连续 open)则跳过
const existing = await this.memoryCache.getTableSchema(tableName);
if (existing) continue;
const columns: Record<string, import('../constants').ColumnDef> = {};
const tx = db.transaction(tableName, 'readonly');
const store = tx.objectStore(tableName);
// 主键列
const pk = store.keyPath as string;
columns[pk] = { type: 'string', primaryKey: true };
// 索引列(idx_ 前缀约定)
for (const idxName of Array.from(store.indexNames)) {
if (idxName.startsWith('idx_')) {
const col = idxName.slice(4);
if (!columns[col]) {
columns[col] = { type: 'string', index: true };
}
}
}
// 从样例数据推断其余列的类型
const rows: Record<string, unknown>[] = await new Promise((resolve, reject) => {
const req = store.getAll();
req.onsuccess = () => resolve((req.result ?? []) as Record<string, unknown>[]);
req.onerror = () => reject(req.error);
});
if (rows.length > 0) {
for (const [key, value] of Object.entries(rows[0])) {
if (!columns[key]) {
columns[key] = { type: inferFieldType(value) };
}
}
}
await this.memoryCache.createTable({ name: tableName, columns });
}
}
async close(): Promise<void> {
if (this.db) {
this.db.onversionchange = null; // 清理监听器
@@ -113,6 +189,48 @@ export class IndexedDBEngine implements IStorageEngine {
await this.idbClear(tableName);
}
// ---- 动态索引(v0.3.0):通过版本升级创建/删除 IDB 索引 ----
async createIndex(tableName: string, column: string, unique?: boolean): Promise<void> {
await this.memoryCache.createIndex(tableName, column, unique);
if (this.txActive) return;
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const newVersion = db.version + 1; db.close();
const request = indexedDB.open(this.dbName, newVersion);
request.onupgradeneeded = (event) => {
const idb = (event.target as IDBOpenDBRequest).result;
const tx = idb.transaction(tableName, 'readwrite');
const store = tx.objectStore(tableName);
if (!store.indexNames.contains(`idx_${column}`)) {
store.createIndex(`idx_${column}`, column, { unique: unique ?? false });
}
};
request.onsuccess = () => { this.db = request.result; resolve(); };
request.onerror = () => reject(new DatabaseError(`Failed to create index "${tableName}.${column}"`, 'IDB_UPGRADE_ERROR', request.error));
});
}
async dropIndex(tableName: string, column: string, _indexName?: string): Promise<void> {
await this.memoryCache.dropIndex(tableName, column);
if (this.txActive) return;
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const newVersion = db.version + 1; db.close();
const request = indexedDB.open(this.dbName, newVersion);
request.onupgradeneeded = (event) => {
const idb = (event.target as IDBOpenDBRequest).result;
const tx = idb.transaction(tableName, 'readwrite');
const store = tx.objectStore(tableName);
if (store.indexNames.contains(`idx_${column}`)) {
store.deleteIndex(`idx_${column}`);
}
};
request.onsuccess = () => { this.db = request.result; resolve(); };
request.onerror = () => reject(new DatabaseError(`Failed to drop index "${tableName}.${column}"`, 'IDB_UPGRADE_ERROR', request.error));
});
}
// ---- 事务 ----
async beginTransaction(): Promise<void> {
@@ -154,8 +272,19 @@ export class IndexedDBEngine implements IStorageEngine {
store.createIndex(`idx_${colName}`, colName, { unique: colDef.unique ?? false });
}
}
// v0.3.2: schema 持久化 store(记录在升级完成后的 onsuccess 写入)
if (!db.objectStoreNames.contains('__metona_schema')) {
db.createObjectStore('__metona_schema', { keyPath: 'name' });
}
};
request.onsuccess = () => {
this.db = request.result;
// v0.3.2: 升级完成后持久化 schema(upgrade 事务内异步写会失败)
const schemaTx = this.db.transaction('__metona_schema', 'readwrite');
schemaTx.objectStore('__metona_schema').put({ name: schema.name, schema: JSON.stringify(schema) });
schemaTx.oncomplete = () => resolve();
schemaTx.onerror = () => reject(new DatabaseError(`Failed to persist schema for "${schema.name}"`, 'IDB_SCHEMA_ERROR', schemaTx.error));
};
request.onsuccess = () => { this.db = request.result; resolve(); };
request.onerror = () => reject(new DatabaseError(`Failed to create table "${schema.name}"`, 'IDB_UPGRADE_ERROR', request.error));
});
}
@@ -169,7 +298,16 @@ export class IndexedDBEngine implements IStorageEngine {
const db = (event.target as IDBOpenDBRequest).result;
if (db.objectStoreNames.contains(tableName)) db.deleteObjectStore(tableName);
};
request.onsuccess = () => { this.db = request.result; resolve(); };
request.onsuccess = () => {
this.db = request.result;
// v0.3.2: 清理持久化 schema 记录(upgrade 后执行,失败不阻塞删除)
if (this.db.objectStoreNames.contains('__metona_schema')) {
const schemaTx = this.db.transaction('__metona_schema', 'readwrite');
schemaTx.objectStore('__metona_schema').delete(tableName);
schemaTx.onerror = () => { /* 忽略:旧库可能无此记录 */ };
}
resolve();
};
request.onerror = () => reject(new DatabaseError(`Failed to drop table "${tableName}"`, 'IDB_UPGRADE_ERROR', request.error));
});
}
@@ -354,3 +492,14 @@ export class IndexedDBEngine implements IStorageEngine {
return this.db;
}
}
/** 从存储值推断字段类型(schema 重建用,v0.3.2 */
function inferFieldType(value: unknown): import('../constants').FieldType {
if (typeof value === 'number') return 'number';
if (typeof value === 'boolean') return 'boolean';
if (typeof value === 'object' && value !== null) return 'json';
if (typeof value === 'string') {
return isNaN(Date.parse(value)) ? 'string' : 'string';
}
return 'string';
}
+8
View File
@@ -55,6 +55,14 @@ export interface IStorageEngine {
/** 清空表数据(保留结构) */
clear(tableName: string): Promise<void>;
// ---- 动态索引(可选,v0.3.0 ----
/** 创建二级索引(CREATE INDEX */
createIndex?(tableName: string, column: string, unique?: boolean): Promise<void>;
/** 删除二级索引(DROP INDEX */
dropIndex?(tableName: string, column: string, indexName?: string): Promise<void>;
// ---- 事务 ----
/** 开始事务 */
+36 -2
View File
@@ -148,6 +148,41 @@ export class MemoryEngine implements IStorageEngine {
if (tableIndexes) for (const colIndex of tableIndexes.values()) colIndex.clear();
}
// ---- 动态索引(v0.3.0 ----
async createIndex(tableName: string, column: string, unique?: boolean): Promise<void> {
this.ensureTable(tableName);
const schema = this.schemas.get(tableName)!;
const colDef = schema.columns[column];
if (!colDef) throw new DatabaseError(`Column "${column}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND');
if (colDef.index || colDef.unique) return; // 已存在
colDef.index = true;
if (unique) colDef.unique = true;
const tableIndexes = this.indexes.get(tableName)!;
if (!tableIndexes.has(column)) tableIndexes.set(column, new Map());
const colIndex = tableIndexes.get(column)!;
const table = this.tables.get(tableName)!;
for (const [pk, row] of table) {
const value = row[column];
if (value !== undefined && value !== null) {
if (!colIndex.has(value)) colIndex.set(value, new Set());
colIndex.get(value)!.add(pk);
}
}
}
async dropIndex(tableName: string, column: string, _indexName?: string): Promise<void> {
this.ensureTable(tableName);
const schema = this.schemas.get(tableName)!;
const colDef = schema.columns[column];
if (!colDef) throw new DatabaseError(`Column "${column}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND');
colDef.index = false;
colDef.unique = false;
const tableIndexes = this.indexes.get(tableName);
if (tableIndexes) tableIndexes.delete(column);
}
// ---- 事务 ----
async beginTransaction(): Promise<void> {
@@ -297,10 +332,9 @@ export class MemoryEngine implements IStorageEngine {
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onDelete) continue;
const [refTable, refCol] = colDef.references.split('.');
const [refTable] = colDef.references.split('.');
if (refTable !== tableName) continue;
const refPkCol = refCol;
const refTableData = this.tables.get(refTableName);
if (!refTableData) continue;
+10
View File
@@ -132,6 +132,16 @@ export class OPFSEngine implements IStorageEngine {
await this.writeTableData(tableName, []);
}
// ---- 动态索引(v0.3.0 ----
async createIndex(tableName: string, column: string, unique?: boolean): Promise<void> {
return this.memoryCache.createIndex(tableName, column, unique);
}
async dropIndex(tableName: string, column: string, indexName?: string): Promise<void> {
return this.memoryCache.dropIndex(tableName, column, indexName);
}
// ---- 事务 ----
async beginTransaction(): Promise<void> {
+31
View File
@@ -25,6 +25,8 @@ export class HybridEngine implements IStorageEngine {
private memoryEngine: MemoryEngine;
private diskEngine: IStorageEngine;
private diskEngineType: DiskEngine;
private dbName = '';
private version = 1;
constructor(diskEngine: DiskEngine = 'indexeddb') {
this.memoryEngine = new MemoryEngine();
@@ -35,6 +37,8 @@ export class HybridEngine implements IStorageEngine {
// ---- 生命周期 ----
async open(dbName: string, version: number): Promise<void> {
this.dbName = dbName;
this.version = version;
// 先打开磁盘引擎
await this.diskEngine.open(dbName, version);
@@ -42,6 +46,17 @@ export class HybridEngine implements IStorageEngine {
await this.memoryEngine.open(dbName, version);
// 从磁盘加载现存表
await this.reloadMemoryFromDisk();
}
/**
* v0.3.2
* 使
*/
async reloadMemoryFromDisk(): Promise<void> {
await this.memoryEngine.close();
await this.memoryEngine.open(this.dbName, this.version);
const tableNames = await this.diskEngine.getTableNames();
for (const tableName of tableNames) {
const schema = await this.diskEngine.getTableSchema(tableName);
@@ -133,6 +148,22 @@ export class HybridEngine implements IStorageEngine {
await this.diskEngine.clear(tableName);
}
// ---- 动态索引(v0.3.0 ----
async createIndex(tableName: string, column: string, unique?: boolean): Promise<void> {
await this.memoryEngine.createIndex(tableName, column, unique);
if (typeof this.diskEngine.createIndex === 'function') {
await this.diskEngine.createIndex(tableName, column, unique);
}
}
async dropIndex(tableName: string, column: string, indexName?: string): Promise<void> {
await this.memoryEngine.dropIndex(tableName, column, indexName);
if (typeof this.diskEngine.dropIndex === 'function') {
await this.diskEngine.dropIndex(tableName, column, indexName);
}
}
// ---- 事务 ----
async beginTransaction(): Promise<void> {
+1 -1
View File
@@ -83,7 +83,7 @@ export { OPFSEngine } from './engine/opfs';
export { AriaEngine } from './engine/aria/index';
export { HybridEngine } from './hybrid/index';
export { Table } from './table/table';
export { parse } from './sql/parser';
export { parse, parseAll } from './sql/parser';
export { tokenize } from './sql/lexer';
// AriaEngine 类型 & 后端
+1 -1
View File
@@ -15,7 +15,7 @@
* }
*/
import type { MetonaSqlark } from '../core';
import { MetonaSqlark } from '../core';
import { useState, useEffect, useCallback, useRef } from 'react';
/** useQuery: 执行 SQL 查询 */
+1 -1
View File
@@ -12,7 +12,7 @@
* const { data, loading, refresh } = useSqlarkQuery(db, 'SELECT * FROM users');
*/
import type { MetonaSqlark } from '../core';
import { MetonaSqlark } from '../core';
import { ref, watch, onMounted, type Ref } from 'vue';
/** useSqlarkQuery: 执行 SQL 查询 */
+70 -4
View File
@@ -14,6 +14,7 @@ import type { WhereCondition, OrderBy } from '../constants';
export type StatementType =
| 'SELECT'
| 'SELECT_UNION'
| 'EXPLAIN'
| 'INSERT'
| 'UPDATE'
@@ -21,7 +22,12 @@ export type StatementType =
| 'CREATE_TABLE'
| 'DROP_TABLE'
| 'ALTER_TABLE'
| 'TRUNCATE_TABLE';
| 'TRUNCATE_TABLE'
| 'CREATE_INDEX'
| 'DROP_INDEX'
| 'BEGIN'
| 'COMMIT'
| 'ROLLBACK';
// ---------------------------------------------------------------------------
// 列引用
@@ -67,7 +73,7 @@ export interface AggregateExpression {
/** 子查询表达式 */
export interface SubqueryExpression {
type: 'SUBQUERY';
statement: SelectStatement;
statement: SelectStatement | SelectUnionStatement;
}
// ---------------------------------------------------------------------------
@@ -126,7 +132,10 @@ export interface InsertStatement {
type: 'INSERT';
into: string;
columns?: string[];
values: unknown[][];
/** VALUES 字面量 */
values?: unknown[][];
/** INSERT INTO ... SELECT ...v0.3.0 */
select?: SelectStatement | SelectUnionStatement;
}
// ---------------------------------------------------------------------------
@@ -173,6 +182,20 @@ export interface SelectStatement {
offset?: number;
}
// ---------------------------------------------------------------------------
// DQL: UNION
// ---------------------------------------------------------------------------
export interface SelectUnionStatement {
type: 'SELECT_UNION';
/** 左操作数(可以是 SELECT 或嵌套 UNION */
left: SelectStatement | SelectUnionStatement;
/** 右操作数 */
right: SelectStatement | SelectUnionStatement;
/** UNION ALL 不去重 */
all?: boolean;
}
// ---------------------------------------------------------------------------
// DDL: ALTER TABLE
// ---------------------------------------------------------------------------
@@ -193,12 +216,50 @@ export interface TruncateTableStatement {
name: string;
}
// ---------------------------------------------------------------------------
// DDL: CREATE INDEX / DROP INDEXv0.3.0
// ---------------------------------------------------------------------------
export interface CreateIndexStatement {
type: 'CREATE_INDEX';
/** 索引名(语法占位) */
name: string;
table: string;
column: string;
/** UNIQUE 索引 */
unique?: boolean;
}
export interface DropIndexStatement {
type: 'DROP_INDEX';
name: string;
table: string;
column: string;
}
// ---------------------------------------------------------------------------
// TCL: 事务语句(v0.3.0
// ---------------------------------------------------------------------------
export interface BeginTransactionStatement {
type: 'BEGIN';
}
export interface CommitTransactionStatement {
type: 'COMMIT';
}
export interface RollbackTransactionStatement {
type: 'ROLLBACK';
}
// ---------------------------------------------------------------------------
// AST 联合类型
// ---------------------------------------------------------------------------
export type Statement =
| SelectStatement
| SelectUnionStatement
| ExplainStatement
| InsertStatement
| UpdateStatement
@@ -206,4 +267,9 @@ export type Statement =
| CreateTableStatement
| DropTableStatement
| AlterTableStatement
| TruncateTableStatement;
| TruncateTableStatement
| CreateIndexStatement
| DropIndexStatement
| BeginTransactionStatement
| CommitTransactionStatement
| RollbackTransactionStatement;
+16 -4
View File
@@ -134,12 +134,16 @@ export class SelectQueryBuilder {
export class UpdateQueryBuilder {
private _where: WhereCondition = {};
private onWrite?: (table: string) => void;
constructor(
private engine: IStorageEngine,
private tableName: string,
private _updates: Record<string, unknown>,
) {}
onWrite?: (table: string) => void,
) {
this.onWrite = onWrite;
}
where(condition: WhereCondition): this {
this._where = { ...this._where, ...condition };
@@ -147,7 +151,9 @@ export class UpdateQueryBuilder {
}
async execute(): Promise<number> {
return this.engine.update(this.tableName, { table: this.tableName, where: this._where }, this._updates);
const count = await this.engine.update(this.tableName, { table: this.tableName, where: this._where }, this._updates);
this.onWrite?.(this.tableName);
return count;
}
toAST(): UpdateStatement {
@@ -161,11 +167,15 @@ export class UpdateQueryBuilder {
export class DeleteQueryBuilder {
private _where: WhereCondition = {};
private onWrite?: (table: string) => void;
constructor(
private engine: IStorageEngine,
private tableName: string,
) {}
onWrite?: (table: string) => void,
) {
this.onWrite = onWrite;
}
where(condition: WhereCondition): this {
this._where = { ...this._where, ...condition };
@@ -173,7 +183,9 @@ export class DeleteQueryBuilder {
}
async execute(): Promise<number> {
return this.engine.delete(this.tableName, { table: this.tableName, where: this._where });
const count = await this.engine.delete(this.tableName, { table: this.tableName, where: this._where });
this.onWrite?.(this.tableName);
return count;
}
toAST(): DeleteStatement {
+631 -18
View File
@@ -7,16 +7,87 @@
import type { IStorageEngine } from '../engine/interface';
import type {
Statement, SelectStatement, InsertStatement, UpdateStatement,
Statement, SelectStatement, SelectUnionStatement, InsertStatement, UpdateStatement,
DeleteStatement, CreateTableStatement, DropTableStatement, JoinClause,
AlterTableStatement, TruncateTableStatement,
AlterTableStatement, TruncateTableStatement, CreateIndexStatement, DropIndexStatement,
} from './ast';
import { DatabaseError } from '../constants';
import { compileStatement } from './compiler';
import { createSchema, astColumnToColumnDef } from '../table/schema';
import { matchWhere, applyOrderBy, projectColumns } from './where-matcher';
import { parseWhereCondition } from '../sql/parser';
import type { WhereCondition } from '../constants';
// ---------------------------------------------------------------------------
// CASE WHEN 表达式(v0.3.1
// ---------------------------------------------------------------------------
interface CaseWhenClause {
/** 条件(已解析为 WhereCondition,解析失败为 null 表示跳过) */
cond: WhereCondition | null;
/** THEN 值(字面量或列引用文本) */
value: string;
}
interface CaseExpression {
whens: CaseWhenClause[];
elseValue: string | null;
alias: string | null;
}
/** 解析 "CASE WHEN c1 THEN v1 WHEN c2 THEN v2 ELSE v3 END [AS alias]" */
function parseCaseExpression(expr: string): CaseExpression | null {
const m = expr.match(/^\s*CASE\s+([\s\S]*?)\s+END\s*(?:AS\s+(\w+))?\s*$/i);
if (!m) return null;
const body = m[1];
const alias = m[2] ?? null;
const whens: CaseWhenClause[] = [];
const re = /WHEN\s+([\s\S]*?)\s+THEN\s+([\s\S]*?)(?=\s+WHEN\s+|\s+ELSE\s+|\s*$)/gi;
let match: RegExpExecArray | null;
while ((match = re.exec(body)) !== null) {
let cond: WhereCondition | null = null;
try {
cond = parseWhereCondition(match[1].trim());
} catch {
// 条件解析失败视为不匹配
}
whens.push({ cond, value: match[2].trim() });
}
let elseValue: string | null = null;
const elseMatch = body.match(/\sELSE\s+([\s\S]*)$/i);
if (elseMatch) elseValue = elseMatch[1].trim();
return { whens, elseValue, alias };
}
/** 解析 CASE 值:字面量(null/true/false/数字/字符串)优先,其次列引用 → 行值 */
function resolveCaseValue(text: string, row: Record<string, unknown>): unknown {
const v = text.trim();
if (v === 'null') return null;
if (v === 'true') return true;
if (v === 'false') return false;
const num = Number(v);
if (v !== '' && !isNaN(num)) return num;
const str = v.match(/^'(.*)'$/s) || v.match(/^"(.*)"$/s);
if (str) return str[1];
if (/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(v)) {
return row[v] ?? null; // 列引用(含 table.col
}
return v;
}
/** 对行求值 CASE WHEN 表达式 */
function evaluateCase(expr: CaseExpression, row: Record<string, unknown>): unknown {
for (const { cond, value } of expr.whens) {
if (cond && matchWhere(row, cond)) {
return resolveCaseValue(value, row);
}
}
return expr.elseValue !== null ? resolveCaseValue(expr.elseValue, row) : null;
}
// ---------------------------------------------------------------------------
// Executor
// ---------------------------------------------------------------------------
@@ -36,6 +107,7 @@ export class QueryExecutor {
async execute(stmt: Statement): Promise<unknown> {
switch (stmt.type) {
case 'SELECT': return this.executeSelect(stmt);
case 'SELECT_UNION': return this.executeSelectUnion(stmt);
case 'EXPLAIN': return this.executeExplain(stmt as any);
case 'INSERT': return this.executeInsert(stmt);
case 'UPDATE': return this.executeUpdate(stmt);
@@ -44,10 +116,69 @@ export class QueryExecutor {
case 'DROP_TABLE': return this.executeDropTable(stmt);
case 'ALTER_TABLE': return this.executeAlterTable(stmt as any);
case 'TRUNCATE_TABLE': return this.executeTruncateTable(stmt as any);
case 'CREATE_INDEX': return this.executeCreateIndex(stmt as any);
case 'DROP_INDEX': return this.executeDropIndex(stmt as any);
case 'BEGIN': return this.executeBegin();
case 'COMMIT': return this.executeCommit();
case 'ROLLBACK': return this.executeRollback();
default: throw new DatabaseError('Unknown statement type', 'UNKNOWN_STATEMENT');
}
}
// ===================================================================
// UNIONv0.3.0
// ===================================================================
/** 递归执行 UNION / UNION ALL,返回合并结果 */
private async executeSelectUnion(stmt: SelectUnionStatement): Promise<Record<string, unknown>[]> {
const leftRows = await this.executeSelectPart(stmt.left);
const rightRows = await this.executeSelectPart(stmt.right);
// 列名以左侧为准,右侧只取值
const leftCols = leftRows.length > 0 ? Object.keys(leftRows[0]) : [];
const normalized: Record<string, unknown>[] = leftRows.map((row) => row);
if (stmt.all) {
for (const row of rightRows) normalized.push(this.projectUnionRow(row, leftCols));
return normalized;
}
// UNION 去重(与 DISTINCT 相同的列值拼接键)
const seen = new Set<string>();
const result: Record<string, unknown>[] = [];
for (const row of normalized) {
const key = Object.values(row).map((v) => String(v ?? '\0')).join('\x1f');
if (!seen.has(key)) {
seen.add(key);
result.push(row);
}
}
for (const row of rightRows) {
const projected = this.projectUnionRow(row, leftCols);
const key = Object.values(projected).map((v) => String(v ?? '\0')).join('\x1f');
if (!seen.has(key)) {
seen.add(key);
result.push(projected);
}
}
return result;
}
private async executeSelectPart(part: SelectStatement | SelectUnionStatement): Promise<Record<string, unknown>[]> {
return part.type === 'SELECT_UNION' ? this.executeSelectUnion(part) : this.executeSelect(part);
}
/** 将 UNION 右侧行投影为左侧列结构(按位置取值) */
private projectUnionRow(row: Record<string, unknown>, leftCols: string[]): Record<string, unknown> {
if (leftCols.length === 0) return row;
const values = Object.values(row);
const projected: Record<string, unknown> = {};
for (let i = 0; i < leftCols.length; i++) {
projected[leftCols[i]] = i < values.length ? values[i] : null;
}
return projected;
}
/** EXPLAIN: 输出查询计划 */
private async executeExplain(stmt: { query: Statement }): Promise<Record<string, unknown>> {
const plan = compileStatement(stmt.query.type === 'SELECT' ? stmt.query : stmt.query as any);
@@ -78,20 +209,40 @@ export class QueryExecutor {
// ===================================================================
private async executeSelect(stmt: SelectStatement): Promise<Record<string, unknown>[]> {
// 先解析子查询
if (stmt.where && Object.keys(stmt.where).length > 0) {
stmt.where = await this.resolveSubqueries(stmt.where);
}
const hasGroupBy = !!(stmt.groupBy && stmt.groupBy.length > 0);
const hasAggregate = !hasGroupBy && this._hasAggregateColumn(stmt.columns);
let rows: Record<string, unknown>[];
if (!stmt.joins || stmt.joins.length === 0) {
const plan = compileStatement(hasGroupBy || hasAggregate ? { ...stmt, columns: ['*'] } : stmt);
rows = await this.engine.find(plan.table, plan);
} else {
const isJoinQuery = !!(stmt.joins && stmt.joins.length > 0);
// CASE WHEN 表达式需要原始列求值(SELECT 列或 WHERE 条件中的 CASE):
// 引擎层取全行,投影统一在 executor 端完成
const needsRawRows = this.hasCaseColumn(stmt.columns) ||
(!!stmt.where && this.whereHasCase(stmt.where));
if (isJoinQuery) {
// JOIN 路径:行带表别名前缀(如 'd.id'),WHERE 保持原名不剥离
rows = await this.executeJoinSelect(stmt);
} else {
// 非 JOIN 路径:规范化 WHERE 字段名(剥离主表别名前缀,修复 WHERE u.age > 20
if (stmt.where && Object.keys(stmt.where).length > 0) {
stmt.where = this.normalizeWhereColumns(stmt.where, [stmt.alias ?? stmt.from]);
}
// WHERE 含关联子查询($col 引用外层行)→ 逐行绑定上下文求值
if (stmt.where && this.hasCorrelatedRefs(stmt.where)) {
const plan = compileStatement(hasGroupBy || hasAggregate ? { ...stmt, columns: ['*'] } : stmt);
if (needsRawRows) plan.columns = ['*'];
rows = await this.engine.find(plan.table, { ...plan, where: this.stripCorrelatedExists(stmt.where) });
rows = await this.filterCorrelated(rows, stmt.where);
} else {
// 先解析子查询
if (stmt.where && Object.keys(stmt.where).length > 0) {
stmt.where = await this.resolveSubqueries(stmt.where);
}
const plan = compileStatement(hasGroupBy || hasAggregate ? { ...stmt, columns: ['*'] } : stmt);
if (needsRawRows) plan.columns = ['*'];
rows = await this.engine.find(plan.table, plan);
}
}
// 无 GROUP BY 但有聚合 → 计算单行聚合结果
@@ -109,7 +260,7 @@ export class QueryExecutor {
const limit = stmt.limit ?? rows.length;
rows = rows.slice(offset, offset + limit);
if (!hasGroupBy && !hasAggregate && stmt.columns.length > 0 && stmt.columns[0] !== '*') {
rows = rows.map((row) => projectColumns(row, stmt.columns));
rows = rows.map((row) => this.projectRow(row, stmt.columns));
}
// 全局行数上限保护
@@ -130,14 +281,27 @@ export class QueryExecutor {
for (const join of stmt.joins!) {
const joinAlias = join.alias ?? join.table;
// v0.3.2: 等值 ON + 右列主键 → 哈希连接(一次 $in 查询替代嵌套循环)
const hashJoined = await this.tryHashJoin(resultRows, join, joinAlias, mainAlias);
if (hashJoined) {
resultRows = hashJoined;
continue;
}
const joinRows = (await this.engine.find(join.table, { table: join.table }))
.map((row) => this.prefixRow(row, joinAlias));
resultRows = this.joinRows(resultRows, joinRows, join);
}
if (stmt.where && Object.keys(stmt.where).length > 0) {
// v0.3.1: 关联子查询($col/EXISTS 引用外层行)→ 逐行绑定求值
if (this.hasCorrelatedRefs(stmt.where)) {
resultRows = await this.filterCorrelated(resultRows, stmt.where);
} else {
// 非关联子查询(IN (SELECT ...) 等),字段名保持别名前缀
stmt.where = await this.resolveSubqueries(stmt.where);
resultRows = resultRows.filter((row) => matchWhere(row, stmt.where));
}
}
return resultRows;
}
@@ -147,6 +311,88 @@ export class QueryExecutor {
return prefixed;
}
/**
* v0.3.2ON /
* $in
* INNER/LEFT JOIN O(N + M)
* null退
*/
private async tryHashJoin(
leftRows: Record<string, unknown>[],
join: JoinClause,
joinAlias: string,
mainAlias: string,
): Promise<Record<string, unknown>[] | null> {
if (join.type === 'CROSS' || join.type === 'RIGHT') return null;
// 提取单一等值条件:{ colA: { $eq: { $col: colB } } } 或 { colA: { $col: colB } }
const keys = Object.keys(join.on);
if (keys.length !== 1) return null;
const keyCol = keys[0];
const cond = join.on[keyCol] as Record<string, unknown> | null | undefined;
let refCol: string | null = null;
if (typeof cond === 'object' && cond !== null) {
if ('$eq' in cond && typeof cond.$eq === 'object' && cond.$eq !== null && '$col' in (cond.$eq as Record<string, unknown>)) {
refCol = String((cond.$eq as Record<string, unknown>).$col);
} else if ('$col' in cond && Object.keys(cond).length === 1) {
refCol = String(cond.$col);
}
}
if (!refCol) return null;
// 方向判定:键/值哪个属于左表(mainAlias 前缀)?
// ON 键形如 'o.user_id'(右表)→ 值 $col 'u.id'(左表);或反向
const keyIsLeft = mainAlias ? keyCol.startsWith(`${mainAlias}.`) : false;
const leftCol = keyIsLeft ? keyCol : refCol;
const rightCol = keyIsLeft ? refCol : keyCol;
// 右表列必须是主键/索引列(确保 $in 走索引)
const schema = await this.engine.getTableSchema(join.table);
if (!schema) return null;
const bareRightCol = rightCol.split('.').pop()!;
const colDef = schema.columns[bareRightCol];
if (!colDef || (!colDef.primaryKey && !colDef.index && !colDef.unique)) return null;
// 收集左表连接值(去重)
const values = Array.from(new Set(leftRows.map((r) => r[leftCol]).filter((v) => v !== undefined && v !== null)));
if (values.length === 0) return null;
// 一次 $in 查询右表
const rightRows = await this.engine.find(join.table, {
table: join.table,
where: { [bareRightCol]: { $in: values } },
});
// 构建哈希映射:右列值 → 行列表
const hash = new Map<unknown, Record<string, unknown>[]>();
for (const rr of rightRows) {
const v = rr[bareRightCol];
if (v === undefined || v === null) continue;
if (!hash.has(v)) hash.set(v, []);
hash.get(v)!.push(rr);
}
const nullRight: Record<string, unknown> = {};
for (const key of Object.keys(schema.columns)) nullRight[key] = null;
const result: Record<string, unknown>[] = [];
for (const l of leftRows) {
const lv = l[leftCol];
const matches = hash.get(lv);
if (matches && matches.length > 0) {
for (const r of matches) {
result.push({ ...l, ...this.prefixRow(r, joinAlias) });
}
} else if (join.type === 'LEFT') {
// LEFT JOIN 无匹配 → 右表列置 null
result.push({ ...l, ...this.prefixRow(nullRight, joinAlias) });
}
// INNER JOIN 无匹配 → 跳过
}
return result;
}
/** 嵌套循环连接(优化:避免 ON 时对象扩散) */
private joinRows(
leftRows: Record<string, unknown>[],
@@ -212,6 +458,10 @@ export class QueryExecutor {
if (m) {
const [, func, arg, alias] = m;
aggregated[alias || colExpr] = this.computeAggregate(func.toUpperCase(), groupRows, arg.trim());
} else if (/^\s*CASE\b/i.test(colExpr)) {
// v0.3.2: 非聚合的 CASE WHEN 列取组内第一行求值
const expr = parseCaseExpression(colExpr);
aggregated[expr?.alias ?? colExpr] = expr ? evaluateCase(expr, groupRows[0]) : null;
} else if (!stmt.groupBy!.includes(colExpr)) {
aggregated[colExpr] = groupRows[0][colExpr];
}
@@ -222,7 +472,12 @@ export class QueryExecutor {
}
private computeAggregate(func: string, rows: Record<string, unknown>[], col: string): number {
const nums = rows.map((r) => r[col]).filter((v) => v !== null && v !== undefined).map(Number);
// v0.3.2: 聚合参数支持 CASE WHEN 表达式(如 SUM(CASE WHEN age > 18 THEN 1 ELSE 0 END)
const caseExpr = /^\s*CASE\b/i.test(col) ? parseCaseExpression(col) : null;
const nums = rows
.map((r) => (caseExpr ? evaluateCase(caseExpr, r) : r[col]))
.filter((v) => v !== null && v !== undefined)
.map(Number);
switch (func) {
case 'COUNT': return col === '*' ? rows.length : nums.length;
case 'SUM': return nums.reduce((a: number, b) => a + b, 0);
@@ -253,7 +508,22 @@ export class QueryExecutor {
const schema = await this.engine.getTableSchema(stmt.into);
if (!schema) throw new DatabaseError(`Table "${stmt.into}" does not exist`, 'TABLE_NOT_FOUND');
const colNames = stmt.columns ?? Object.keys(schema.columns);
const rows: Record<string, unknown>[] = stmt.values.map((vals: unknown[]) => {
// INSERT INTO ... SELECT ...v0.3.0
if (stmt.select) {
const selectRows = await this.executeSelectPart(stmt.select);
const rows: Record<string, unknown>[] = selectRows.map((row) => {
const mapped: Record<string, unknown> = {};
const values = Object.values(row);
for (let i = 0; i < colNames.length; i++) {
if (i < values.length) mapped[colNames[i]] = values[i];
}
return mapped;
});
return this.engine.insert(stmt.into, rows);
}
const rows: Record<string, unknown>[] = (stmt.values ?? []).map((vals: unknown[]) => {
const row: Record<string, unknown> = {};
for (let i = 0; i < colNames.length; i++) { if (i < vals.length) row[colNames[i]] = vals[i]; }
return row;
@@ -323,8 +593,96 @@ export class QueryExecutor {
return this.engine.clear(stmt.name);
}
// ===================================================================
// CREATE INDEX / DROP INDEXv0.3.0
// ===================================================================
private async executeCreateIndex(stmt: CreateIndexStatement): Promise<void> {
const exists = await this.engine.hasTable(stmt.table);
if (!exists) throw new DatabaseError(`Table "${stmt.table}" does not exist`, 'TABLE_NOT_FOUND');
const schema = await this.engine.getTableSchema(stmt.table);
if (schema && !schema.columns[stmt.column]) {
throw new DatabaseError(`Column "${stmt.column}" does not exist in table "${stmt.table}"`, 'COLUMN_NOT_FOUND');
}
if (typeof this.engine.createIndex !== 'function') {
throw new DatabaseError(
`Engine "${this.engine.name}" does not support CREATE INDEX`,
'NOT_SUPPORTED',
);
}
return this.engine.createIndex(stmt.table, stmt.column, stmt.unique);
}
private async executeDropIndex(stmt: DropIndexStatement): Promise<void> {
if (typeof this.engine.dropIndex !== 'function') {
throw new DatabaseError(
`Engine "${this.engine.name}" does not support DROP INDEX`,
'NOT_SUPPORTED',
);
}
return this.engine.dropIndex(stmt.table, stmt.column, stmt.name);
}
// ===================================================================
// 事务语句(v0.3.0
// ===================================================================
private async executeBegin(): Promise<void> {
return this.engine.beginTransaction();
}
private async executeCommit(): Promise<void> {
return this.engine.commitTransaction();
}
private async executeRollback(): Promise<void> {
return this.engine.rollbackTransaction();
}
/** 列列表是否包含 CASE WHEN 表达式 */
private hasCaseColumn(columns: string[]): boolean {
return columns.some((col) => /^\s*CASE\b/i.test(col));
}
/** WHERE 是否包含 CASE WHEN 表达式键 */
private whereHasCase(where: WhereCondition): boolean {
for (const [key, value] of Object.entries(where)) {
if (key === '$and' || key === '$or') {
if ((value as WhereCondition[]).some((sub) => this.whereHasCase(sub))) return true;
continue;
}
if (key === '$not') {
if (this.whereHasCase(value as WhereCondition)) return true;
continue;
}
if (/^\s*CASE\b/i.test(key)) return true;
}
return false;
}
getEngine(): IStorageEngine { return this.engine; }
/**
* v0.3.1 projectColumnsCASE WHEN
*/
private projectRow(row: Record<string, unknown>, columns: string[]): Record<string, unknown> {
const plain: string[] = [];
const caseCols: { alias: string; expr: CaseExpression }[] = [];
for (const col of columns) {
if (col === '*') continue;
const expr = parseCaseExpression(col);
if (expr) caseCols.push({ alias: expr.alias ?? col, expr });
else plain.push(col);
}
const projected = plain.length > 0 ? projectColumns(row, plain) : {};
for (const { alias, expr } of caseCols) {
projected[alias] = evaluateCase(expr, row);
}
return projected;
}
// ===================================================================
// 无 GROUP BY 时的聚合计算
// ===================================================================
@@ -351,6 +709,242 @@ export class QueryExecutor {
return result;
}
// ===================================================================
// 关联子查询 / 别名规范化(v0.3.0)
// ===================================================================
/** 剥离主表别名前缀:'u.id' → 'id'(键与 $col 值均处理,支持多层别名) */
private normalizeWhereColumns(where: WhereCondition, aliases: string[]): WhereCondition {
const normalized: WhereCondition = {};
for (const [key, value] of Object.entries(where)) {
if (key === '$and' || key === '$or') {
normalized[key] = (value as WhereCondition[]).map((sub) => this.normalizeWhereColumns(sub, aliases));
continue;
}
if (key === '$not') {
normalized.$not = this.normalizeWhereColumns(value as WhereCondition, aliases);
continue;
}
if (key === '$exists') {
normalized[key] = this.normalizeExistsValue(value, aliases);
continue;
}
const newKey = this.stripAlias(key, aliases);
normalized[newKey] = this.normalizeFieldValue(value, aliases);
}
return normalized;
}
private normalizeExistsValue(value: unknown, aliases: string[]): unknown {
if (typeof value !== 'object' || value === null) return value;
const v = value as Record<string, unknown>;
if (v.$subquery) {
const sub = v.$subquery as SelectStatement;
// 子查询 where 需同时识别:子查询自身别名 + 外层别名(关联引用)
const subAliases = [sub.alias ?? sub.from, ...aliases].filter(Boolean);
return { ...v, $subquery: { ...sub, where: this.normalizeWhereColumns(sub.where, subAliases) } };
}
return value;
}
private normalizeFieldValue(value: unknown, aliases: string[]): unknown {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return value;
const ops: Record<string, unknown> = {};
for (const [op, operand] of Object.entries(value as Record<string, unknown>)) {
if (op === '$and' || op === '$or') {
ops[op] = (operand as WhereCondition[]).map((sub) => this.normalizeWhereColumns(sub, aliases));
} else if (op === '$not' && typeof operand === 'object' && operand !== null) {
ops[op] = this.normalizeFieldValue(operand, aliases);
} else if (op === '$col') {
ops[op] = this.stripAlias(String(operand), aliases);
} else if (typeof operand === 'object' && operand !== null && !Array.isArray(operand) && '$col' in (operand as Record<string, unknown>)) {
// 操作符值中嵌套的列引用:{ $eq: { $col: 'u.id' } }
ops[op] = { $col: this.stripAlias(String((operand as Record<string, unknown>).$col), aliases) };
} else {
ops[op] = operand;
}
}
return ops;
}
private stripAlias(col: string, aliases: string[]): string {
for (const alias of aliases) {
if (!alias) continue;
const prefix = `${alias}.`;
if (col.startsWith(prefix)) return col.slice(prefix.length);
}
return col;
}
/** WHERE 是否含关联引用($col 或关联 EXISTS)或 CASE WHEN 表达式键 */
private hasCorrelatedRefs(where: WhereCondition): boolean {
for (const [key, value] of Object.entries(where)) {
if (key === '$and' || key === '$or') {
if ((value as WhereCondition[]).some((sub) => this.hasCorrelatedRefs(sub))) return true;
continue;
}
if (key === '$not') {
if (this.hasCorrelatedRefs(value as WhereCondition)) return true;
continue;
}
if (key === '$exists') {
// 关联 EXISTS:子查询 where 含 $col 或主 where 含 $negate 未解析标记
if (typeof value === 'object' && value !== null && '$subquery' in (value as Record<string, unknown>)) {
return true; // 关联 EXISTS 统一走逐行求值
}
continue;
}
// v0.3.2: CASE WHEN 表达式键(逐行求值)
if (/^\s*CASE\b/i.test(key)) return true;
if (this.fieldHasColRef(value)) return true;
}
return false;
}
private fieldHasColRef(value: unknown): boolean {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const ops = value as Record<string, unknown>;
if ('$col' in ops) return true;
if ('$and' in ops || '$or' in ops) {
const subs = (ops.$and ?? ops.$or) as WhereCondition[];
return subs.some((sub) => this.hasCorrelatedRefs(sub));
}
if ('$not' in ops && typeof ops.$not === 'object' && ops.$not !== null) {
return this.fieldHasColRef(ops.$not);
}
// 操作符值中嵌套的列引用:{ $eq: { $col: 'id' } }
for (const [, operand] of Object.entries(ops)) {
if (typeof operand === 'object' && operand !== null && !Array.isArray(operand)) {
if ('$col' in (operand as Record<string, unknown>)) return true;
if (this.fieldHasColRef(operand)) return true;
}
}
return false;
}
/** 移除关联 EXISTS 标记(引擎层先执行无 EXISTS 条件的查询) */
private stripCorrelatedExists(where: WhereCondition): WhereCondition {
const cleaned: WhereCondition = {};
for (const [key, value] of Object.entries(where)) {
if (key === '$and' || key === '$or') {
cleaned[key] = (value as WhereCondition[]).map((sub) => this.stripCorrelatedExists(sub));
continue;
}
if (key === '$not') {
const inner = this.stripCorrelatedExists(value as WhereCondition);
// 剥离后为空 → 条件恒真,删掉该键(避免引擎层执行 NOT(true) 过滤掉所有行)
if (Object.keys(inner).length > 0) cleaned.$not = inner;
continue;
}
if (key === '$exists') continue; // 逐行求值时单独处理
if (/^\s*CASE\b/i.test(key)) continue; // v0.3.2: CASE 键逐行求值
cleaned[key] = value;
}
return cleaned;
}
/** 逐行绑定外层行上下文,求值关联 EXISTS、$col 引用与 CASE WHEN 键 */
private async filterCorrelated(rows: Record<string, unknown>[], where: WhereCondition): Promise<Record<string, unknown>[]> {
const result: Record<string, unknown>[] = [];
for (const row of rows) {
// 1. CASE WHEN 键 → 布尔条件(同步)
let rowWhere = this.resolveCaseKeys(where, row);
// 2. $col 绑定 + 关联 EXISTS 求值(异步)
rowWhere = await this.resolveSubqueries(rowWhere, row);
if (matchWhere(row, rowWhere)) {
result.push(row);
}
}
return result;
}
/** 将 WHERE 中的 CASE WHEN 表达式键求值为布尔条件($caseResult */
private resolveCaseKeys(where: WhereCondition, row: Record<string, unknown>): WhereCondition {
const resolved: WhereCondition = {};
for (const [key, value] of Object.entries(where)) {
if (key === '$and' || key === '$or') {
resolved[key] = (value as WhereCondition[]).map((sub) => this.resolveCaseKeys(sub, row));
continue;
}
if (key === '$not') {
resolved.$not = this.resolveCaseKeys(value as WhereCondition, row);
continue;
}
if (/^\s*CASE\b/i.test(key)) {
const expr = parseCaseExpression(key);
if (!expr) continue; // 解析失败视为不满足
const val = evaluateCase(expr, row);
if (this.caseConditionMatches(val, value)) {
resolved.$caseResult = true;
} else {
return { $caseResult: false };
}
continue;
}
resolved[key] = value;
}
return resolved;
}
/** CASE 求值结果与操作符条件比较 */
private caseConditionMatches(val: unknown, condition: unknown): boolean {
if (typeof condition !== 'object' || condition === null || Array.isArray(condition)) {
return val === condition;
}
const ops = condition as Record<string, unknown>;
for (const [op, operand] of Object.entries(ops)) {
switch (op) {
case '$eq': if (val !== operand) return false; break;
case '$ne': if (val === operand) return false; break;
case '$gt': if (!((val as number) > (operand as number))) return false; break;
case '$gte': if (!((val as number) >= (operand as number))) return false; break;
case '$lt': if (!((val as number) < (operand as number))) return false; break;
case '$lte': if (!((val as number) <= (operand as number))) return false; break;
case '$in': if (!(Array.isArray(operand) && operand.includes(val))) return false; break;
case '$nin': if (Array.isArray(operand) && operand.includes(val)) return false; break;
default: break;
}
}
return true;
}
/** 将 where 中的 $col 引用替换为上下文行值 */
private bindColumnRefs(value: unknown, contextRow: Record<string, unknown>): unknown {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return value;
const ops: Record<string, unknown> = {};
for (const [op, operand] of Object.entries(value as Record<string, unknown>)) {
if (op === '$col') {
ops[op] = contextRow[String(operand)] ?? null;
} else if (op === '$and' || op === '$or') {
ops[op] = (operand as WhereCondition[]).map((sub) => this.bindWhereRefs(sub, contextRow));
} else if (op === '$not' && typeof operand === 'object' && operand !== null) {
ops[op] = this.bindColumnRefs(operand, contextRow);
} else if (typeof operand === 'object' && operand !== null && !Array.isArray(operand) && '$col' in (operand as Record<string, unknown>)) {
// 操作符值中嵌套的列引用:{ $eq: { $col: 'id' } } → { $eq: row['id'] }
ops[op] = contextRow[String((operand as Record<string, unknown>).$col)] ?? null;
} else {
ops[op] = operand;
}
}
return ops;
}
private bindWhereRefs(where: WhereCondition, contextRow: Record<string, unknown>): WhereCondition {
const bound: WhereCondition = {};
for (const [key, value] of Object.entries(where)) {
if (key === '$and' || key === '$or') {
bound[key] = (value as WhereCondition[]).map((sub) => this.bindWhereRefs(sub, contextRow));
} else if (key === '$not') {
bound.$not = this.bindWhereRefs(value as WhereCondition, contextRow);
} else if (key === '$exists') {
bound[key] = value;
} else {
bound[key] = this.bindColumnRefs(value, contextRow);
}
}
return bound;
}
// ===================================================================
// 子查询解析
// ===================================================================
@@ -358,26 +952,45 @@ export class QueryExecutor {
/**
* WHERE $subquery
*
* @param contextRow $col
*/
private async resolveSubqueries(where: WhereCondition): Promise<WhereCondition> {
private async resolveSubqueries(where: WhereCondition, contextRow?: Record<string, unknown>): Promise<WhereCondition> {
// 关联上下文:先把字段级的 $col 引用绑定为外层行值
if (contextRow) {
where = this.bindWhereRefs(where, contextRow);
}
const resolved: WhereCondition = {};
for (const [key, value] of Object.entries(where)) {
// 顶层 $existsv0.3.0):执行子查询并解析为 boolean,由 where-matcher 消费
if (key === '$exists' && typeof value === 'object' && value !== null) {
const v = value as Record<string, unknown>;
const sub = v.$subquery as SelectStatement;
const negate = !!(v as Record<string, unknown>).$negate;
let subWhere = sub.where;
// 子查询内的关联引用(如 o.user_id = u.id 中的 u.id)绑定外层行
if (this.hasCorrelatedRefs(subWhere)) {
subWhere = this.bindWhereRefs(subWhere, contextRow ?? {});
}
const rows = await this.executeSelectPart({ ...sub, where: subWhere });
resolved.$exists = rows.length > 0 !== negate;
continue;
}
// 逻辑组合操作符
if (key === '$and' && Array.isArray(value)) {
resolved.$and = await Promise.all(
(value as WhereCondition[]).map((sub) => this.resolveSubqueries(sub)),
(value as WhereCondition[]).map((sub) => this.resolveSubqueries(sub, contextRow)),
);
continue;
}
if (key === '$or' && Array.isArray(value)) {
resolved.$or = await Promise.all(
(value as WhereCondition[]).map((sub) => this.resolveSubqueries(sub)),
(value as WhereCondition[]).map((sub) => this.resolveSubqueries(sub, contextRow)),
);
continue;
}
if (key === '$not' && typeof value === 'object' && value !== null) {
resolved.$not = await this.resolveSubqueries(value as WhereCondition);
resolved.$not = await this.resolveSubqueries(value as WhereCondition, contextRow);
continue;
}
+15
View File
@@ -42,6 +42,16 @@ export function matchWhere(
options: { $col?: boolean } = {},
): boolean {
for (const [field, condition] of Object.entries(where)) {
// 顶层 $caseResultv0.3.2):由 Executor 对 CASE WHEN 表达式逐行求值后产生
if (field === '$caseResult') {
if (condition !== true) return false;
continue;
}
// 顶层 $existsv0.3.0):由 Executor.resolveSubqueries 解析为 boolean
if (field === '$exists') {
if (condition !== true) return false;
continue;
}
// 顶层 $and
if (field === '$and') {
const subs = condition as WhereCondition[];
@@ -54,6 +64,11 @@ export function matchWhere(
if (!subs.some((sub) => matchWhere(row, sub, options))) return false;
continue;
}
// 顶层 $notv0.3.2 修复:NOT (expr) 生成的 { $not: inner }
if (field === '$not') {
if (matchWhere(row, condition as WhereCondition, options)) return false;
continue;
}
if (!matchField(row[field], condition, row, options)) return false;
}
return true;
+272 -9
View File
@@ -11,6 +11,7 @@ import { TokenType, type Token } from './tokens';
import type {
Statement,
SelectStatement,
SelectUnionStatement,
InsertStatement,
UpdateStatement,
DeleteStatement,
@@ -18,6 +19,11 @@ import type {
DropTableStatement,
AlterTableStatement,
TruncateTableStatement,
CreateIndexStatement,
DropIndexStatement,
BeginTransactionStatement,
CommitTransactionStatement,
RollbackTransactionStatement,
ASTColumnDef,
} from '../query/ast';
import type { WhereCondition, OrderBy, SortDirection } from '../constants';
@@ -31,8 +37,10 @@ export class Parser {
private lexer: Lexer;
private curToken!: Token;
private peekToken!: Token;
private sql: string;
constructor(sql: string) {
this.sql = sql;
this.lexer = new Lexer(sql);
// 预读两个 token
this.nextToken();
@@ -51,23 +59,141 @@ export class Parser {
case TokenType.DELETE:
return this.parseDelete();
case TokenType.CREATE:
return this.parseCreateTable();
return this.parseCreateStatement();
case TokenType.DROP:
return this.parseDropTable();
return this.parseDropStatement();
case TokenType.ALTER:
return this.parseAlterTable();
case TokenType.TRUNCATE:
return this.parseTruncateTable();
case TokenType.BEGIN:
return this.parseBegin();
case TokenType.COMMIT:
return this.parseCommit();
case TokenType.ROLLBACK:
return this.parseRollback();
default:
throw this.error(`Unexpected token "${this.curToken.value}"`);
}
}
/** 解析所有语句(分号分隔的多语句支持) */
parseAllStatements(): Statement[] {
const statements: Statement[] = [];
while (!this.curTokenIs(TokenType.EOF)) {
// 跳过多余的分号
while (this.curTokenIs(TokenType.SEMICOLON)) this.nextToken();
if (this.curTokenIs(TokenType.EOF)) break;
statements.push(this.parseStatement());
// 语句后应紧跟分号或 EOF
if (this.curTokenIs(TokenType.SEMICOLON)) {
this.nextToken();
} else if (!this.curTokenIs(TokenType.EOF)) {
throw this.error(`Expected ';' after statement, got "${this.curToken.value}"`);
}
}
return statements;
}
// ---- 事务语句 ----
private parseBegin(): BeginTransactionStatement {
this.expect(TokenType.BEGIN);
// 可选 TRANSACTION 关键字
if (this._isKeywordAsIdent() && this.curToken.value.toUpperCase() === 'TRANSACTION') {
this.nextToken();
}
return { type: 'BEGIN' };
}
private parseCommit(): CommitTransactionStatement {
this.expect(TokenType.COMMIT);
if (this._isKeywordAsIdent() && this.curToken.value.toUpperCase() === 'TRANSACTION') {
this.nextToken();
}
return { type: 'COMMIT' };
}
private parseRollback(): RollbackTransactionStatement {
this.expect(TokenType.ROLLBACK);
if (this._isKeywordAsIdent() && this.curToken.value.toUpperCase() === 'TRANSACTION') {
this.nextToken();
}
return { type: 'ROLLBACK' };
}
// ---- CREATE TABLE / CREATE INDEX ----
private parseCreateStatement(): Statement {
this.expect(TokenType.CREATE);
if (this.curTokenIs(TokenType.TABLE)) {
return this.parseCreateTable();
}
if (this.curTokenIs(TokenType.INDEX) ||
(this._isKeywordAsIdent() && this.curToken.value.toUpperCase() === 'INDEX')) {
return this.parseCreateIndex();
}
if (this.curTokenIs(TokenType.UNIQUE)) {
// CREATE UNIQUE INDEX
this.nextToken();
if (this.curTokenIs(TokenType.INDEX) ||
(this._isKeywordAsIdent() && this.curToken.value.toUpperCase() === 'INDEX')) {
const stmt = this.parseCreateIndex();
stmt.unique = true;
return stmt;
}
}
throw this.error(`Expected TABLE or INDEX after CREATE, got "${this.curToken.value}"`);
}
private parseCreateIndex(): CreateIndexStatement {
this.expect(TokenType.INDEX);
const name = this.expectIdentifier('index name');
this.expect(TokenType.ON);
const table = this.expectIdentifier('table name');
this.expect(TokenType.LPAREN);
const column = this.expectIdentifier('column name');
this.expect(TokenType.RPAREN);
return { type: 'CREATE_INDEX', name, table, column };
}
// ---- DROP TABLE / DROP INDEX ----
private parseDropStatement(): Statement {
this.expect(TokenType.DROP);
if (this.curTokenIs(TokenType.TABLE)) {
return this.parseDropTable();
}
if (this.curTokenIs(TokenType.INDEX) ||
(this._isKeywordAsIdent() && this.curToken.value.toUpperCase() === 'INDEX')) {
return this.parseDropIndex();
}
throw this.error(`Expected TABLE or INDEX after DROP, got "${this.curToken.value}"`);
}
private parseDropIndex(): DropIndexStatement {
this.expect(TokenType.INDEX);
const name = this.expectIdentifier('index name');
// SQLite 风格:DROP INDEX idx_name [ON table]
let table = '';
let column = '';
if (this.curTokenIs(TokenType.ON)) {
this.nextToken();
table = this.expectIdentifier('table name');
if (this.curTokenIs(TokenType.LPAREN)) {
this.nextToken();
column = this.expectIdentifier('column name');
this.expect(TokenType.RPAREN);
}
}
return { type: 'DROP_INDEX', name, table, column };
}
// ===================================================================
// SELECT
// ===================================================================
private parseSelect(): SelectStatement {
private parseSelect(): SelectStatement | SelectUnionStatement {
this.expect(TokenType.SELECT);
// DISTINCT(可选)
@@ -153,9 +279,48 @@ export class Parser {
stmt.offset = this.expectNumber('OFFSET value');
}
// UNION / UNION ALL(可选,v0.3.0
if (this.curTokenIs(TokenType.UNION)) {
return this.parseUnion(stmt);
}
return stmt;
}
/** 解析 UNION / UNION ALL 组合(支持链式) */
private parseUnion(left: SelectStatement | SelectUnionStatement): SelectUnionStatement {
this.expect(TokenType.UNION);
let all = false;
if (this.curTokenIs(TokenType.ALL)) {
all = true;
this.nextToken();
}
const right = this.parseSelect();
const unionStmt: SelectUnionStatement = { type: 'SELECT_UNION', left, right, all: all || undefined };
// 链式 UNION
if (this.curTokenIs(TokenType.UNION)) {
return this.parseUnionChain(unionStmt);
}
return unionStmt;
}
/** 链式 UNION:左侧是已组合的 UNION 语句 */
private parseUnionChain(left: SelectUnionStatement): SelectUnionStatement {
this.expect(TokenType.UNION);
let all = false;
if (this.curTokenIs(TokenType.ALL)) {
all = true;
this.nextToken();
}
const right = this.parseSelect();
const unionStmt: SelectUnionStatement = { type: 'SELECT_UNION', left, right, all: all || undefined };
if (this.curTokenIs(TokenType.UNION)) {
return this.parseUnionChain(unionStmt);
}
return unionStmt;
}
/** 解析 JOIN 子句列表 */
private parseJoinClauses(): import('../query/ast').JoinClause[] {
const joins: import('../query/ast').JoinClause[] = [];
@@ -258,6 +423,16 @@ export class Parser {
this.expect(TokenType.RPAREN);
}
// INSERT INTO ... SELECT ...v0.3.0
if (this.curTokenIs(TokenType.SELECT)) {
return {
type: 'INSERT',
into: tableName,
columns,
select: this.parseSelect(),
};
}
// VALUES
this.expect(TokenType.VALUES);
@@ -331,7 +506,6 @@ export class Parser {
// ===================================================================
private parseCreateTable(): CreateTableStatement {
this.expect(TokenType.CREATE);
this.expect(TokenType.TABLE);
// IF NOT EXISTS(可选)
@@ -483,7 +657,6 @@ export class Parser {
// ===================================================================
private parseDropTable(): DropTableStatement {
this.expect(TokenType.DROP);
this.expect(TokenType.TABLE);
// IF EXISTS(可选)
let ifExists = false;
@@ -520,9 +693,27 @@ export class Parser {
return left;
}
/** 公共 WHERE 条件入口(供 CASE WHEN 求值等外部场景,v0.3.1 */
parseWhere(): WhereCondition {
return this.parseCondition();
}
/** simple_cond column op value | column IS [NOT] NULL | column [NOT] LIKE pattern
* | column [NOT] IN (values) | NOT condition | (condition) */
* | column [NOT] IN (values) | NOT condition | (condition)
* | [NOT] EXISTS (SELECT ...) v0.3.0 */
private parseSimpleCondition(): WhereCondition {
// [NOT] EXISTS (SELECT ...)
if (this.curTokenIs(TokenType.EXISTS) ||
(this._isKeywordAsIdent() && this.curToken.value.toUpperCase() === 'EXISTS')) {
this.nextToken();
return this.parseExistsCondition(false);
}
if (this.curTokenIs(TokenType.NOT) && this._peekIsExists()) {
this.nextToken(); // 跳过 NOT
this.nextToken(); // 跳过 EXISTS
return this.parseExistsCondition(true);
}
// NOT expr(注意 NOT IN / NOT LIKE 不作为通用 NOT
if (this.curTokenIs(TokenType.NOT) && !this._isNotInOrLike()) {
this.nextToken();
@@ -663,6 +854,21 @@ export class Parser {
return result;
}
/** 解析 EXISTS (SELECT ...) / NOT EXISTS (SELECT ...) */
private parseExistsCondition(negate: boolean): WhereCondition {
this.expect(TokenType.LPAREN);
const subquery = this.parseSelect();
this.expect(TokenType.RPAREN);
// $exists 键由 Executor.resolveSubqueries 解析为 booleanwhere-matcher 消费
return { $exists: { $subquery: subquery, $negate: negate || undefined } } as unknown as WhereCondition;
}
/** 判断当前 NOT 后是否紧跟 EXISTS */
private _peekIsExists(): boolean {
return this.peekToken.type === TokenType.EXISTS ||
(this.peekToken.type === TokenType.IDENTIFIER && this.peekToken.value.toUpperCase() === 'EXISTS');
}
/** 判断当前 NOT 是否为 NOT IN / NOT LIKE 的一部分(不应作为通用 NOT 处理) */
private _isNotInOrLike(): boolean {
return this.peekTokenIs(TokenType.IN) || this.peekTokenIs(TokenType.LIKE);
@@ -699,8 +905,20 @@ export class Parser {
return cols;
}
/** 解析列引用:支持 'col'、'table.col''COUNT(*)'/'SUM(col)' */
/** 解析列引用:支持 'col'、'table.col''COUNT(*)'/'SUM(col)'、数字常量列(SELECT 1)和 CASE WHEN 表达式(v0.3.1 */
private parseColumnRef(): string {
// CASE WHEN 表达式(v0.3.1
if (this.curTokenIs(TokenType.CASE)) {
return this.parseCaseExpressionText();
}
// 数字常量列:SELECT 1 FROM t(常见于 EXISTS 子查询)
if (this.curTokenIs(TokenType.NUMBER)) {
const value = this.curToken.value;
this.nextToken();
return value;
}
// 聚合函数?
if (
this.curTokenIs(TokenType.COUNT) ||
@@ -721,8 +939,41 @@ export class Parser {
return first;
}
/** 解析聚合函数调用: COUNT(*), SUM(col), AVG(col), MIN(col), MAX(col) */
private parseAggregateCall(): string {
/**
* CASE WHEN AS
* CASE WHEN age > 30 THEN 'senior' ELSE 'junior' END AS status
*/
private parseCaseExpressionText(): string {
const start = this.curToken.position;
this.nextToken(); // 跳过 CASE
let depth = 1;
let end = start + 'CASE'.length;
while (!this.curTokenIs(TokenType.EOF) && depth > 0) {
if (this.curTokenIs(TokenType.CASE)) depth++;
if (this.curTokenIs(TokenType.END)) {
depth--;
end = this.curToken.position + 'END'.length;
this.nextToken();
if (depth === 0) break;
}
end = this.curToken.position + this.curToken.value.length;
this.nextToken();
}
let text = this.sql.slice(start, end);
// 可选 AS 别名
if (this.curTokenIs(TokenType.AS)) {
this.nextToken();
text += ` AS ${this.expectIdentifier('alias')}`;
} else if (this.curToken.type === TokenType.IDENTIFIER && !this.curTokenIs(TokenType.COMMA) && !this._isReservedAfterFrom()) {
text += ` AS ${this.curToken.value}`;
this.nextToken();
}
return text;
}
/** 解析聚合函数调用: COUNT(*), SUM(col), AVG(col), MIN(col), MAX(col) */ private parseAggregateCall(): string {
const func = this.curToken.value.toUpperCase();
this.nextToken();
this.expect(TokenType.LPAREN);
@@ -899,3 +1150,15 @@ export function parse(sql: string): Statement {
const stmt = parser.parseStatement();
return stmt;
}
/** 解析 SQL 字符串为 AST Statement 数组(分号分隔的多语句支持,v0.3.0) */
export function parseAll(sql: string): Statement[] {
const parser = new Parser(sql);
return parser.parseAllStatements();
}
/** 解析独立 WHERE 条件表达式(CASE WHEN 求值等场景,v0.3.1 */
export function parseWhereCondition(sql: string): WhereCondition {
const parser = new Parser(sql);
return parser.parseWhere();
}
+30
View File
@@ -68,6 +68,21 @@ export enum TokenType {
MAX = 'MAX',
DISTINCT = 'DISTINCT',
// v0.3.0: 事务 / UNION / EXISTS / 动态索引
BEGIN = 'BEGIN',
COMMIT = 'COMMIT',
ROLLBACK = 'ROLLBACK',
UNION = 'UNION',
ALL = 'ALL',
INDEX = 'INDEX',
// v0.3.1: CASE WHEN 表达式
CASE = 'CASE',
WHEN = 'WHEN',
THEN = 'THEN',
ELSE = 'ELSE',
END = 'END',
// 标识符 & 字面量
IDENTIFIER = 'IDENTIFIER',
STRING = 'STRING',
@@ -165,4 +180,19 @@ export const KEYWORDS: Record<string, TokenType> = {
'MIN': TokenType.MIN,
'MAX': TokenType.MAX,
'DISTINCT': TokenType.DISTINCT,
// v0.3.0
'BEGIN': TokenType.BEGIN,
'COMMIT': TokenType.COMMIT,
'ROLLBACK': TokenType.ROLLBACK,
'UNION': TokenType.UNION,
'ALL': TokenType.ALL,
'INDEX': TokenType.INDEX,
// v0.3.1
'CASE': TokenType.CASE,
'WHEN': TokenType.WHEN,
'THEN': TokenType.THEN,
'ELSE': TokenType.ELSE,
'END': TokenType.END,
};
+14 -6
View File
@@ -18,11 +18,14 @@ export class Table<T = Record<string, unknown>> {
private engine: IStorageEngine;
private schema: TableSchema | null = null;
private executor: QueryExecutor | undefined;
/** 写入回调(多标签页广播,v0.3.2) */
private onWrite?: (table: string) => void;
constructor(engine: IStorageEngine, tableName: string, executor?: QueryExecutor) {
constructor(engine: IStorageEngine, tableName: string, executor?: QueryExecutor, onWrite?: (table: string) => void) {
this.engine = engine;
this.name = tableName;
this.executor = executor;
this.onWrite = onWrite;
}
// ---- Schema ----
@@ -40,11 +43,14 @@ export class Table<T = Record<string, unknown>> {
async insert(row: T & Record<string, unknown>): Promise<string> {
const pks = await this.engine.insert(this.name, [row as Record<string, unknown>]);
this.onWrite?.(this.name);
return pks[0];
}
async insertMany(rows: (T & Record<string, unknown>)[]): Promise<string[]> {
return this.engine.insert(this.name, rows as Record<string, unknown>[]);
const pks = await this.engine.insert(this.name, rows as Record<string, unknown>[]);
this.onWrite?.(this.name);
return pks;
}
// ---- 查询 ----
@@ -56,13 +62,13 @@ export class Table<T = Record<string, unknown>> {
// ---- 更新 ----
update(updates: Partial<T> & Record<string, unknown>): UpdateQueryBuilder {
return new UpdateQueryBuilder(this.engine, this.name, updates);
return new UpdateQueryBuilder(this.engine, this.name, updates, this.onWrite);
}
// ---- 删除 ----
delete(): DeleteQueryBuilder {
return new DeleteQueryBuilder(this.engine, this.name);
return new DeleteQueryBuilder(this.engine, this.name, this.onWrite);
}
// ---- 聚合 ----
@@ -74,10 +80,12 @@ export class Table<T = Record<string, unknown>> {
// ---- 管理 ----
async clear(): Promise<void> {
return this.engine.clear(this.name);
await this.engine.clear(this.name);
this.onWrite?.(this.name);
}
async drop(): Promise<void> {
return this.engine.dropTable(this.name);
await this.engine.dropTable(this.name);
this.onWrite?.(this.name);
}
}
+256
View File
@@ -0,0 +1,256 @@
/**
* AriaEngine SSTable
* @module tests/engine/aria-cache
*
* v0.2.6
* 1. SSTable cacheLimitBytes LRU
* 2. //PK/prefetch
* 3.
*/
import { AriaEngine } from '../../src/engine/aria/index';
import { createSchema } from '../../src/table/schema';
/** 构造小缓存 + 小 MemTable 阈值的引擎,快速产生多个 SSTable */
function createSmallCacheEngine(bufferPoolPages = 2) {
return new AriaEngine({
storageBackend: 'memory',
memtableSizeThreshold: 2048, // ~2KB 阈值 → 300 行会产生多个 SSTable
bufferPoolPages,
checkpointInterval: 100000, // 关闭自动 checkpoint,避免干扰
walSyncMode: 'none',
} as any);
}
function makeRows(count: number): Record<string, unknown>[] {
const rows: Record<string, unknown>[] = [];
for (let i = 0; i < count; i++) {
rows.push({ id: `u${i}`, name: `User${i}`, age: 20 + (i % 30) });
}
return rows;
}
describe('AriaEngine SSTable 缓存内存上限', () => {
test('缓存大小受 cacheLimitBytes 约束', async () => {
const engine = createSmallCacheEngine(2); // 2 * 4096 = 8KB 上限
await engine.open('cache-limit-test', 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
age: { type: 'number', index: true },
}));
await engine.insert('users', makeRows(300));
const lsm = (engine as any).lsm as {
getCacheSize(): number;
getCacheLimit(): number;
getStats(): { sstableCount: number };
};
const stats = lsm.getStats();
// 300 行 / 2KB 阈值 → 应产生多个 SSTable
expect(stats.sstableCount).toBeGreaterThan(1);
// 多轮查询后缓存仍受上限约束
for (let round = 0; round < 5; round++) {
const rows = await engine.find('users', { table: 'users', where: { age: 25 } });
expect(rows.length).toBe(10);
expect(lsm.getCacheSize()).toBeLessThanOrEqual(lsm.getCacheLimit());
}
await engine.close();
});
test('缓存驱逐后全表扫描仍返回完整数据(prefetch 兜底)', async () => {
const engine = createSmallCacheEngine(1); // 4KB 上限,必然触发驱逐
await engine.open('cache-evict-fullscan', 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
}));
const rows = makeRows(300);
await engine.insert('users', rows);
const all = await engine.find('users', { table: 'users' });
expect(all.length).toBe(300);
const lsm = (engine as any).lsm;
expect(lsm.getCacheSize()).toBeLessThanOrEqual(lsm.getCacheLimit());
await engine.close();
});
test('缓存驱逐后 PK 等值查询仍正确(prefetchKeys 兜底)', async () => {
const engine = createSmallCacheEngine(1);
await engine.open('cache-evict-pk', 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
}));
const rows = makeRows(300);
await engine.insert('users', rows);
// 分散查询多个 PK,每次都会经历 驱逐+重新加载
for (let i = 0; i < 300; i += 11) {
const found = await engine.find('users', { table: 'users', where: { id: `u${i}` } });
expect(found.length).toBe(1);
expect(found[0].name).toBe(`User${i}`);
}
await engine.close();
});
test('缓存驱逐后二级索引查询仍正确', async () => {
const engine = createSmallCacheEngine(1);
await engine.open('cache-evict-idx', 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
age: { type: 'number', index: true },
}));
await engine.insert('users', makeRows(300));
// 索引等值 + 范围查询
const eq = await engine.find('users', { table: 'users', where: { age: 25 } });
expect(eq.length).toBe(10);
// age 范围 20-49,每个值 10 行
const range = await engine.find('users', { table: 'users', where: { age: { $gte: 40 } } });
expect(range.length).toBe(100);
const range2 = await engine.find('users', { table: 'users', where: { age: { $gt: 45 } } });
expect(range2.length).toBe(40);
const inQuery = await engine.find('users', { table: 'users', where: { age: { $in: [21, 22] } } });
expect(inQuery.length).toBe(20);
await engine.close();
});
test('UPDATE/DELETE 在缓存驱逐后仍作用于全部行', async () => {
const engine = createSmallCacheEngine(1);
await engine.open('cache-evict-mutate', 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
age: { type: 'number' },
}));
await engine.insert('users', makeRows(300));
// 无条件更新 → 全表更新
const updated = await engine.update('users', { table: 'users' }, { name: 'Renamed' });
expect(updated).toBe(300);
// age 20-49 每个值 10 行;$lt 25 → age 20-24 → 50 行
const deleted = await engine.delete('users', { table: 'users', where: { age: { $lt: 25 } } });
expect(deleted).toBe(50);
const remaining = await engine.find('users', { table: 'users' });
expect(remaining.length).toBe(250);
expect(remaining.every((r) => r.name === 'Renamed')).toBe(true);
await engine.close();
});
test('写入路径不突破缓存上限(flush 后立即裁剪)', async () => {
const engine = createSmallCacheEngine(2);
await engine.open('cache-write-bound', 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
}));
// 分批写入,每批都触发多次 flush
for (let batch = 0; batch < 10; batch++) {
await engine.insert('users', makeRows(30).map((r, i) => ({ ...r, id: `b${batch}_u${i}` })));
const lsm = (engine as any).lsm;
expect(lsm.getCacheSize()).toBeLessThanOrEqual(lsm.getCacheLimit());
}
const all = await engine.find('users', { table: 'users' });
expect(all.length).toBe(300);
await engine.close();
});
test('回归:主 LSM 与二级索引 LSM 的 SSTable 不互相覆盖(命名空间隔离)', async () => {
const engine = createSmallCacheEngine(4);
await engine.open('regression-ns', 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string', index: true },
age: { type: 'number', index: true },
}));
// 小阈值下 insert/update 会同时触发主 LSM 与两个索引 LSM 的多次 flush
await engine.insert('users', makeRows(120));
await engine.update('users', { table: 'users', where: { age: { $gte: 30 } } }, { name: 'Senior' });
// 主数据完整且为最新值(age 20-49 每个值出现 4 次;$gte 30 → 20 个值 × 4 = 80 行)
const all = await engine.find('users', { table: 'users' });
expect(all.length).toBe(120);
expect(all.filter((r) => r.name === 'Senior').length).toBe(80);
// 二级索引等值查找仍正确(索引 LSM 数据未被覆盖)
const byName = await engine.find('users', { table: 'users', where: { name: 'Senior' } });
expect(byName.length).toBe(80);
const byAge = await engine.find('users', { table: 'users', where: { age: 25 } });
expect(byAge.length).toBe(4);
await engine.close();
});
test('回归:同 key 跨多次 flush 更新后读到最新值(多版本语义)', async () => {
const engine = createSmallCacheEngine(4);
await engine.open('regression-versions', 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
value: { type: 'number' },
}));
await engine.insert('users', [{ id: 'a', value: 1 }]);
// 连续更新同一行 20 次,每次更新都经历 flush
for (let v = 2; v <= 20; v++) {
await engine.update('users', { table: 'users', where: { id: 'a' } }, { value: v });
}
const rows = await engine.find('users', { table: 'users', where: { id: 'a' } });
expect(rows.length).toBe(1);
expect(rows[0].value).toBe(20);
// 全表扫描也应返回最新值
const all = await engine.find('users', { table: 'users' });
expect(all.length).toBe(1);
expect(all[0].value).toBe(20);
await engine.close();
});
test('回归:删除后 tombstone 跨 flush 仍生效(不残留旧数据)', async () => {
const engine = createSmallCacheEngine(4);
await engine.open('regression-tombstone', 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
age: { type: 'number', index: true },
}));
await engine.insert('users', makeRows(120));
// 分批删除,触发多次 flush
for (let batch = 0; batch < 4; batch++) {
const deleted = await engine.delete('users', { table: 'users', where: { age: { $gte: 20 + batch * 5, $lt: 25 + batch * 5 } } });
expect(deleted).toBe(20);
}
const remaining = await engine.find('users', { table: 'users' });
expect(remaining.length).toBe(40);
// 索引查找也不应返回已删除行
const ghost = await engine.find('users', { table: 'users', where: { age: 22 } });
expect(ghost.length).toBe(0);
await engine.close();
});
});
+93 -2
View File
@@ -9,10 +9,14 @@ import { MergeIterator, ArrayEntrySource } from '../../src/engine/aria/index/mer
// LZ4 压缩 — 安全烟雾测试(不卡死)
// ===================================================================
describe('AriaEngine — LZ4 Compression', () => {
it('短于 4 字节时原样返回', () => {
it('短于 4 字节时压缩为纯字面量 token 且往返一致', () => {
const input = new Uint8Array([1, 2]);
const compressed = compressLZ4(input);
expect(compressed).toBe(input);
// token(lo=0) + 2 字节字面量
expect(compressed.byteLength).toBe(3);
expect(compressed[0]).toBe(0x20); // litLen=2, matchField=0
const restored = decompressLZ4(compressed, 2);
expect(Array.from(restored)).toEqual([1, 2]);
});
it('简单文本压缩不抛出异常且产生输出', () => {
@@ -59,6 +63,58 @@ describe('AriaEngine — LZ4 Compression', () => {
const compressed = compressLZ4(input);
expect(() => decompressLZ4(compressed, input.byteLength)).not.toThrow();
});
// ---- v0.2.6 补强:压缩 → 解压 往返一致性 ----
it('往返一致:重复模式数据', () => {
const input = new TextEncoder().encode('ABCD'.repeat(100));
const compressed = compressLZ4(input);
const restored = decompressLZ4(compressed, input.byteLength);
expect(Array.from(restored)).toEqual(Array.from(input));
});
it('往返一致:自然文本数据', () => {
const input = new TextEncoder().encode(
'The quick brown fox jumps over the lazy dog. '.repeat(10),
);
const compressed = compressLZ4(input);
const restored = decompressLZ4(compressed, input.byteLength);
expect(Array.from(restored)).toEqual(Array.from(input));
});
it('往返一致:随机不可压缩数据', () => {
const input = new Uint8Array(512);
for (let i = 0; i < 512; i++) input[i] = Math.floor(Math.random() * 256);
const compressed = compressLZ4(input);
const restored = decompressLZ4(compressed, input.byteLength);
expect(Array.from(restored)).toEqual(Array.from(input));
});
it('往返一致:多种长度与字节模式', () => {
for (const size of [4, 5, 15, 16, 17, 50, 100, 300, 1000]) {
const input = new Uint8Array(size);
for (let i = 0; i < size; i++) input[i] = i % 7 === 0 ? i % 256 : 0x41;
const compressed = compressLZ4(input);
const restored = decompressLZ4(compressed, input.byteLength);
expect(Array.from(restored)).toEqual(Array.from(input));
}
});
it('往返一致:恰好 15 字节字面量边界', () => {
// 字面量长度恰好 15(token 上限)时不应丢字节
const input = new Uint8Array(15);
for (let i = 0; i < 15; i++) input[i] = i;
const compressed = compressLZ4(input);
const restored = decompressLZ4(compressed, input.byteLength);
expect(Array.from(restored)).toEqual(Array.from(input));
});
it('往返一致:超过 15 字节的连续匹配', () => {
const input = new TextEncoder().encode('X'.repeat(200) + 'Y' + 'X'.repeat(60));
const compressed = compressLZ4(input);
const restored = decompressLZ4(compressed, input.byteLength);
expect(Array.from(restored)).toEqual(Array.from(input));
});
});
// ===================================================================
@@ -89,6 +145,41 @@ describe('AriaEngine — MergeIterator', () => {
expect(result[2][0]).toBe('c');
});
// ---- v0.2.6 回归:同 key 多来源时保留 sourceIndex 最小(最新)的条目 ----
it('回归:同 key 出现在多个来源时返回 sourceIndex 最小(最新来源)的值', () => {
const mi = new MergeIterator();
// 语义:sourceIndex 越小越新(memtable=0 < immutable=1 < sstable=2+
mi.addSource(new ArrayEntrySource([['a', { v: 'source0' }]])); // 最新来源
mi.addSource(new ArrayEntrySource([['a', { v: 'source1' }]]));
mi.addSource(new ArrayEntrySource([['a', { v: 'source2' }]])); // 最旧来源
const result = mi.drain();
expect(result).toHaveLength(1);
// 取的是 sourceIndex 最小(最新来源)的条目,而非堆序决定的任意条目
expect(result[0][1].v).toBe('source0');
});
it('回归:最新来源的值位于中间 sourceIndex 时仍取 sourceIndex 最小者', () => {
const mi = new MergeIterator();
mi.addSource(new ArrayEntrySource([['a', { v: 'middle' }]])); // index 0 = 最新来源
mi.addSource(new ArrayEntrySource([['a', { v: 'newest' }]])); // index 1
mi.addSource(new ArrayEntrySource([['a', { v: 'oldest' }]])); // index 2
const result = mi.drain();
expect(result).toHaveLength(1);
expect(result[0][1].v).toBe('middle'); // index 0 的条目胜出
});
it('回归:多个同 key 来源 + 其他独立 key 混合', () => {
const mi = new MergeIterator();
mi.addSource(new ArrayEntrySource([['a', { v: 1 }], ['b', { v: 10 }]]));
mi.addSource(new ArrayEntrySource([['a', { v: 2 }]]));
mi.addSource(new ArrayEntrySource([['a', { v: 3 }], ['c', { v: 30 }]]));
const result = mi.drain();
expect(result.map(([k]) => k)).toEqual(['a', 'b', 'c']);
expect(result[0][1].v).toBe(1); // sourceIndex 0 = 最新
expect(result[1][1].v).toBe(10);
expect(result[2][1].v).toBe(30);
});
it('空数据源归并', () => {
const mi = new MergeIterator();
mi.addSource(new ArrayEntrySource([]));
+111
View File
@@ -0,0 +1,111 @@
/**
* AriaEngine CryptoManager
* @module tests/engine/aria-crypto
*
* v0.2.6
*/
import {
CryptoManager,
initCrypto,
encryptPage,
decryptPage,
closeCrypto,
} from '../../src/engine/aria/crypto';
function toBytes(data: ArrayBuffer): number[] {
return Array.from(new Uint8Array(data));
}
describe('AriaEngine — CryptoManager', () => {
test('加解密往返一致', async () => {
const cm = new CryptoManager();
await cm.init('test-password');
expect(cm.enabled).toBe(true);
const original = new TextEncoder().encode('sensitive row data').buffer;
const { iv, data } = await cm.encryptPage(original);
// 密文应为乱码(与原文不同)
expect(toBytes(data)).not.toEqual(toBytes(original));
const decrypted = await cm.decryptPage(iv, data);
expect(toBytes(decrypted)).toEqual(toBytes(original));
cm.close();
expect(cm.enabled).toBe(false);
});
test('错误密码解密失败(密钥不同)', async () => {
const cm1 = new CryptoManager();
await cm1.init('correct-password');
const original = new TextEncoder().encode('top secret').buffer;
const { iv, data } = await cm1.encryptPage(original);
const cm2 = new CryptoManager();
await cm2.init('wrong-password');
await expect(cm2.decryptPage(iv, data)).rejects.toThrow();
cm1.close();
cm2.close();
});
test('不同 salt 派生不同密钥,解密互相失败', async () => {
const cm1 = new CryptoManager();
await cm1.init('pwd', new Uint8Array(16).fill(1));
const cm2 = new CryptoManager();
await cm2.init('pwd', new Uint8Array(16).fill(2));
const original = new TextEncoder().encode('salt matters').buffer;
const { iv, data } = await cm1.encryptPage(original);
await expect(cm2.decryptPage(iv, data)).rejects.toThrow();
cm1.close();
cm2.close();
});
test('未初始化时加密抛错', async () => {
const cm = new CryptoManager();
expect(cm.enabled).toBe(false);
const data = new TextEncoder().encode('x').buffer;
await expect(cm.encryptPage(data)).rejects.toThrow(/not initialized/);
});
test('不同实例互不影响(独立密钥状态)', async () => {
const cm1 = new CryptoManager();
await cm1.init('pwd-a');
const cm2 = new CryptoManager();
await cm2.init('pwd-b');
const original = new TextEncoder().encode('instance isolation').buffer;
const { iv, data } = await cm1.encryptPage(original);
await expect(cm2.decryptPage(iv, data)).rejects.toThrow();
// 各自解密自己的数据
const dec2orig = new TextEncoder().encode('two').buffer;
const enc2 = await cm2.encryptPage(dec2orig);
const dec2 = await cm2.decryptPage(enc2.iv, enc2.data);
expect(toBytes(dec2)).toEqual(toBytes(dec2orig));
cm1.close();
cm2.close();
});
test('全局兼容层往返一致', async () => {
await initCrypto('global-password');
const original = new TextEncoder().encode('global compat layer').buffer;
const { iv, data } = await encryptPage(original);
const decrypted = await decryptPage(iv, data);
expect(toBytes(decrypted)).toEqual(toBytes(original));
closeCrypto();
});
test('大块数据(接近页面大小)往返一致', async () => {
const cm = new CryptoManager();
await cm.init('page-size-test');
// 4KB 页面数据
const original = new Uint8Array(4096);
for (let i = 0; i < 4096; i++) original[i] = i % 251;
const { iv, data } = await cm.encryptPage(original.buffer);
const decrypted = await cm.decryptPage(iv, data);
expect(toBytes(decrypted)).toEqual(toBytes(original.buffer));
cm.close();
});
});
+200
View File
@@ -0,0 +1,200 @@
/**
* React hooks v0.2.6
* @module tests/integrations/react
*
* react peer dependency
* 使 React mock hooks
* - mount
* -
* - refresh
* - SQL
*/
import { MetonaSqlark } from '../../src/core';
// ---- 最小 React mock(自包含:jest.mock 工厂不能引用外部变量) ----
jest.mock('react', () => {
const stateStore: any[] = [];
const registeredEffects = new Set<number>();
const effectQueue: Array<() => void | Promise<void>> = [];
let cursor = 0;
return {
useState: (init: any) => {
const idx = cursor++;
if (!(idx in stateStore)) stateStore[idx] = init;
return [
stateStore[idx],
(v: any) => {
stateStore[idx] = typeof v === 'function' ? v(stateStore[idx]) : v;
},
];
},
// 按 hook 调用位置去重:同一位置的 effect 只在首次 render 注册
useEffect: (fn: any, _deps: any[]) => {
const idx = cursor;
if (!registeredEffects.has(idx)) {
registeredEffects.add(idx);
effectQueue.push(fn);
}
},
useCallback: (fn: any) => fn,
useRef: (init: any) => ({ current: init }),
/** 模拟一次组件渲染:cursor 归零后执行 hook 函数 */
__mockRender: (fn: () => any): any => {
cursor = 0;
return fn();
},
__mockEffects: effectQueue,
__mockReset: () => {
stateStore.length = 0;
cursor = 0;
effectQueue.length = 0;
registeredEffects.clear();
},
};
}, { virtual: true });
import { useQuery, useTable, useDatabase } from '../../src/integrations/react';
/** mock 模块内的 effect 队列与渲染控制(自包含作用域) */
const reactMock = jest.requireMock('react') as {
__mockEffects: Array<() => void | Promise<void>>;
__mockRender: (fn: () => any) => any;
__mockReset: () => void;
};
beforeEach(() => {
reactMock.__mockReset();
});
/** 模拟组件挂载:执行 useEffect 中注册的回调 */
async function flushEffects(): Promise<void> {
const fns = reactMock.__mockEffects.splice(0);
for (const fn of fns) {
await fn();
}
}
/** 模拟组件渲染(cursor 归零,读取最新状态) */
function render<T>(fn: () => T): T {
return reactMock.__mockRender(fn);
}
describe('useQuery', () => {
test('mount 时执行 SQL 查询并更新 data/loading', async () => {
const db = { query: jest.fn().mockResolvedValue([{ id: '1', name: 'Alice' }]) } as any;
const mount = () => useQuery(db, 'SELECT * FROM users');
const hook = render(mount);
expect(hook.loading).toBe(true);
expect(db.query).not.toHaveBeenCalled();
await flushEffects();
const after = render(mount); // 模拟重渲染读取最新状态
expect(db.query).toHaveBeenCalledWith('SELECT * FROM users');
expect(after.loading).toBe(false);
expect(after.data).toEqual([{ id: '1', name: 'Alice' }]);
expect(after.error).toBeNull();
});
test('查询失败时设置 error 且 data 保持空', async () => {
const db = { query: jest.fn().mockRejectedValue(new Error('query boom')) } as any;
const mount = () => useQuery(db, 'SELECT * FROM users');
render(mount);
await flushEffects();
const after = render(mount);
expect(after.error).toBeInstanceOf(Error);
expect((after.error as Error).message).toBe('query boom');
expect(after.data).toEqual([]);
expect(after.loading).toBe(false);
});
test('refresh 可重新执行查询', async () => {
let call = 0;
const db = { query: jest.fn().mockImplementation(async () => [{ n: ++call }]) } as any;
const mount = () => useQuery(db, 'SELECT * FROM users');
const hook = render(mount);
await flushEffects();
expect(render(mount).data).toEqual([{ n: 1 }]);
await hook.refresh();
await flushEffects();
expect(db.query).toHaveBeenCalledTimes(2);
expect(render(mount).data).toEqual([{ n: 2 }]);
});
test('不同 SQL 使用各自独立的 hook 状态', async () => {
const db = { query: jest.fn().mockResolvedValue([]) } as any;
const hook1 = useQuery(db, 'SELECT * FROM a');
const hook2 = useQuery(db, 'SELECT * FROM b');
await flushEffects();
expect(hook1).not.toBe(hook2);
expect(db.query).toHaveBeenCalledWith('SELECT * FROM a');
expect(db.query).toHaveBeenCalledWith('SELECT * FROM b');
});
});
describe('useTable', () => {
test('合法表名执行全表查询', async () => {
const db = { query: jest.fn().mockResolvedValue([{ id: 1 }]) } as any;
const mount = () => useTable(db, 'users');
render(mount);
await flushEffects();
const after = render(mount);
expect(db.query).toHaveBeenCalledWith('SELECT * FROM users');
expect(after.data).toEqual([{ id: 1 }]);
expect(after.loading).toBe(false);
});
test('非法表名抛出校验错误(SQL 注入防护)', () => {
const db = { query: jest.fn() } as any;
expect(() => useTable(db, 'users; DROP TABLE orders')).toThrow(/Invalid table name/);
expect(() => useTable(db, "users' OR '1'='1")).toThrow(/Invalid table name/);
expect(() => useTable(db, '1users')).toThrow(/Invalid table name/);
expect(db.query).not.toHaveBeenCalled();
});
});
describe('useDatabase', () => {
test('创建数据库实例并初始化', async () => {
const initSpy = jest.spyOn(MetonaSqlark.prototype, 'init').mockResolvedValue();
const closeSpy = jest.spyOn(MetonaSqlark.prototype, 'close').mockResolvedValue();
const mount = () => useDatabase({ name: 'hook-test' });
const hook = render(mount);
expect(hook.ready).toBe(false);
await flushEffects();
const after = render(mount);
expect(initSpy).toHaveBeenCalled();
expect(after.db).toBeInstanceOf(MetonaSqlark);
expect(after.ready).toBe(true);
expect(after.error).toBeNull();
initSpy.mockRestore();
closeSpy.mockRestore();
});
test('初始化失败时设置 error', async () => {
const initSpy = jest.spyOn(MetonaSqlark.prototype, 'init').mockRejectedValue(new Error('init fail'));
const mount = () => useDatabase({ name: 'hook-fail' });
render(mount);
await flushEffects();
const after = render(mount);
expect(after.db).toBeNull();
expect(after.ready).toBe(false);
expect(after.error).toBeInstanceOf(Error);
expect((after.error as Error).message).toBe('init fail');
initSpy.mockRestore();
});
});
+167
View File
@@ -0,0 +1,167 @@
/**
* Vue composables v0.2.6
* @module tests/integrations/vue
*
* vue peer dependency
* 使 Vue mock composables
* - onMounted
* - watch sql/deps
* - refresh
* - SQL
*/
import 'fake-indexeddb/auto';
// ---- 最小 Vue mock(自包含:jest.mock 工厂不能引用外部变量) ----
jest.mock('vue', () => {
const mountQueue: Array<() => void | Promise<void>> = [];
const watchList: Array<{ sources: any[]; cb: () => void | Promise<void> }> = [];
return {
ref: (init: any) => {
const box: { value: any } = { value: init };
return box;
},
watch: (sources: any[], cb: any) => {
watchList.push({ sources, cb });
},
onMounted: (fn: any) => {
mountQueue.push(fn);
},
__mockMounted: mountQueue,
__mockWatch: watchList,
__mockReset: () => {
mountQueue.length = 0;
watchList.length = 0;
},
};
}, { virtual: true });
import { useSqlarkQuery, useSqlarkTable, useSqlarkDatabase } from '../../src/integrations/vue';
/** mock 模块内的挂载/监听队列(自包含作用域) */
const vueMock = jest.requireMock('vue') as {
__mockMounted: Array<() => void | Promise<void>>;
__mockWatch: Array<{ sources: any[]; cb: () => void | Promise<void> }>;
__mockReset: () => void;
};
beforeEach(() => {
vueMock.__mockReset();
});
/** 模拟组件挂载:执行 onMounted 注册的回调 */
async function flushMounted(): Promise<void> {
const fns = vueMock.__mockMounted.splice(0);
for (const fn of fns) {
await fn();
}
}
/** 触发 watch 回调 */
async function flushWatch(): Promise<void> {
const pairs = vueMock.__mockWatch.splice(0);
for (const { cb } of pairs) {
await cb();
}
}
/** 构造最小响应式引用(与 vue.mock 的 ref 等价) */
function makeRef<T>(init: T): { value: T } {
return { value: init };
}
describe('useSqlarkQuery', () => {
test('onMounted 时执行 SQL 查询并更新响应式 data', async () => {
const db = { query: jest.fn().mockResolvedValue([{ id: '1' }]) } as any;
const hook = useSqlarkQuery(db, 'SELECT * FROM users');
expect(hook.loading.value).toBe(true);
expect(db.query).not.toHaveBeenCalled();
await flushMounted();
expect(db.query).toHaveBeenCalledWith('SELECT * FROM users');
expect(hook.loading.value).toBe(false);
expect(hook.data.value).toEqual([{ id: '1' }]);
expect(hook.error.value).toBeNull();
});
test('查询失败时设置 error', async () => {
const db = { query: jest.fn().mockRejectedValue(new Error('vue boom')) } as any;
const hook = useSqlarkQuery(db, 'SELECT * FROM users');
await flushMounted();
expect(hook.error.value).toBeInstanceOf(Error);
expect((hook.error.value as Error).message).toBe('vue boom');
expect(hook.data.value).toEqual([]);
});
test('refresh 重新执行查询', async () => {
let call = 0;
const db = { query: jest.fn().mockImplementation(async () => [{ n: ++call }]) } as any;
const hook = useSqlarkQuery(db, 'SELECT * FROM users');
await flushMounted();
expect(hook.data.value).toEqual([{ n: 1 }]);
await hook.refresh();
expect(db.query).toHaveBeenCalledTimes(2);
expect(hook.data.value).toEqual([{ n: 2 }]);
});
test('watch 注册在 sql 与 deps 上', async () => {
const db = { query: jest.fn().mockResolvedValue([]) } as any;
const sqlRef = makeRef('SELECT * FROM users');
useSqlarkQuery(db, sqlRef.value, [sqlRef]);
expect(vueMock.__mockWatch).toHaveLength(1);
expect(vueMock.__mockWatch[0].sources).toHaveLength(2); // [() => sql, ...deps]
// 模拟依赖变化触发 watch
sqlRef.value = 'SELECT * FROM orders';
await flushWatch();
expect(db.query).toHaveBeenCalledWith('SELECT * FROM users');
});
});
describe('useSqlarkTable', () => {
test('合法表名执行全表查询', async () => {
const db = { query: jest.fn().mockResolvedValue([{ id: 1 }]) } as any;
const hook = useSqlarkTable(db, 'users');
await flushMounted();
expect(db.query).toHaveBeenCalledWith('SELECT * FROM users');
expect(hook.data.value).toEqual([{ id: 1 }]);
});
test('非法表名抛出校验错误(SQL 注入防护)', () => {
const db = { query: jest.fn() } as any;
expect(() => useSqlarkTable(db, 'users; DELETE FROM orders')).toThrow(/Invalid table name/);
expect(() => useSqlarkTable(db, 'users--')).toThrow(/Invalid table name/);
expect(db.query).not.toHaveBeenCalled();
});
});
describe('useSqlarkDatabase', () => {
test('onMounted 创建并初始化数据库实例', async () => {
const hook = useSqlarkDatabase({ name: 'vue-hook' });
expect(hook.ready.value).toBe(false);
await flushMounted();
expect(hook.db.value).not.toBeNull();
expect(hook.ready.value).toBe(true);
expect(hook.error.value).toBeNull();
});
test('初始化失败时设置 error', async () => {
// 使用非法配置触发初始化错误(mode 未知)
const hook = useSqlarkDatabase({ name: 'vue-fail', mode: 'unknown-mode' as any });
await flushMounted();
expect(hook.db.value).toBeNull();
expect(hook.ready.value).toBe(false);
expect(hook.error.value).toBeInstanceOf(Error);
});
});
+372
View File
@@ -0,0 +1,372 @@
/**
* v0.3.0 SQL
* @module tests/sql-ext
*
* / / INSERT...SELECT / UNION / CREATE INDEX / EXISTS
*/
import 'fake-indexeddb/auto';
import { MetonaSqlark } from '../src/core';
import '../src/connection-manager';
import { parse, parseAll } from '../src/sql/parser';
async function createDb(mode: 'memory' | 'disk' | 'aria' = 'memory') {
const db = new MetonaSqlark({ name: `sql-ext-${mode}-${Date.now()}-${Math.random()}`, mode, diskEngine: 'indexeddb' });
await db.init();
await db.defineTable('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
age: { type: 'number' },
city: { type: 'string' },
});
await db.defineTable('orders', {
id: { type: 'string', primaryKey: true },
user_id: { type: 'string' },
amount: { type: 'number' },
});
await db.query(`INSERT INTO users VALUES ('u1', 'Alice', 30, 'Beijing')`);
await db.query(`INSERT INTO users VALUES ('u2', 'Bob', 25, 'Shanghai')`);
await db.query(`INSERT INTO users VALUES ('u3', 'Carol', 35, 'Beijing')`);
await db.query(`INSERT INTO users VALUES ('u4', 'Dave', 28, 'Shenzhen')`);
await db.query(`INSERT INTO orders VALUES ('o1', 'u1', 100)`);
await db.query(`INSERT INTO orders VALUES ('o2', 'u1', 200)`);
await db.query(`INSERT INTO orders VALUES ('o3', 'u2', 50)`);
return db;
}
// ===================================================================
// 多语句 parseAll
// ===================================================================
describe('[v0.3.0] 多语句支持', () => {
test('parseAll 解析分号分隔的多条语句', () => {
const stmts = parseAll('SELECT * FROM a; INSERT INTO b VALUES (1); DELETE FROM c WHERE id = 1');
expect(stmts).toHaveLength(3);
expect(stmts[0].type).toBe('SELECT');
expect(stmts[1].type).toBe('INSERT');
expect(stmts[2].type).toBe('DELETE');
});
test('parseAll 忽略多余分号与尾部空语句', () => {
const stmts = parseAll(';;SELECT * FROM a;;;');
expect(stmts).toHaveLength(1);
expect(stmts[0].type).toBe('SELECT');
});
test('parseAll 支持事务语句', () => {
const stmts = parseAll('BEGIN; INSERT INTO a VALUES (1); COMMIT');
expect(stmts.map((s) => s.type)).toEqual(['BEGIN', 'INSERT', 'COMMIT']);
});
test('parse 保持单语句兼容', () => {
expect(parse('SELECT * FROM a').type).toBe('SELECT');
});
test('parseAll 语句间缺分号报错', () => {
expect(() => parseAll('SELECT * FROM a SELECT * FROM b')).toThrow(/Expected ';'/);
});
test('core.query 顺序执行多语句', async () => {
const db = await createDb();
await db.query('CREATE TABLE logs (id STRING PRIMARY KEY, msg STRING); INSERT INTO logs VALUES (\'l1\', \'hello\'); INSERT INTO logs VALUES (\'l2\', \'world\')');
const rows = await db.query('SELECT * FROM logs') as Record<string, unknown>[];
expect(rows).toHaveLength(2);
await db.close();
});
});
// ===================================================================
// 事务语句 BEGIN / COMMIT / ROLLBACK
// ===================================================================
describe('[v0.3.0] 事务语句', () => {
test('BEGIN + INSERT + COMMIT 持久化', async () => {
const db = await createDb();
await db.query('BEGIN');
await db.query(`INSERT INTO users VALUES ('u5', 'Eve', 22, 'Beijing')`);
const visible = await db.query('SELECT * FROM users') as Record<string, unknown>[];
expect(visible).toHaveLength(5); // 事务内可见
await db.query('COMMIT');
const after = await db.query('SELECT * FROM users') as Record<string, unknown>[];
expect(after).toHaveLength(5);
await db.close();
});
test('BEGIN + INSERT + ROLLBACK 回滚', async () => {
const db = await createDb();
await db.query('BEGIN');
await db.query(`INSERT INTO users VALUES ('u5', 'Eve', 22, 'Beijing')`);
await db.query('ROLLBACK');
const after = await db.query('SELECT * FROM users') as Record<string, unknown>[];
expect(after).toHaveLength(4);
await db.close();
});
test('BEGIN 嵌套报错', async () => {
const db = await createDb();
await db.query('BEGIN');
await expect(db.query('BEGIN')).rejects.toThrow(/TX_ACTIVE|Transaction already/);
await db.query('ROLLBACK');
await db.close();
});
test('无事务时 COMMIT 报错', async () => {
const db = await createDb();
await expect(db.query('COMMIT')).rejects.toThrow(/TX_NONE|No active transaction/);
await db.close();
});
test('事务语句在 Aria 引擎上可用', async () => {
const db = await createDb('aria');
await db.query('BEGIN');
await db.query(`INSERT INTO users VALUES ('u5', 'Eve', 22, 'Beijing')`);
await db.query('ROLLBACK');
const after = await db.query('SELECT * FROM users') as Record<string, unknown>[];
expect(after).toHaveLength(4);
await db.close();
});
});
// ===================================================================
// INSERT INTO ... SELECT
// ===================================================================
describe('[v0.3.0] INSERT INTO ... SELECT', () => {
test('INSERT SELECT 全列复制', async () => {
const db = await createDb();
await db.defineTable('users_backup', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
age: { type: 'number' },
city: { type: 'string' },
});
await db.query('INSERT INTO users_backup SELECT * FROM users');
const rows = await db.query('SELECT * FROM users_backup') as Record<string, unknown>[];
expect(rows).toHaveLength(4);
expect(rows[0].name).toBe('Alice');
await db.close();
});
test('INSERT SELECT 带 WHERE 过滤', async () => {
const db = await createDb();
await db.defineTable('beijing_users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
age: { type: 'number' },
city: { type: 'string' },
});
await db.query(`INSERT INTO beijing_users SELECT * FROM users WHERE city = 'Beijing'`);
const rows = await db.query('SELECT * FROM beijing_users') as Record<string, unknown>[];
expect(rows).toHaveLength(2);
await db.close();
});
test('INSERT SELECT 指定列映射', async () => {
const db = await createDb();
await db.defineTable('names', { id: { type: 'string', primaryKey: true }, n: { type: 'string' } });
await db.query('INSERT INTO names (id, n) SELECT id, name FROM users');
const rows = await db.query('SELECT * FROM names') as Record<string, unknown>[];
expect(rows).toHaveLength(4);
expect(rows[0].n).toBe('Alice');
await db.close();
});
test('INSERT SELECT 语法解析', () => {
const stmt = parse('INSERT INTO a (x, y) SELECT p, q FROM b WHERE r > 1') as any;
expect(stmt.type).toBe('INSERT');
expect(stmt.select).toBeDefined();
expect(stmt.select.type).toBe('SELECT');
expect(stmt.columns).toEqual(['x', 'y']);
});
});
// ===================================================================
// UNION / UNION ALL
// ===================================================================
describe('[v0.3.0] UNION / UNION ALL', () => {
test('UNION 去重合并', async () => {
const db = await createDb();
const rows = await db.query(`SELECT name FROM users WHERE city = 'Beijing' UNION SELECT name FROM users WHERE age < 30`) as Record<string, unknown>[];
// Beijing: Alice, CarolAlice 同时 age30 不重复);age<30: Bob, Dave
expect(rows).toHaveLength(4);
await db.close();
});
test('UNION ALL 不去重', async () => {
const db = await createDb();
const rows = await db.query(`SELECT city FROM users WHERE city = 'Beijing' UNION ALL SELECT city FROM users WHERE city = 'Beijing'`) as Record<string, unknown>[];
expect(rows).toHaveLength(4); // 2 + 2
await db.close();
});
test('UNION 对相同行去重', async () => {
const db = await createDb();
const rows = await db.query(`SELECT city FROM users WHERE city = 'Beijing' UNION SELECT city FROM users WHERE city = 'Beijing'`) as Record<string, unknown>[];
// 左右各 2 行同值 → 去重后 1 行
expect(rows).toHaveLength(1);
expect(rows[0].city).toBe('Beijing');
await db.close();
});
test('UNION 链式(三表合并)', async () => {
const db = await createDb();
await db.query(`INSERT INTO users VALUES ('u5', 'Eve', 22, 'Beijing')`);
const rows = await db.query(`SELECT city FROM users WHERE city = 'Beijing' UNION SELECT city FROM users WHERE city = 'Shanghai' UNION SELECT city FROM users WHERE city = 'Beijing'`) as Record<string, unknown>[];
// Beijing(3) + Shanghai(1) + Beijing(3 重复去重) → 2 个城市
expect(rows).toHaveLength(2);
await db.close();
});
test('UNION 语法解析', () => {
const stmt = parse('SELECT a FROM t1 UNION SELECT b FROM t2') as any;
expect(stmt.type).toBe('SELECT_UNION');
expect(stmt.all).toBeUndefined();
const stmtAll = parse('SELECT a FROM t1 UNION ALL SELECT b FROM t2') as any;
expect(stmtAll.type).toBe('SELECT_UNION');
expect(stmtAll.all).toBe(true);
});
test('UNION 在 Aria 引擎上可用', async () => {
const db = await createDb('aria');
const rows = await db.query(`SELECT city FROM users WHERE city = 'Beijing' UNION SELECT city FROM users WHERE city = 'Beijing'`) as Record<string, unknown>[];
expect(rows).toHaveLength(1);
await db.close();
});
});
// ===================================================================
// CREATE INDEX / DROP INDEX
// ===================================================================
describe('[v0.3.0] CREATE INDEX / DROP INDEX', () => {
test('CREATE INDEX 后索引查询可用(Memory', async () => {
const db = await createDb();
await db.query('CREATE INDEX idx_users_city ON users (city)');
const rows = await db.query(`SELECT * FROM users WHERE city = 'Beijing'`) as Record<string, unknown>[];
expect(rows).toHaveLength(2);
await db.close();
});
test('CREATE INDEX 后索引查询可用(Aria', async () => {
const db = await createDb('aria');
await db.query('CREATE INDEX idx_users_city ON users (city)');
const rows = await db.query(`SELECT * FROM users WHERE city = 'Beijing'`) as Record<string, unknown>[];
expect(rows).toHaveLength(2);
await db.close();
});
test('CREATE INDEX 对已有数据立即生效(索引填充)', async () => {
const db = await createDb();
await db.query('INSERT INTO users VALUES (\'u5\', \'Eve\', 22, \'Beijing\')');
await db.query('CREATE INDEX idx_users_age ON users (age)');
const rows = await db.query(`SELECT * FROM users WHERE age = 22`) as Record<string, unknown>[];
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe('u5');
await db.close();
});
test('DROP INDEX 后查询回退全表扫描', async () => {
const db = await createDb();
await db.query('CREATE INDEX idx_users_city ON users (city)');
await db.query('DROP INDEX idx_users_city ON users (city)');
const rows = await db.query(`SELECT * FROM users WHERE city = 'Beijing'`) as Record<string, unknown>[];
expect(rows).toHaveLength(2);
await db.close();
});
test('CREATE INDEX 重复创建幂等', async () => {
const db = await createDb();
await db.query('CREATE INDEX idx_users_city ON users (city)');
await db.query('CREATE INDEX idx_users_city ON users (city)'); // 不抛错
const rows = await db.query(`SELECT * FROM users WHERE city = 'Beijing'`) as Record<string, unknown>[];
expect(rows).toHaveLength(2);
await db.close();
});
test('CREATE INDEX 不存在列报错', async () => {
const db = await createDb();
const err = await db.query('CREATE INDEX idx_bad ON users (nonexistent)').catch((e: any) => e);
expect(err).toBeDefined();
expect(err.code).toBe('COLUMN_NOT_FOUND');
await db.close();
});
test('CREATE INDEX 后插入新数据索引同步更新', async () => {
const db = await createDb();
await db.query('CREATE INDEX idx_users_city ON users (city)');
await db.query('INSERT INTO users VALUES (\'u5\', \'Eve\', 22, \'Beijing\')');
const rows = await db.query(`SELECT * FROM users WHERE city = 'Beijing'`) as Record<string, unknown>[];
expect(rows).toHaveLength(3);
await db.close();
});
test('DROP INDEX Aria 主键索引受保护', async () => {
const db = await createDb('aria');
await expect(db.query('DROP INDEX pk ON users (id)')).rejects.toThrow(/Cannot drop primary key/);
await db.close();
});
test('CREATE UNIQUE INDEX 语法解析', () => {
const stmt = parse('CREATE UNIQUE INDEX idx_u ON users (email)') as any;
expect(stmt.type).toBe('CREATE_INDEX');
expect(stmt.unique).toBe(true);
expect(stmt.column).toBe('email');
});
test('CREATE INDEX / DROP INDEX 语法解析', () => {
const stmt = parse('CREATE INDEX idx_c ON users (city)') as any;
expect(stmt).toMatchObject({ type: 'CREATE_INDEX', name: 'idx_c', table: 'users', column: 'city' });
const drop = parse('DROP INDEX idx_c ON users (city)') as any;
expect(drop).toMatchObject({ type: 'DROP_INDEX', name: 'idx_c', table: 'users', column: 'city' });
});
});
// ===================================================================
// EXISTS / NOT EXISTS
// ===================================================================
describe('[v0.3.0] EXISTS / NOT EXISTS', () => {
test('EXISTS 子查询为真返回行', async () => {
const db = await createDb();
const rows = await db.query(`SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)`) as Record<string, unknown>[];
// 有订单的用户:u1, u2
expect(rows).toHaveLength(2);
expect(rows.map((r) => r.id).sort()).toEqual(['u1', 'u2']);
await db.close();
});
test('NOT EXISTS 返回无关联行', async () => {
const db = await createDb();
const rows = await db.query(`SELECT * FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)`) as Record<string, unknown>[];
// 无订单用户:u3, u4
expect(rows).toHaveLength(2);
expect(rows.map((r) => r.id).sort()).toEqual(['u3', 'u4']);
await db.close();
});
test('EXISTS 与 AND 组合', async () => {
const db = await createDb();
const rows = await db.query(`SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.amount > 150) AND u.city = 'Beijing'`) as Record<string, unknown>[];
// o2 金额 200 > 150 属于 u1Beijing
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe('u1');
await db.close();
});
test('EXISTS 语法解析', () => {
const stmt = parse(`SELECT * FROM users WHERE EXISTS (SELECT 1 FROM orders)`) as any;
expect(stmt.where.$exists).toBeDefined();
expect(stmt.where.$exists.$subquery.type).toBe('SELECT');
const notStmt = parse(`SELECT * FROM users WHERE NOT EXISTS (SELECT 1 FROM orders)`) as any;
expect(notStmt.where.$exists).toBeDefined();
expect(notStmt.where.$exists.$negate).toBe(true);
});
test('EXISTS 在 Aria 引擎上可用', async () => {
const db = await createDb('aria');
const rows = await db.query(`SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)`) as Record<string, unknown>[];
expect(rows).toHaveLength(2);
await db.close();
});
});
+313
View File
@@ -0,0 +1,313 @@
/**
* v0.3.1
* @module tests/sql-ext2
*
* CASE WHEN / JOIN + / WAL
*/
import 'fake-indexeddb/auto';
import { MetonaSqlark } from '../src/core';
import { parse } from '../src/sql/parser';
import { WAL, type WALStore } from '../src/engine/aria/wal/log';
import { WALRecordType } from '../src/engine/aria/types';
async function createDb(mode: 'memory' | 'aria' = 'memory') {
const db = new MetonaSqlark({ name: `sql-ext2-${mode}-${Date.now()}-${Math.random()}`, mode, diskEngine: 'indexeddb' });
await db.init();
await db.defineTable('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
age: { type: 'number' },
city: { type: 'string' },
});
await db.defineTable('orders', {
id: { type: 'string', primaryKey: true },
user_id: { type: 'string' },
amount: { type: 'number' },
});
await db.query(`INSERT INTO users VALUES ('u1', 'Alice', 30, 'Beijing')`);
await db.query(`INSERT INTO users VALUES ('u2', 'Bob', 17, 'Shanghai')`);
await db.query(`INSERT INTO users VALUES ('u3', 'Carol', 42, 'Beijing')`);
await db.query(`INSERT INTO orders VALUES ('o1', 'u1', 100)`);
await db.query(`INSERT INTO orders VALUES ('o2', 'u1', 200)`);
await db.query(`INSERT INTO orders VALUES ('o3', 'u2', 50)`);
return db;
}
// ===================================================================
// CASE WHEN
// ===================================================================
describe('[v0.3.1] CASE WHEN', () => {
test('基本 CASE WHEN(单条件 + ELSE', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT name, CASE WHEN age >= 18 THEN 'adult' ELSE 'minor' END AS status FROM users`,
) as Record<string, unknown>[];
expect(rows).toHaveLength(3);
const byName = Object.fromEntries(rows.map((r) => [r.name, r]));
expect(byName['Alice'].status).toBe('adult');
expect(byName['Bob'].status).toBe('minor');
expect(byName['Carol'].status).toBe('adult');
await db.close();
});
test('多 WHEN 分支按顺序匹配', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT name, CASE WHEN age < 18 THEN 'teen' WHEN age < 40 THEN 'adult' ELSE 'senior' END AS age_group FROM users`,
) as Record<string, unknown>[];
const byName = Object.fromEntries(rows.map((r) => [r.name, r]));
expect(byName['Alice'].age_group).toBe('adult');
expect(byName['Bob'].age_group).toBe('teen');
expect(byName['Carol'].age_group).toBe('senior');
await db.close();
});
test('THEN 值为列引用', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT CASE WHEN age >= 18 THEN city ELSE 'underage' END AS location FROM users`,
) as Record<string, unknown>[];
const cities = rows.map((r) => r.location);
expect(cities).toContain('Beijing');
expect(cities).toContain('underage');
await db.close();
});
test('无 ELSE 时返回 null', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT name, CASE WHEN age >= 40 THEN 'senior' END AS tag FROM users`,
) as Record<string, unknown>[];
const byName = Object.fromEntries(rows.map((r) => [r.name, r]));
expect(byName['Carol'].tag).toBe('senior');
expect(byName['Alice'].tag).toBeNull();
await db.close();
});
test('字面量:数字 / 布尔 / 字符串', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT name, CASE WHEN age > 20 THEN 1 ELSE 0 END AS flag, CASE WHEN city = 'Beijing' THEN true ELSE false END AS is_bj FROM users`,
) as Record<string, unknown>[];
const byName = Object.fromEntries(rows.map((r) => [r.name, r]));
expect(byName['Alice'].flag).toBe(1);
expect(byName['Bob'].flag).toBe(0);
expect(byName['Alice'].is_bj).toBe(true);
expect(byName['Bob'].is_bj).toBe(false);
await db.close();
});
test('多条件组合(AND/OR', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT name, CASE WHEN age >= 18 AND city = 'Beijing' THEN 'local adult' ELSE 'other' END AS label FROM users`,
) as Record<string, unknown>[];
const byName = Object.fromEntries(rows.map((r) => [r.name, r]));
expect(byName['Alice'].label).toBe('local adult');
expect(byName['Carol'].label).toBe('local adult');
expect(byName['Bob'].label).toBe('other');
await db.close();
});
test('语法解析:CASE 列被解析为原文', () => {
const stmt = parse(`SELECT name, CASE WHEN age > 18 THEN 'x' ELSE 'y' END AS s FROM users`) as any;
expect(stmt.columns[1]).toMatch(/^CASE WHEN age > 18 THEN 'x' ELSE 'y' END AS s$/);
});
test('与普通列混合投影', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT name, age, CASE WHEN age >= 18 THEN 'ok' ELSE 'no' END AS adult FROM users`,
) as Record<string, unknown>[];
expect(rows[0].name).toBeDefined();
expect(rows[0].age).toBeDefined();
expect(rows[0].adult).toBeDefined();
expect(Object.keys(rows[0])).toEqual(expect.arrayContaining(['name', 'age', 'adult']));
await db.close();
});
test('Aria 引擎可用', async () => {
const db = await createDb('aria');
const rows = await db.query(
`SELECT name, CASE WHEN age >= 18 THEN 'adult' ELSE 'minor' END AS status FROM users`,
) as Record<string, unknown>[];
expect(rows).toHaveLength(3);
await db.close();
});
});
// ===================================================================
// JOIN + 关联子查询
// ===================================================================
describe('[v0.3.1] JOIN + 关联子查询', () => {
test('JOIN 结果上执行 EXISTS 关联过滤', async () => {
const db = await createDb();
// 有高额订单(>150)的用户
const rows = await db.query(
`SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id WHERE EXISTS (SELECT 1 FROM orders o2 WHERE o2.user_id = u.id AND o2.amount > 150)`,
) as Record<string, unknown>[];
// 只有 u1 有 200 元订单
expect(rows).toHaveLength(2); // JOIN 展开 2 行(u1 有 2 个订单)
expect(rows.every((r) => r['u.name'] === 'Alice')).toBe(true);
await db.close();
});
test('JOIN + NOT EXISTS 排除关联行', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT DISTINCT u.name FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE NOT EXISTS (SELECT 1 FROM orders o2 WHERE o2.user_id = u.id)`,
) as Record<string, unknown>[];
// 无订单用户:u3 (Carol)JOIN 列键带表名前缀)
expect(rows.map((r) => r['u.name'])).toEqual(['Carol']);
await db.close();
});
test('JOIN + 关联子查询 + 普通条件组合', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id WHERE EXISTS (SELECT 1 FROM orders o2 WHERE o2.user_id = u.id) AND o.amount > 50`,
) as Record<string, unknown>[];
// 有订单且订单 >50u1 的 o1(100), o2(200) + u2 的 o3(50 不满足)
expect(rows).toHaveLength(2);
expect(rows.every((r) => r['u.name'] === 'Alice')).toBe(true);
await db.close();
});
test('JOIN 子查询中的 $col 引用绑定外层行', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT o.id, o.amount FROM orders o JOIN users u ON u.id = o.user_id WHERE EXISTS (SELECT 1 FROM orders o2 WHERE o2.amount > o.amount)`,
) as Record<string, unknown>[];
// 存在比自身金额更大的订单:o1(100) < o2(200) → o1 满足;o2 无更大;o3(50) < o1/o2 → 满足
const ids = rows.map((r) => r['o.id']).sort();
expect(ids).toEqual(['o1', 'o3']);
await db.close();
});
test('Aria 引擎 JOIN + EXISTS 可用', async () => {
const db = await createDb('aria');
const rows = await db.query(
`SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id WHERE EXISTS (SELECT 1 FROM orders o2 WHERE o2.user_id = u.id AND o2.amount > 150)`,
) as Record<string, unknown>[];
expect(rows).toHaveLength(2);
await db.close();
});
});
// ===================================================================
// WAL 批量组提交
// ===================================================================
describe('[v0.3.1] WAL 批量组提交', () => {
class MockWALStore implements WALStore {
chunks: Uint8Array[] = [];
appendCount = 0;
async append(data: Uint8Array) { this.chunks.push(data); this.appendCount++; }
async readAll(): Promise<Uint8Array> {
const total = this.chunks.reduce((s, c) => s + c.byteLength, 0);
const combined = new Uint8Array(total);
let off = 0;
for (const c of this.chunks) { combined.set(c, off); off += c.byteLength; }
return combined;
}
async truncate() { this.chunks = []; this.appendCount = 0; }
async exists() { return this.chunks.length > 0; }
}
test('appendBatch 合并为一次底层写入', async () => {
const store = new MockWALStore();
const wal = new WAL(store, true, 'full');
await wal.appendBatch([
{ type: WALRecordType.INSERT, txnId: 0, tableName: 't', key: '1', data: { v: 1 } },
{ type: WALRecordType.INSERT, txnId: 0, tableName: 't', key: '2', data: { v: 2 } },
{ type: WALRecordType.INSERT, txnId: 0, tableName: 't', key: '3', data: { v: 3 } },
]);
expect(store.appendCount).toBe(1); // 3 条记录 1 次写入
});
test('appendBatch 记录可恢复', async () => {
const store = new MockWALStore();
const wal = new WAL(store, true, 'full');
await wal.appendBatch([
{ type: WALRecordType.INSERT, txnId: 0, tableName: 'users', key: 'a', data: { n: 1 } },
{ type: WALRecordType.INSERT, txnId: 0, tableName: 'users', key: 'b', data: { n: 2 } },
]);
const records: { tableName: string; key: string }[] = [];
await wal.recover((r) => records.push(r));
expect(records).toHaveLength(2);
expect(records[0].key).toBe('a');
expect(records[1].key).toBe('b');
});
test('batch 模式 appendBatch 缓冲后 flush', async () => {
const store = new MockWALStore();
const wal = new WAL(store, true, 'batch');
await wal.appendBatch([
{ type: WALRecordType.INSERT, txnId: 0, tableName: 't', key: '1', data: {} },
{ type: WALRecordType.INSERT, txnId: 0, tableName: 't', key: '2', data: {} },
]);
expect(store.appendCount).toBe(0); // 缓冲未落盘
await wal.flush();
expect(store.appendCount).toBe(1);
});
test('append 与 appendBatch 共存', async () => {
const store = new MockWALStore();
const wal = new WAL(store, true, 'full');
await wal.append({ type: WALRecordType.BEGIN, txnId: 7, tableName: '', key: '' });
await wal.appendBatch([
{ type: WALRecordType.INSERT, txnId: 7, tableName: 't', key: '1', data: {} },
{ type: WALRecordType.INSERT, txnId: 7, tableName: 't', key: '2', data: {} },
]);
await wal.append({ type: WALRecordType.COMMIT, txnId: 7, tableName: '', key: '' });
expect(store.appendCount).toBe(3); // BEGIN + 批量(1) + COMMIT
const records: number[] = [];
await wal.recover((r) => records.push(r.type));
expect(records).toEqual([
WALRecordType.BEGIN,
WALRecordType.INSERT,
WALRecordType.INSERT,
WALRecordType.COMMIT,
]);
});
test('引擎 insert 批量写入只触发一次 WAL 落盘', async () => {
const db = new MetonaSqlark({ name: `wal-batch-${Date.now()}`, mode: 'aria', diskEngine: 'memory' });
await db.init();
await db.defineTable('users', { id: { type: 'string', primaryKey: true }, name: { type: 'string' } });
const engine = db.getEngine() as any;
let before = 0;
const origRead = engine.backend.read.bind(engine.backend);
// 统计 __wal_ 写入次数
const origWrite = engine.backend.write.bind(engine.backend);
let walWrites = 0;
engine.backend.write = async (key: string, data: ArrayBuffer) => {
if (key.startsWith('__wal_') && !key.startsWith('__wal_count')) walWrites++;
return origWrite(key, data);
};
void before; void origRead;
await db.table('users').insertMany([
{ id: '1', name: 'A' },
{ id: '2', name: 'B' },
{ id: '3', name: 'C' },
{ id: '4', name: 'D' },
]);
expect(walWrites).toBe(1); // 4 行 1 次 WAL 写入
const rows = await db.query('SELECT * FROM users') as Record<string, unknown>[];
expect(rows).toHaveLength(4);
await db.close();
});
});
+406
View File
@@ -0,0 +1,406 @@
/**
* v0.3.2
* @module tests/sql-ext3
*
* CASE WHEN WHERE/ / JOIN /
*/
import 'fake-indexeddb/auto';
import { MetonaSqlark } from '../src/core';
async function createDb(mode: 'memory' | 'hybrid' = 'memory', extra: Record<string, unknown> = {}) {
const db = new MetonaSqlark({
name: `sql-ext3-${mode}-${Date.now()}-${Math.random()}`,
mode,
diskEngine: 'indexeddb',
...extra,
});
await db.init();
await db.defineTable('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
age: { type: 'number' },
city: { type: 'string' },
});
await db.defineTable('orders', {
id: { type: 'string', primaryKey: true },
user_id: { type: 'string' },
amount: { type: 'number' },
});
await db.query(`INSERT INTO users VALUES ('u1', 'Alice', 30, 'Beijing')`);
await db.query(`INSERT INTO users VALUES ('u2', 'Bob', 17, 'Shanghai')`);
await db.query(`INSERT INTO users VALUES ('u3', 'Carol', 42, 'Beijing')`);
await db.query(`INSERT INTO orders VALUES ('o1', 'u1', 100)`);
await db.query(`INSERT INTO orders VALUES ('o2', 'u1', 200)`);
await db.query(`INSERT INTO orders VALUES ('o3', 'u2', 50)`);
return db;
}
// ===================================================================
// CASE WHEN 用于 WHERE
// ===================================================================
describe('[v0.3.2] CASE WHEN 用于 WHERE', () => {
test('WHERE CASE 等值比较', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT name FROM users WHERE CASE WHEN age >= 18 THEN 'adult' ELSE 'minor' END = 'adult'`,
) as Record<string, unknown>[];
expect(rows.map((r) => r.name).sort()).toEqual(['Alice', 'Carol']);
await db.close();
});
test('WHERE CASE 与 AND 组合', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT name FROM users WHERE CASE WHEN age >= 18 THEN 'adult' ELSE 'minor' END = 'adult' AND city = 'Beijing'`,
) as Record<string, unknown>[];
expect(rows.map((r) => r.name).sort()).toEqual(['Alice', 'Carol']);
await db.close();
});
test('WHERE CASE 数字比较', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT name FROM users WHERE CASE WHEN city = 'Beijing' THEN 1 ELSE 0 END = 1`,
) as Record<string, unknown>[];
expect(rows.map((r) => r.name).sort()).toEqual(['Alice', 'Carol']);
await db.close();
});
test('WHERE NOT CASE 组合', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT name FROM users WHERE NOT (CASE WHEN age >= 18 THEN 'adult' ELSE 'minor' END = 'adult')`,
) as Record<string, unknown>[];
expect(rows.map((r) => r.name)).toEqual(['Bob']);
await db.close();
});
test('Aria 引擎 WHERE CASE 可用', async () => {
const db = await createDb('hybrid');
const rows = await db.query(
`SELECT name FROM users WHERE CASE WHEN age >= 18 THEN 'adult' ELSE 'minor' END = 'minor'`,
) as Record<string, unknown>[];
expect(rows.map((r) => r.name)).toEqual(['Bob']);
await db.close();
});
});
// ===================================================================
// CASE WHEN 用于聚合
// ===================================================================
describe('[v0.3.2] CASE WHEN 用于聚合', () => {
test('SUM(CASE WHEN...) 条件计数', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT SUM(CASE WHEN age >= 18 THEN 1 ELSE 0 END) AS adults FROM users`,
) as Record<string, unknown>[];
expect(rows[0].adults).toBe(2);
await db.close();
});
test('COUNT(CASE WHEN...) 与 AVG', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT COUNT(CASE WHEN city = 'Beijing' THEN 1 END) AS bj_count, AVG(CASE WHEN age >= 18 THEN age END) AS adult_avg FROM users`,
) as Record<string, unknown>[];
expect(rows[0].bj_count).toBe(2);
expect(rows[0].adult_avg).toBe(36); // (30 + 42) / 2
await db.close();
});
test('GROUP BY + SUM(CASE WHEN...)', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT city, SUM(CASE WHEN age >= 18 THEN 1 ELSE 0 END) AS adults FROM users GROUP BY city`,
) as Record<string, unknown>[];
const byCity = Object.fromEntries(rows.map((r) => [r.city, r.adults]));
expect(byCity['Beijing']).toBe(2); // Alice + Carol
expect(byCity['Shanghai']).toBe(0); // Bob 17 岁
await db.close();
});
test('GROUP BY + CASE 非聚合列', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT city, CASE WHEN age >= 18 THEN 'adult' ELSE 'minor' END AS status FROM users GROUP BY city`,
) as Record<string, unknown>[];
const byCity = Object.fromEntries(rows.map((r) => [r.city, r.status]));
expect(byCity['Beijing']).toBe('adult'); // 组内第一行 Alice
expect(byCity['Shanghai']).toBe('minor');
await db.close();
});
});
// ===================================================================
// JOIN 哈希连接
// ===================================================================
describe('[v0.3.2] JOIN 哈希连接', () => {
test('INNER JOIN 主键等值走哈希连接(结果正确)', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT o.id FROM orders o INNER JOIN users u ON u.id = o.user_id`,
) as Record<string, unknown>[];
// orders 全有匹配用户
expect(rows.map((r) => r['o.id']).sort()).toEqual(['o1', 'o2', 'o3']);
await db.close();
});
test('LEFT JOIN 哈希连接保留未匹配行(null 填充)', async () => {
const db = await createDb();
await db.query(`INSERT INTO users VALUES ('u9', 'Zoe', 20, 'Guangzhou')`);
const rows = await db.query(
`SELECT u.name, o.id FROM users u LEFT JOIN orders o ON o.user_id = u.id`,
) as Record<string, unknown>[];
// Zoe 无订单 → 保留(null 填充);Alice 有 2 个订单 → 展开 2 行(LEFT JOIN 语义)
expect(rows.map((r) => r['u.name']).sort()).toEqual(['Alice', 'Alice', 'Bob', 'Carol', 'Zoe']);
const zoe = rows.find((r) => r['u.name'] === 'Zoe');
expect(zoe).toBeDefined();
expect(zoe!['o.id']).toBeNull();
await db.close();
});
test('INNER JOIN 哈希连接过滤无匹配行', async () => {
const db = await createDb();
await db.query(`INSERT INTO users VALUES ('u9', 'Zoe', 20, 'Guangzhou')`);
const rows = await db.query(
`SELECT u.name FROM users u INNER JOIN orders o ON o.user_id = u.id`,
) as Record<string, unknown>[];
// Alice 2 个订单 → 2 行;Bob 1 行;Carol 无订单被过滤
expect(rows.map((r) => r['u.name']).sort()).toEqual(['Alice', 'Alice', 'Bob']);
await db.close();
});
test('哈希连接一次 $in 查询(不再全表拉取)', async () => {
const db = await createDb();
const engine = db.getEngine() as any;
let rightTableFinds = 0;
const origFind = engine.find.bind(engine);
engine.find = async (table: string, query: any) => {
if (table === 'orders') {
rightTableFinds++;
if (query.where?.user_id?.$in) {
// 哈希连接:$in 一次查询
expect(query.where.user_id.$in).toEqual(expect.arrayContaining(['u1', 'u2']));
}
}
return origFind(table, query);
};
await db.query(`SELECT u.name FROM users u INNER JOIN orders o ON o.user_id = u.id`);
// orders.user_id 无索引 → 哈希回退;仍应恰有一次右表查询
expect(rightTableFinds).toBe(1);
await db.close();
});
test('哈希连接不适用于非索引右列(回退嵌套循环)', async () => {
const db = await createDb();
// orders.user_id 无索引 → 回退;结果仍正确
const rows = await db.query(
`SELECT o.id FROM orders o INNER JOIN users u ON u.id = o.user_id`,
) as Record<string, unknown>[];
expect(rows).toHaveLength(3);
await db.close();
});
});
// ===================================================================
// 多标签页同步
// ===================================================================
describe('[v0.3.2] 多标签页同步', () => {
// BroadcastChannel mock:模拟同源标签页间消息传递
class MockBroadcastChannel {
static instances: MockBroadcastChannel[] = [];
name: string;
onmessage: ((event: { data: unknown }) => void) | null = null;
closed = false;
constructor(name: string) {
this.name = name;
MockBroadcastChannel.instances.push(this);
}
postMessage(data: unknown): void {
if (this.closed) return;
for (const other of MockBroadcastChannel.instances) {
if (other !== this && other.name === this.name && !other.closed && other.onmessage) {
other.onmessage({ data });
}
}
}
close(): void {
this.closed = true;
}
static reset(): void {
MockBroadcastChannel.instances = [];
}
}
const origBC = (globalThis as any).BroadcastChannel;
beforeAll(() => {
(globalThis as any).BroadcastChannel = MockBroadcastChannel;
});
afterAll(() => {
(globalThis as any).BroadcastChannel = origBC;
});
beforeEach(() => {
MockBroadcastChannel.reset();
});
test('SQL 写语句广播表变更,其他标签页订阅收到 external 事件', async () => {
// 先建表(DDL 版本升级会触发其他标签页 onversionchange 关闭连接,故先建表再开第二连接)
const setup = new MetonaSqlark({ name: 'mt-a', mode: 'hybrid', diskEngine: 'indexeddb' });
await setup.init();
await setup.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
await setup.close();
const dbA = new MetonaSqlark({ name: 'mt-a', version: 2, mode: 'hybrid', diskEngine: 'indexeddb', multiTabSync: true });
const dbB = new MetonaSqlark({ name: 'mt-a', version: 2, mode: 'hybrid', diskEngine: 'indexeddb', multiTabSync: true });
await dbA.init();
await dbB.init();
const events: { type: string; table?: string }[] = [];
dbB.subscribe('t', (e) => events.push(e));
await dbA.query(`INSERT INTO t VALUES ('1', 10)`);
// 等待广播送达(同步 mock 已即时)
expect(events.length).toBeGreaterThan(0);
expect(events[0].type).toBe('external');
expect(events[0].table).toBe('t');
await dbA.close();
await dbB.close();
});
test('Hybrid 标签页收到广播后内存重载(读到其他标签页的新数据)', async () => {
const setup = new MetonaSqlark({ name: 'mt-b', mode: 'hybrid', diskEngine: 'indexeddb' });
await setup.init();
await setup.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
await setup.close();
const dbA = new MetonaSqlark({ name: 'mt-b', version: 2, mode: 'hybrid', diskEngine: 'indexeddb', multiTabSync: true });
const dbB = new MetonaSqlark({ name: 'mt-b', version: 2, mode: 'hybrid', diskEngine: 'indexeddb', multiTabSync: true });
await dbA.init();
await dbB.init();
// B 订阅外部变更后等待 reload 完成
let reloadDone: Promise<void> = Promise.resolve();
dbB.subscribe('t', async () => {
reloadDone = reloadDone.then(async () => {
// Hybrid reload 由 onmessage 触发(异步),订阅回调后再等一拍
await new Promise((r) => setTimeout(r, 30));
});
});
await dbA.query(`INSERT INTO t VALUES ('1', 100)`);
await new Promise((r) => setTimeout(r, 80));
const rows = await dbB.query('SELECT * FROM t') as Record<string, unknown>[];
expect(rows).toHaveLength(1);
expect(rows[0].v).toBe(100);
await dbA.close();
await dbB.close();
});
test('未启用 multiTabSync 不广播', async () => {
const setup = new MetonaSqlark({ name: 'mt-c', mode: 'hybrid', diskEngine: 'indexeddb' });
await setup.init();
await setup.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
await setup.close();
const dbA = new MetonaSqlark({ name: 'mt-c', version: 2, mode: 'hybrid', diskEngine: 'indexeddb' });
const dbB = new MetonaSqlark({ name: 'mt-c', version: 2, mode: 'hybrid', diskEngine: 'indexeddb', multiTabSync: true });
await dbA.init();
await dbB.init();
const events: unknown[] = [];
dbB.subscribe('t', (e) => events.push(e));
await dbA.query(`INSERT INTO t VALUES ('1', 10)`);
await new Promise((r) => setTimeout(r, 30));
expect(events).toHaveLength(0); // dbA 未启用 → 无广播
await dbA.close();
await dbB.close();
});
test('Table API 写入也广播', async () => {
const setup = new MetonaSqlark({ name: 'mt-d', mode: 'hybrid', diskEngine: 'indexeddb' });
await setup.init();
await setup.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
await setup.close();
const dbA = new MetonaSqlark({ name: 'mt-d', version: 2, mode: 'hybrid', diskEngine: 'indexeddb', multiTabSync: true });
const dbB = new MetonaSqlark({ name: 'mt-d', version: 2, mode: 'hybrid', diskEngine: 'indexeddb', multiTabSync: true });
await dbA.init();
await dbB.init();
const events: unknown[] = [];
dbB.subscribe('t', (e) => events.push(e));
await dbA.table('t').insert({ id: '1', v: 10 });
expect(events.length).toBeGreaterThan(0);
await dbA.close();
await dbB.close();
});
});
// ===================================================================
// 回归:IndexedDB reopen 后 schema 持久化(v0.3.2 修复)
// ===================================================================
describe('[v0.3.2] IndexedDB reopen schema 持久化', () => {
test('close 后重新 open 表结构与数据完整', async () => {
const setup = new MetonaSqlark({ name: 'reopen-a', mode: 'hybrid', diskEngine: 'indexeddb' });
await setup.init();
await setup.defineTable('t', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
v: { type: 'number' },
});
await setup.query(`INSERT INTO t VALUES ('1', 'Alice', 42)`);
await setup.close();
// 重新打开(模拟页面刷新)
const db = new MetonaSqlark({ name: 'reopen-a', version: 2, mode: 'hybrid', diskEngine: 'indexeddb' });
await db.init();
const schema = await db.getEngine().getTableSchema('t');
expect(schema?.columns.v).toBeDefined(); // 持久化 schema 保留完整列
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
expect(rows).toHaveLength(1);
expect(rows[0].name).toBe('Alice');
expect(rows[0].v).toBe(42);
// 重新打开后仍可写入并校验类型
await db.query(`INSERT INTO t VALUES ('2', 'Bob', 30)`);
await expect(db.query(`INSERT INTO t VALUES ('3', 'Bad', 'not-a-number')`)).rejects.toBeDefined();
await db.close();
});
test('reopen 后 UPDATE 全列生效', async () => {
const setup = new MetonaSqlark({ name: 'reopen-b', mode: 'hybrid', diskEngine: 'indexeddb' });
await setup.init();
await setup.defineTable('t', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
v: { type: 'number' },
});
await setup.query(`INSERT INTO t VALUES ('1', 'Alice', 42)`);
await setup.close();
const db = new MetonaSqlark({ name: 'reopen-b', version: 2, mode: 'hybrid', diskEngine: 'indexeddb' });
await db.init();
await db.query(`UPDATE t SET name = 'Renamed', v = 99 WHERE id = '1'`);
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
expect(rows[0].name).toBe('Renamed');
expect(rows[0].v).toBe(99);
await db.close();
});
});
+6 -19
View File
@@ -23,8 +23,8 @@ import { createSchema } from '../src/table/schema';
// ---------------------------------------------------------------------------
describe('[v0.2.5] P0-1: 版本号统一', () => {
test('VERSION 常量为 0.2.5', () => {
expect(VERSION).toBe('0.2.5');
test('VERSION 常量为当前版本(0.3.2', () => {
expect(VERSION).toBe('0.3.2');
});
});
@@ -220,25 +220,12 @@ describe('[v0.2.5] P1-7: SSTableReader 二分查找', () => {
// ---------------------------------------------------------------------------
// P1-8: SQL 注入防护 — 表名校验
// 真实覆盖位置:tests/integrations/react.test.tsuseTable
// tests/integrations/vue.test.tsuseSqlarkTable
// 通过真实 hooks 调用触发 validateTableName,验证非法表名抛错。
// 注:v0.2.6 移除此处"复制正则自测"的伪测试(未触达真实代码)。
// ---------------------------------------------------------------------------
describe('[v0.2.5] P1-8: SQL 注入防护', () => {
test('表名校验正则表达式正确', () => {
// 验证正则逻辑本身
const validName = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
expect(validName.test('users')).toBe(true);
expect(validName.test('user_table')).toBe(true);
expect(validName.test('_private')).toBe(true);
expect(validName.test('Table1')).toBe(true);
// 非法表名
expect(validName.test('users; DROP TABLE')).toBe(false);
expect(validName.test('1table')).toBe(false);
expect(validName.test('user.name')).toBe(false);
expect(validName.test('user name')).toBe(false);
expect(validName.test("'; DROP TABLE users; --")).toBe(false);
});
});
// ---------------------------------------------------------------------------
// P1-9: crypto 实例化
// ---------------------------------------------------------------------------