release: v0.3.2 — 质量加固 + SQL扩展 + 表达式 + 并发同步
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:
@@ -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 JOIN,O(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 dependency,rollup 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
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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/EXISTS(v0.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 压缩版(~105KB,gzip ~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 |
|
||||
| 存储引擎 | 5(Memory / IndexedDB / OPFS / Hybrid / **Aria**) |
|
||||
|
||||
|
||||
+2047
-582
File diff suppressed because it is too large
Load Diff
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+152
-7
@@ -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):普通列走 projectColumns,CASE 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' store,v0.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 };
|
||||
|
||||
Vendored
+2046
-582
File diff suppressed because it is too large
Load Diff
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2046
-581
File diff suppressed because it is too large
Load Diff
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
@@ -40,6 +40,8 @@ export default [
|
||||
exports: 'named',
|
||||
sourcemap: true,
|
||||
},
|
||||
// react/vue 为 peer dependency(integrations 子路径),不打包
|
||||
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({
|
||||
|
||||
@@ -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
@@ -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 语句... SELECT * FROM users; INSERT INTO users VALUES ('4', 'Diana', 'diana@test.com', 28); 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 语句... SELECT * FROM users; INSERT INTO users VALUES ('4', 'Diana', 'diana@test.com', 28); 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 → ROLLBACK(a3 不会生效)
|
||||
BEGIN;
|
||||
INSERT INTO audit VALUES ('a3', 'will be rolled back');
|
||||
ROLLBACK;
|
||||
SELECT * FROM audit ORDER BY id;
|
||||
|
||||
-- 事务语句:BEGIN → COMMIT(a4 生效)
|
||||
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
@@ -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"><!-- UMD 格式,暴露 window.MetonaSqlark 和 window.MeSqlark --></span>
|
||||
<script src=<span class="s">"https://git.metona.cn/.../metona-sqlark.min.js"</span>></script>
|
||||
<script src=<span class="s">"https://git.metona.cn/MetonaTeam/MetonaSqlark/raw/branch/master/dist/metona-sqlark.min.js"</span>></script>
|
||||
<script>
|
||||
<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
@@ -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-shakable,UMD/ESM/CJS 多格式输出,~10KB gzip。</p>
|
||||
<p>纯 TypeScript 实现,不依赖任何第三方库。Tree-shakable,UMD/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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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. 初始化主 LSM(PK 索引)
|
||||
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 个)
|
||||
|
||||
+180
-67
@@ -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 刷盘为 SSTable(id 由 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
|
||||
// =======================================================================
|
||||
|
||||
/** 同步执行 Compaction(public,供 VACUUM 等外部调用) */
|
||||
compactLevel(level: number): void {
|
||||
this.compactLevelSync(level);
|
||||
/** 执行 Compaction(public,供 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.sstableCache.set(id, 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
/** 耗尽管道,返回所有归并结果 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 的映射
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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 重建 schema(schema 此前只存内存缓存,重开连接即丢失)
|
||||
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' store,v0.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';
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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 类型 & 后端
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* }
|
||||
*/
|
||||
|
||||
import type { MetonaSqlark } from '../core';
|
||||
import { MetonaSqlark } from '../core';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
/** useQuery: 执行 SQL 查询 */
|
||||
|
||||
@@ -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
@@ -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 INDEX(v0.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
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// UNION(v0.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.2):ON 为单一等值条件且右表列为索引/主键时,
|
||||
* 收集左表连接值 → 一次 $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 INDEX(v0.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):普通列走 projectColumns,CASE 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)) {
|
||||
// 顶层 $exists(v0.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;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,16 @@ export function matchWhere(
|
||||
options: { $col?: boolean } = {},
|
||||
): boolean {
|
||||
for (const [field, condition] of Object.entries(where)) {
|
||||
// 顶层 $caseResult(v0.3.2):由 Executor 对 CASE WHEN 表达式逐行求值后产生
|
||||
if (field === '$caseResult') {
|
||||
if (condition !== true) return false;
|
||||
continue;
|
||||
}
|
||||
// 顶层 $exists(v0.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;
|
||||
}
|
||||
// 顶层 $not(v0.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
@@ -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 解析为 boolean,where-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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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([]));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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, Carol(Alice 同时 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 属于 u1(Beijing)
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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>[];
|
||||
// 有订单且订单 >50:u1 的 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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.ts(useTable)
|
||||
// tests/integrations/vue.test.ts(useSqlarkTable)
|
||||
// 通过真实 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 实例化
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user