release: v0.4.1 — Aria 级联/ALTER/clearAll + 流式查询/派生表 + 正确性加固
新增: - AriaEngine 外键级联(CASCADE/SET NULL/RESTRICT)+ clearAll() 重置 API - 引擎级 alterTable:Aria DROP COLUMN 重写存储行 + schema 持久化 - 流式查询 queryStream / findStream(LSM 惰性扫描不物化) - FROM 派生表 / 多列 ON 哈希连接 / COUNT(DISTINCT) / NULLS FIRST/LAST - 普通列别名 + ORDER BY 别名 + 无表查询 + 字符串常量列 - 演示页引擎切换器(Memory/Aria)+ 预设自动重置 修复: - Aria WAL DROP_TABLE 崩溃恢复(删表复活)+ 恢复后 WAL 截断 - Memory update/delete 索引维护(unique 约束绕过) - 关联 EXISTS 绑定失效 / HAVING 标量子查询 / INSERT SELECT 位置错位 - 裸布尔列条件(WHERE done / CASE WHEN done) - Aria $in 重复行 / JOIN 主表 WHERE 下推 / DROP INDEX 报错 - ORDER BY/GROUP BY/SELECT 表前缀列 + SQL '' 标准转义 质量:894 测试 · 47 套件 · 81.5% 覆盖率
This commit is contained in:
@@ -2,6 +2,66 @@
|
|||||||
|
|
||||||
All notable changes to MetonaSqlark will be documented in this file.
|
All notable changes to MetonaSqlark will be documented in this file.
|
||||||
|
|
||||||
|
## [0.4.1] - 2026-08-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **AriaEngine 外键级联删除** — 对齐 Memory 引擎:`CASCADE`(递归多层)、`SET NULL`、`RESTRICT`(抛 `FOREIGN_KEY_VIOLATION`),含二级索引清理、WAL 记录、事务内可提交/回滚、环路保护
|
||||||
|
- **`AriaEngine.clearAll()`** — 重置数据库(清空后端存储/LSM/WAL/MVCC/二级索引),实例可继续使用;供演示页刷新场景与 API 用户使用
|
||||||
|
- **演示页引擎切换器** — 右上角 `⚡ Memory` / `🌲 Aria` 一键切换,Aria 模式每次加载 `clearAll()` 保证演示确定性;aria 预设文案更新(数据库即 Aria)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- 版本号升至 v0.4.1
|
||||||
|
- 测试 880 → **894**(46 → 47 套件)
|
||||||
|
- `AriaEngine` 级联行为与 MemoryEngine 对齐:`SET NULL` 不影响删除返回行数
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **JOIN 主表 WHERE 不下推** — `SELECT ... FROM orders o JOIN users u ... WHERE o.user_id = '1'` 的 WHERE 只在 JOIN 后过滤,二级索引形同虚设。v0.4.1 将主表前缀等值条件下推到引擎(`extractPushableWhere`),索引真正生效
|
||||||
|
- **DROP INDEX 不存在的索引静默成功** — Memory/Aria 引擎 `dropIndex` 对无索引列静默返回,改为抛 `INDEX_NOT_FOUND`
|
||||||
|
- **Memory 索引查找不支持 `$eq` 对象形式** — SQL 解析器生成的 `{ $eq: value }` 等值条件不走哈希索引(仅简单值走),补 `$eq` 分支
|
||||||
|
- **演示页预设数据被前序预设污染** — 连续点击预设时,cascade(删除 Alice/重建 orders)、truncate、alter 等会修改种子数据,后续预设(如 index)查询返回空。`loadPreset` 点击预设前自动重置数据库到初始状态,每个预设可预期演示
|
||||||
|
- **Aria ALTER TABLE 不生效(严重)** — `DROP COLUMN` 仅改 schema 引用,`find()` 返回行副本无法就地删除,存储行残留已删列值(SELECT * 仍显示);且 ALTER 不持久化 schema,重启后列定义回退。新增引擎级 `alterTable`(`IStorageEngine` 可选接口):Aria 重写主 LSM 移除列值 + `persistSchemas` + WAL UPDATE 记录,崩溃恢复后 schema 与行一致
|
||||||
|
- **裸布尔列条件不支持(严重)** — `WHERE done` / `CASE WHEN done THEN` 解析失败(无比较运算符即抛错),条件被忽略导致 CASE 恒走 ELSE。parser 支持裸列真值判断(`{ col: { $eq: true } }`)
|
||||||
|
- **Aria $in 返回重复行** — IN 子查询含重复值(如 Alice 两条订单)时 PK/二级索引 `$in` 逐值查找重复 push,按主键去重
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-08-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **流式查询 API** — `db.queryStream(sql, onRow)` / `db.table(name).stream(onRow)` / `IStorageEngine.findStream()`:
|
||||||
|
- Aria 引擎走 LSM `rangeScanLazy` 惰性扫描,逐行回调不物化结果数组(大表友好)
|
||||||
|
- Memory/Hybrid/OPFS 逐行迭代;IndexedDB 批量读入后逐行回调(保持接口一致)
|
||||||
|
- 支持 WHERE/LIMIT/OFFSET/列投影;JOIN/GROUP BY/UNION/聚合/ORDER BY 自动回退物化
|
||||||
|
- 用户 async 回调自动回退物化(同步扫描无法 await,避免吞 Promise)
|
||||||
|
- **多列 ON 哈希连接** — 复合等值条件(`ON a.tenant = b.tenant AND a.key = b.key`)走哈希连接(含顶层 `$and` 展开),任一右列为索引/主键即可;不适用自动回退嵌套循环
|
||||||
|
- **FROM 子查询(派生表)** — `SELECT ... FROM (SELECT ...) AS alias WHERE ...`,子查询结果作为行源,支持 WHERE/ORDER BY/LIMIT 与 JOIN 组合
|
||||||
|
- **COUNT(DISTINCT col)** — 聚合去重(COUNT/SUM/AVG/MIN/MAX 均支持),任意类型去重
|
||||||
|
- **NULLS FIRST / NULLS LAST** — `ORDER BY col ASC NULLS FIRST` 等,NULL 位置固定不受升降序反转
|
||||||
|
- **普通列别名** — `SELECT name AS n` / `SELECT name n`(此前仅聚合/CASE 支持别名),别名可用于 ORDER BY
|
||||||
|
- **无表查询与字符串常量列** — `SELECT 1` / `SELECT 'lit' AS x`(无需 FROM),`''` 转义与常量列投影
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **AriaEngine 崩溃恢复后已删除表/数据复活(严重,P0)** — WAL 回放忽略 `DROP_TABLE` 而 `CREATE_TABLE` 无条件恢复 schema,删表后崩溃重启表+数据全部复活。v0.4.0 新增 `applyDropTableRecovery()` 回放删除 schema + 清除 LSM 残留
|
||||||
|
- **WAL 恢复后不截断** — 每次重启重复回放 + WAL 无限膨胀。恢复完成将回放数据落盘后 `wal.checkpoint()` 截断
|
||||||
|
- **MemoryEngine update 不维护索引(P0)** — `update()` 后唯一约束可被绕过、按新值索引查询丢行。`removeIndexEntries()` 在 update/delete/级联删除/SET NULL 前清理旧值索引
|
||||||
|
- **Aria 事务内读不到自己写入的行** — `update/delete/count/clear` 的 `getAllRows()` 不合并 `txnSnapshot`,事务内先 insert 再 update/delete 失效;统一 `mergeTxnSnapshot()`
|
||||||
|
- **Aria TRUNCATE 不写 WAL** — `clear()` 崩溃恢复后丢失;事务内清空走快照删除标记,提交生效
|
||||||
|
- **WAL 大小阈值在 full 模式永不触发** — `getBufferedBytes()` 跟踪真实字节数替代缓冲计数估算
|
||||||
|
- **rollback 后二级索引残留** — 事务内直接写入索引 LSM,回滚后对受影响表全量重建索引
|
||||||
|
- **Savepoint 回滚 MVCC 不一致** — `discardVersions()` 清理版本链保留事务登记
|
||||||
|
- **PK 范围查询字符串算术 bug** — `$gt/$gte/$lt/$lte` 用 `Number(v)+1` 构造 key 对字符串 PK 失效;改为主 LSM 前缀扫描 + 条件过滤;主键列不再建冗余二级索引(PK `$in` 走多次精确查找)
|
||||||
|
- **SQL 字符串 `''` 标准转义** — 双引号转义此前从未生效(lexer 条件先于转义分支退出);重写 `readString`
|
||||||
|
- **INSERT INTO ... SELECT 位置错位(严重)** — 源行 `validateRow` 跳过 undefined 值导致行键缺失/乱序(如 ALTER 加列后省略列插入的行),`Object.values` 位置映射把 age 数字填进 email 列报类型错。改为按源表 schema 列顺序/SELECT 列列表对齐,缺列不填
|
||||||
|
- **ORDER BY / GROUP BY 表前缀列** — `ORDER BY o.amount` / `GROUP BY u.name` 解析失败(`Expected ';' ... got "."`),parser 支持 `table.column` 引用,executor 非 JOIN 路径剥离前缀
|
||||||
|
- **关联 EXISTS 绑定失效(严重)** — `SELECT u.name ... WHERE EXISTS (SELECT 1 FROM o WHERE o.user_id = u.id)` 返回空:引擎按 SELECT 列投影后外层行缺 `id` 键,`$col` 绑定为 null。关联子查询路径强制 `plan.columns=['*']` 取完整行;非 JOIN 路径 SELECT 列带表前缀(`u.name`)统一剥离
|
||||||
|
- **HAVING 标量子查询失效(静默返回空)** — `HAVING SUM(o.amount) > (SELECT AVG(amount) FROM orders)` 的 `$subquery` 未解析 + 聚合表达式键(`SUM(o.amount)`)与别名键(`spent`)不匹配。HAVING 先 `resolveSubqueries`,GROUP BY 结果维护「表达式键→别名键」映射做归一化
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- 版本号升至 v0.4.0
|
||||||
|
- 测试 837 → **876**(44 → 46 套件),覆盖率 81.1% → **81.5%**(Lines 84.7%)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.3.2] - 2026-08-08
|
## [0.3.2] - 2026-08-08
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# MetonaSqlark
|
# MetonaSqlark
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/version-0.3.2-blue?style=flat-square" alt="version">
|
<img src="https://img.shields.io/badge/version-0.4.1-blue?style=flat-square" alt="version">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license">
|
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license">
|
||||||
<img src="https://img.shields.io/badge/coverage-81.1%25-brightgreen?style=flat-square" alt="coverage">
|
<img src="https://img.shields.io/badge/coverage-81.5%25-brightgreen?style=flat-square" alt="coverage">
|
||||||
<img src="https://img.shields.io/badge/tests-837%20passed-success?style=flat-square" alt="tests">
|
<img src="https://img.shields.io/badge/tests-894%20passed-success?style=flat-square" alt="tests">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> 基于 TypeScript 的**前端关系型数据库**,支持完整 SQL 查询、Query Builder 链式 API、与 **AriaEngine 自研页面式存储引擎**。
|
> 基于 TypeScript 的**前端关系型数据库**,支持完整 SQL 查询、Query Builder 链式 API、与 **AriaEngine 自研页面式存储引擎**。
|
||||||
@@ -18,13 +18,15 @@
|
|||||||
- 🔒 **生产级数据安全** — WAL CRC 完整性校验、`RESTRICT` 外键约束、Hybrid 提交原子性、SQL 注入防护
|
- 🔒 **生产级数据安全** — WAL CRC 完整性校验、`RESTRICT` 外键约束、Hybrid 提交原子性、SQL 注入防护
|
||||||
- 🛡 **输入校验全覆盖** — `maxLength`/`min`/`max` 约束、类型检查、必填验证
|
- 🛡 **输入校验全覆盖** — `maxLength`/`min`/`max` 约束、类型检查、必填验证
|
||||||
- 💾 **多引擎架构** — Memory / IndexedDB / OPFS / Hybrid(write-through) / Aria 五种模式
|
- 💾 **多引擎架构** — Memory / IndexedDB / OPFS / Hybrid(write-through) / Aria 五种模式
|
||||||
- 📝 **完整 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)
|
- 📝 **完整 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)+ CASE WHEN/哈希连接/组提交(v0.3.1)+ 多标签页同步(v0.3.2)
|
||||||
|
- 🚰 **流式查询** — `queryStream`/`stream()` 逐行回调,Aria LSM 惰性扫描不物化结果集(v0.4.1)
|
||||||
|
- 🧩 **派生表** — `FROM (SELECT ...)` 子查询作为行源,多列 ON 哈希连接,COUNT(DISTINCT),NULLS FIRST/LAST(v0.4.1)
|
||||||
- 🔗 **Query Builder API** — 链式 `.select().where().orderBy().limit().execute()`
|
- 🔗 **Query Builder API** — 链式 `.select().where().orderBy().limit().execute()`
|
||||||
- 🔄 **事务回滚** — Memory/IndexedDB/Hybrid/Aria 四引擎事务原子性,自动回滚,MVCC 版本链接入读写路径
|
- 🔄 **事务回滚** — Memory/IndexedDB/Hybrid/Aria 四引擎事务原子性,自动回滚,MVCC 版本链接入读写路径
|
||||||
- 🌲 **RB-Tree 完整实现** — 标准红黑树插入+删除修复,O(log n) 保证
|
- 🌲 **RB-Tree 完整实现** — 标准红黑树插入+删除修复,O(log n) 保证
|
||||||
- ⚡ **性能优化** — SSTableReader 二分查找统一、IndexedDB 索引利用、crypto 实例化避免全局状态
|
- ⚡ **性能优化** — SSTableReader 二分查找统一、IndexedDB 索引利用、crypto 实例化避免全局状态
|
||||||
- 🌐 **浏览器兼容** — Chrome 80+ / Firefox 80+ / Safari 14+ / Edge 80+ / Node.js 16+
|
- 🌐 **浏览器兼容** — Chrome 80+ / Firefox 80+ / Safari 14+ / Edge 80+ / Node.js 16+
|
||||||
- 🧪 **837 测试 · 81.1% 覆盖率** — 44 套件,生产级质量保证
|
- 🧪 **894 测试 · 81.5% 覆盖率** — 47 套件,生产级质量保证
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -165,6 +167,17 @@ const hasOrders = await db.query('SELECT * FROM users u WHERE EXISTS (SELECT 1 F
|
|||||||
const labeled = await db.query("SELECT name, CASE WHEN age >= 18 THEN 'adult' ELSE 'minor' END AS status FROM users");
|
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)');
|
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.4.1 — 流式查询(大表逐行回调,不物化全部结果)
|
||||||
|
let count = 0;
|
||||||
|
await db.queryStream('SELECT * FROM logs WHERE level = \'error\'', (row) => {
|
||||||
|
count++;
|
||||||
|
processRow(row);
|
||||||
|
});
|
||||||
|
// v0.4.1 — 派生表 / 多列哈希连接 / COUNT(DISTINCT) / NULLS 排序
|
||||||
|
const top = await db.query('SELECT dept, total FROM (SELECT dept, SUM(salary) AS total FROM emp GROUP BY dept) AS t WHERE total > 100 ORDER BY total DESC');
|
||||||
|
await db.query('SELECT COUNT(DISTINCT city) AS n FROM users');
|
||||||
|
await db.query('SELECT name FROM users ORDER BY age ASC NULLS FIRST');
|
||||||
|
|
||||||
// 事务 — 自动回滚 v0.1.13
|
// 事务 — 自动回滚 v0.1.13
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await trx.table('users').insert({ id: '3', name: 'Charlie' });
|
await trx.table('users').insert({ id: '3', name: 'Charlie' });
|
||||||
@@ -222,6 +235,7 @@ await db2.disconnect(); // 引用计数 -1
|
|||||||
| 方法 | 说明 |
|
| 方法 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `db.query(sql)` | 执行 SQL 字符串 |
|
| `db.query(sql)` | 执行 SQL 字符串 |
|
||||||
|
| `db.queryStream(sql, onRow)` | 流式查询(逐行回调,不物化)🆕 |
|
||||||
| `db.table(name)` | 获取表操作对象 |
|
| `db.table(name)` | 获取表操作对象 |
|
||||||
| `db.defineTable(name, cols)` | 定义表结构 |
|
| `db.defineTable(name, cols)` | 定义表结构 |
|
||||||
| `db.transaction(fn)` | 执行事务(自动回滚)🆕 |
|
| `db.transaction(fn)` | 执行事务(自动回滚)🆕 |
|
||||||
@@ -373,9 +387,9 @@ npm run typecheck # 类型检查
|
|||||||
|
|
||||||
| 指标 | 数值 |
|
| 指标 | 数值 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 测试用例 | 837 |
|
| 测试用例 | 894 |
|
||||||
| 测试套件 | 44 |
|
| 测试套件 | 47 |
|
||||||
| 行覆盖率 | 81.1% |
|
| 行覆盖率 | 81.5% |
|
||||||
| SQL 关键字 | 36 |
|
| SQL 关键字 | 36 |
|
||||||
| 存储引擎 | 5(Memory / IndexedDB / OPFS / Hybrid / **Aria**) |
|
| 存储引擎 | 5(Memory / IndexedDB / OPFS / Hybrid / **Aria**) |
|
||||||
|
|
||||||
|
|||||||
Vendored
+1010
-141
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
+101
-16
@@ -83,6 +83,8 @@ interface OrderBy {
|
|||||||
column: string;
|
column: string;
|
||||||
/** 排序方向 */
|
/** 排序方向 */
|
||||||
direction: SortDirection;
|
direction: SortDirection;
|
||||||
|
/** v0.4.0: NULL 值排序位置(first 排最前 / last 排最后,默认同引擎行为) */
|
||||||
|
nulls?: 'first' | 'last';
|
||||||
}
|
}
|
||||||
/** 查询计划 — 由 Executor 编译 AST 后生成 */
|
/** 查询计划 — 由 Executor 编译 AST 后生成 */
|
||||||
interface QueryPlan {
|
interface QueryPlan {
|
||||||
@@ -116,7 +118,7 @@ interface MetonaPlugin {
|
|||||||
/** 销毁 */
|
/** 销毁 */
|
||||||
destroy(): void;
|
destroy(): void;
|
||||||
}
|
}
|
||||||
declare const VERSION = "0.3.2";
|
declare const VERSION = "0.4.1";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* metona-sqlark Plugin — 插件系统
|
* metona-sqlark Plugin — 插件系统
|
||||||
@@ -145,11 +147,6 @@ declare class PluginManager {
|
|||||||
destroy(): void;
|
destroy(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* metona-sqlark Engine Interface — 存储引擎抽象接口
|
|
||||||
* @module engine/interface
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface IStorageEngine {
|
interface IStorageEngine {
|
||||||
/** 引擎名称 */
|
/** 引擎名称 */
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
@@ -173,6 +170,8 @@ interface IStorageEngine {
|
|||||||
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
|
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
|
||||||
/** 查询行 */
|
/** 查询行 */
|
||||||
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
||||||
|
/** v0.4.0: 流式查询 — 逐行回调扫描(有 where/limit/projection,无 orderBy 语义;有 orderBy 时实现可回退物化) */
|
||||||
|
findStream?(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number>;
|
||||||
/** 更新行,返回影响行数 */
|
/** 更新行,返回影响行数 */
|
||||||
update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number>;
|
update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number>;
|
||||||
/** 删除行,返回影响行数 */
|
/** 删除行,返回影响行数 */
|
||||||
@@ -181,6 +180,10 @@ interface IStorageEngine {
|
|||||||
count(tableName: string, query?: QueryPlan): Promise<number>;
|
count(tableName: string, query?: QueryPlan): Promise<number>;
|
||||||
/** 清空表数据(保留结构) */
|
/** 清空表数据(保留结构) */
|
||||||
clear(tableName: string): Promise<void>;
|
clear(tableName: string): Promise<void>;
|
||||||
|
/** v0.4.1: ALTER TABLE(可选)— 引擎级结构变更(Aria 需重写存储行,其余引擎走 Executor 通用路径) */
|
||||||
|
alterTable?(tableName: string, action: 'ADD' | 'DROP', column: ColumnDef & {
|
||||||
|
name: string;
|
||||||
|
}): Promise<void>;
|
||||||
/** 创建二级索引(CREATE INDEX) */
|
/** 创建二级索引(CREATE INDEX) */
|
||||||
createIndex?(tableName: string, column: string, unique?: boolean): Promise<void>;
|
createIndex?(tableName: string, column: string, unique?: boolean): Promise<void>;
|
||||||
/** 删除二级索引(DROP INDEX) */
|
/** 删除二级索引(DROP INDEX) */
|
||||||
@@ -281,6 +284,8 @@ interface SelectStatement {
|
|||||||
columns: ColumnRef[];
|
columns: ColumnRef[];
|
||||||
distinct?: boolean;
|
distinct?: boolean;
|
||||||
from: string;
|
from: string;
|
||||||
|
/** v0.4.0: FROM (SELECT ...) 派生表(存在时 from 为占位,行源取此子查询结果) */
|
||||||
|
fromSubquery?: SelectStatement | SelectUnionStatement;
|
||||||
/** 主表别名 */
|
/** 主表别名 */
|
||||||
alias?: string;
|
alias?: string;
|
||||||
/** JOIN 子句列表 */
|
/** JOIN 子句列表 */
|
||||||
@@ -364,7 +369,13 @@ declare class QueryExecutor {
|
|||||||
private executeJoinSelect;
|
private executeJoinSelect;
|
||||||
private prefixRow;
|
private prefixRow;
|
||||||
/**
|
/**
|
||||||
* 哈希连接(v0.3.2):ON 为单一等值条件且右表列为索引/主键时,
|
* v0.4.1: 提取可下推的 WHERE 条件 — 主表别名前缀的普通条件(如 o.user_id = '1')。
|
||||||
|
* 下推到引擎可走二级索引;$col/$subquery/$and/$or/$not 等复杂条件保守不下推。
|
||||||
|
*/
|
||||||
|
private extractPushableWhere;
|
||||||
|
/**
|
||||||
|
* 哈希连接(v0.3.2 单等值 / v0.4.0 多列等值):
|
||||||
|
* ON 为等值条件(单列或多列)且右表任一列为索引/主键时,
|
||||||
* 收集左表连接值 → 一次 $in 查询右表 → 哈希映射匹配。
|
* 收集左表连接值 → 一次 $in 查询右表 → 哈希映射匹配。
|
||||||
* 替代嵌套循环,大表 INNER/LEFT JOIN 复杂度 O(N + M)。
|
* 替代嵌套循环,大表 INNER/LEFT JOIN 复杂度 O(N + M)。
|
||||||
* 不适用时返回 null(回退嵌套循环)。
|
* 不适用时返回 null(回退嵌套循环)。
|
||||||
@@ -389,11 +400,17 @@ declare class QueryExecutor {
|
|||||||
private executeRollback;
|
private executeRollback;
|
||||||
/** 列列表是否包含 CASE WHEN 表达式 */
|
/** 列列表是否包含 CASE WHEN 表达式 */
|
||||||
private hasCaseColumn;
|
private hasCaseColumn;
|
||||||
|
/**
|
||||||
|
* v0.3.3: ORDER BY 是否引用 SELECT 别名(如 `SELECT name AS n ... ORDER BY n`)。
|
||||||
|
* 别名列在引擎层投影前不存在,需投影后重新排序。
|
||||||
|
*/
|
||||||
|
private orderByUsesSelectAlias;
|
||||||
/** WHERE 是否包含 CASE WHEN 表达式键 */
|
/** WHERE 是否包含 CASE WHEN 表达式键 */
|
||||||
private whereHasCase;
|
private whereHasCase;
|
||||||
getEngine(): IStorageEngine;
|
getEngine(): IStorageEngine;
|
||||||
/**
|
/**
|
||||||
* 列投影(v0.3.1):普通列走 projectColumns,CASE WHEN 表达式逐行求值
|
* 列投影(v0.3.1):普通列走 projectColumns,CASE WHEN 表达式逐行求值;
|
||||||
|
* v0.3.3: 支持 `col AS alias` 列别名
|
||||||
*/
|
*/
|
||||||
private projectRow;
|
private projectRow;
|
||||||
/** 检查 SELECT 列列表中是否包含聚合函数 */
|
/** 检查 SELECT 列列表中是否包含聚合函数 */
|
||||||
@@ -516,6 +533,13 @@ declare class Table<T = Record<string, unknown>> {
|
|||||||
insert(row: T & Record<string, unknown>): Promise<string>;
|
insert(row: T & Record<string, unknown>): Promise<string>;
|
||||||
insertMany(rows: (T & Record<string, unknown>)[]): Promise<string[]>;
|
insertMany(rows: (T & Record<string, unknown>)[]): Promise<string[]>;
|
||||||
select(columns?: string[]): SelectQueryBuilder;
|
select(columns?: string[]): SelectQueryBuilder;
|
||||||
|
/** v0.4.0: 流式查询 — 逐行回调,不物化全部结果 */
|
||||||
|
stream(onRow: (row: T & Record<string, unknown>) => void, query?: {
|
||||||
|
where?: Record<string, unknown>;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
columns?: string[];
|
||||||
|
}): Promise<number>;
|
||||||
update(updates: Partial<T> & Record<string, unknown>): UpdateQueryBuilder;
|
update(updates: Partial<T> & Record<string, unknown>): UpdateQueryBuilder;
|
||||||
delete(): DeleteQueryBuilder;
|
delete(): DeleteQueryBuilder;
|
||||||
count(where?: Record<string, unknown>): Promise<number>;
|
count(where?: Record<string, unknown>): Promise<number>;
|
||||||
@@ -580,6 +604,23 @@ declare class MetonaSqlark {
|
|||||||
getTableNames(): Promise<string[]>;
|
getTableNames(): Promise<string[]>;
|
||||||
/** 执行 SQL 字符串查询 */
|
/** 执行 SQL 字符串查询 */
|
||||||
query(sql: string): Promise<unknown>;
|
query(sql: string): Promise<unknown>;
|
||||||
|
/**
|
||||||
|
* 流式查询:逐行回调,不一次性物化全部结果(大表友好)。
|
||||||
|
* 支持简单 SELECT(WHERE/LIMIT/OFFSET/列投影);
|
||||||
|
* JOIN/GROUP BY/UNION/聚合/ORDER BY 自动回退为物化查询后逐行回调。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* let total = 0;
|
||||||
|
* await db.queryStream('SELECT * FROM logs WHERE level = \'error\'', (row) => {
|
||||||
|
* total++;
|
||||||
|
* processRow(row);
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
queryStream<T extends Record<string, unknown> = Record<string, unknown>>(sql: string, onRow: (row: T) => void): Promise<number>;
|
||||||
|
/** 流式查询用:剥离主表别名前缀(复用 query 路径的规范化逻辑) */
|
||||||
|
private normalizeWhereForStream;
|
||||||
/** 执行事务 */
|
/** 执行事务 */
|
||||||
transaction<T>(fn: (trx: Transaction) => Promise<T>): Promise<T>;
|
transaction<T>(fn: (trx: Transaction) => Promise<T>): Promise<T>;
|
||||||
/** 导出表数据为 JSON */
|
/** 导出表数据为 JSON */
|
||||||
@@ -648,6 +689,8 @@ declare class MemoryEngine implements IStorageEngine {
|
|||||||
getTableSchema(tableName: string): Promise<TableSchema | null>;
|
getTableSchema(tableName: string): Promise<TableSchema | null>;
|
||||||
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
|
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
|
||||||
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
||||||
|
/** v0.4.0: 流式查询 — 逐行回调(单次迭代,不物化结果数组) */
|
||||||
|
findStream(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number>;
|
||||||
update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number>;
|
update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number>;
|
||||||
delete(tableName: string, query: QueryPlan): Promise<number>;
|
delete(tableName: string, query: QueryPlan): Promise<number>;
|
||||||
count(tableName: string, query?: QueryPlan): Promise<number>;
|
count(tableName: string, query?: QueryPlan): Promise<number>;
|
||||||
@@ -669,6 +712,8 @@ declare class MemoryEngine implements IStorageEngine {
|
|||||||
private tryIndexLookup;
|
private tryIndexLookup;
|
||||||
/** 更新索引 */
|
/** 更新索引 */
|
||||||
private updateIndexes;
|
private updateIndexes;
|
||||||
|
/** v0.3.3: 从所有索引中移除一行的条目(update/delete 前调用,修复索引过期/残留) */
|
||||||
|
private removeIndexEntries;
|
||||||
/**
|
/**
|
||||||
* 级联删除:查找引用 tableName.pkValue 的所有表的行并删除。
|
* 级联删除:查找引用 tableName.pkValue 的所有表的行并删除。
|
||||||
* @returns 级联删除的行数
|
* @returns 级联删除的行数
|
||||||
@@ -706,6 +751,8 @@ declare class IndexedDBEngine implements IStorageEngine {
|
|||||||
getTableSchema(tableName: string): Promise<TableSchema | null>;
|
getTableSchema(tableName: string): Promise<TableSchema | null>;
|
||||||
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
|
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
|
||||||
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
||||||
|
/** v0.4.0: 流式查询 — IDB 批量读入后逐行回调(保持接口一致性) */
|
||||||
|
findStream(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number>;
|
||||||
update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number>;
|
update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number>;
|
||||||
delete(tableName: string, query: QueryPlan): Promise<number>;
|
delete(tableName: string, query: QueryPlan): Promise<number>;
|
||||||
count(tableName: string, query?: QueryPlan): Promise<number>;
|
count(tableName: string, query?: QueryPlan): Promise<number>;
|
||||||
@@ -753,6 +800,8 @@ declare class OPFSEngine implements IStorageEngine {
|
|||||||
getTableSchema(tableName: string): Promise<TableSchema | null>;
|
getTableSchema(tableName: string): Promise<TableSchema | null>;
|
||||||
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
|
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
|
||||||
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
||||||
|
/** v0.4.0: 流式查询(委托内存缓存) */
|
||||||
|
findStream(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number>;
|
||||||
update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number>;
|
update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number>;
|
||||||
delete(tableName: string, query: QueryPlan): Promise<number>;
|
delete(tableName: string, query: QueryPlan): Promise<number>;
|
||||||
count(tableName: string, query?: QueryPlan): Promise<number>;
|
count(tableName: string, query?: QueryPlan): Promise<number>;
|
||||||
@@ -798,13 +847,6 @@ interface AriaEngineConfig {
|
|||||||
maxMemoryMB?: number;
|
maxMemoryMB?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* AriaEngine — 自研页面式存储引擎主类
|
|
||||||
* @module engine/aria/index
|
|
||||||
*
|
|
||||||
* v0.2.5: WAL 同步修复 + MVCC 接入 + 版本统一 + 生产加固
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare class AriaEngine implements IStorageEngine {
|
declare class AriaEngine implements IStorageEngine {
|
||||||
readonly name = "aria";
|
readonly name = "aria";
|
||||||
private config;
|
private config;
|
||||||
@@ -826,6 +868,11 @@ declare class AriaEngine implements IStorageEngine {
|
|||||||
constructor(config?: AriaEngineConfig);
|
constructor(config?: AriaEngineConfig);
|
||||||
open(dbName: string, _version: number): Promise<void>;
|
open(dbName: string, _version: number): Promise<void>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
|
/**
|
||||||
|
* v0.4.1: 重置数据库 — 清空全部数据与表结构(演示页刷新/重新初始化用)。
|
||||||
|
* 清空存储后端、LSM、WAL、MVCC 与二级索引,后续可继续使用本实例。
|
||||||
|
*/
|
||||||
|
clearAll(): Promise<void>;
|
||||||
isOpen(): boolean;
|
isOpen(): boolean;
|
||||||
createTable(schema: TableSchema): Promise<void>;
|
createTable(schema: TableSchema): Promise<void>;
|
||||||
dropTable(tableName: string): Promise<void>;
|
dropTable(tableName: string): Promise<void>;
|
||||||
@@ -836,8 +883,32 @@ declare class AriaEngine implements IStorageEngine {
|
|||||||
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
||||||
update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number>;
|
update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number>;
|
||||||
delete(tableName: string, query: QueryPlan): Promise<number>;
|
delete(tableName: string, query: QueryPlan): Promise<number>;
|
||||||
|
/**
|
||||||
|
* v0.4.1: 外键级联规则 — 对齐 MemoryEngine.cascadeDelete 行为。
|
||||||
|
* 删除 tableName 主键为 pkValue 的行前,检查引用它的所有表:
|
||||||
|
* - RESTRICT: 存在引用行 → 抛 FOREIGN_KEY_VIOLATION
|
||||||
|
* - CASCADE: 递归删除引用行(含索引/WAL)
|
||||||
|
* - SET NULL: 引用行外键列置 null(含索引/WAL)
|
||||||
|
* @returns 级联影响的行数(CASCADE 删除行数 + SET NULL 更新行数)
|
||||||
|
*/
|
||||||
|
private applyForeignKeyRules;
|
||||||
|
/**
|
||||||
|
* v0.4.0: 流式查询 — 逐行回调,不物化结果数组。
|
||||||
|
* 全表路径走 LSM rangeScanLazy 惰性扫描;索引等值/范围路径复用 tryIndexLookup。
|
||||||
|
* 事务中回退物化(快照合并需要全量行集)。
|
||||||
|
*/
|
||||||
|
findStream(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number>;
|
||||||
count(tableName: string, query?: QueryPlan): Promise<number>;
|
count(tableName: string, query?: QueryPlan): Promise<number>;
|
||||||
clear(tableName: string): Promise<void>;
|
clear(tableName: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* v0.4.1: ALTER TABLE — 结构变更真正生效于存储:
|
||||||
|
* - ADD: 持久化 schema(persistSchemas),行无需修改
|
||||||
|
* - DROP: 持久化 schema + 遍历主 LSM 重写所有行(移除该列键)+ WAL UPDATE 记录
|
||||||
|
* (通用路径 getTableSchema 返回副本,Executor 的引用修改对 Aria 无效)
|
||||||
|
*/
|
||||||
|
alterTable(tableName: string, action: 'ADD' | 'DROP', column: ColumnDef & {
|
||||||
|
name: string;
|
||||||
|
}): Promise<void>;
|
||||||
createIndex(tableName: string, column: string, unique?: boolean): Promise<void>;
|
createIndex(tableName: string, column: string, unique?: boolean): Promise<void>;
|
||||||
dropIndex(tableName: string, column: string, _indexName?: string): Promise<void>;
|
dropIndex(tableName: string, column: string, _indexName?: string): Promise<void>;
|
||||||
beginTransaction(): Promise<void>;
|
beginTransaction(): Promise<void>;
|
||||||
@@ -849,6 +920,11 @@ declare class AriaEngine implements IStorageEngine {
|
|||||||
releaseSavepoint(name: string): Promise<void>;
|
releaseSavepoint(name: string): Promise<void>;
|
||||||
backup(): Promise<Record<string, Record<string, unknown>[]>>;
|
backup(): Promise<Record<string, Record<string, unknown>[]>>;
|
||||||
private getAllRows;
|
private getAllRows;
|
||||||
|
/**
|
||||||
|
* v0.3.3: 将事务未提交快照的变更合并到行列表(新增/更新/删除标记)。
|
||||||
|
* 幂等操作:行已是最新时不重复修改。
|
||||||
|
*/
|
||||||
|
private mergeTxnSnapshot;
|
||||||
private getPK;
|
private getPK;
|
||||||
private validateRow;
|
private validateRow;
|
||||||
private checkType;
|
private checkType;
|
||||||
@@ -864,6 +940,13 @@ declare class AriaEngine implements IStorageEngine {
|
|||||||
*/
|
*/
|
||||||
private createSSTableStore;
|
private createSSTableStore;
|
||||||
private applyWALRecord;
|
private applyWALRecord;
|
||||||
|
/**
|
||||||
|
* v0.3.3: DROP_TABLE 恢复 — 删除 schema 并清除主 LSM 中该表的所有残留数据。
|
||||||
|
*
|
||||||
|
* 此前 DROP_TABLE 在恢复时被忽略,而 CREATE_TABLE 回放会重建 schema,
|
||||||
|
* 导致崩溃后"已删除的表和数据复活"(实证 P0 bug)。
|
||||||
|
*/
|
||||||
|
private applyDropTableRecovery;
|
||||||
/** 更新行的二级索引条目 */
|
/** 更新行的二级索引条目 */
|
||||||
private updateSecondaryIndexes;
|
private updateSecondaryIndexes;
|
||||||
/** 通过二级索引快速查找 */
|
/** 通过二级索引快速查找 */
|
||||||
@@ -942,6 +1025,8 @@ declare class HybridEngine implements IStorageEngine {
|
|||||||
getTableSchema(tableName: string): Promise<TableSchema | null>;
|
getTableSchema(tableName: string): Promise<TableSchema | null>;
|
||||||
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
|
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
|
||||||
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
||||||
|
/** v0.4.0: 流式查询(内存引擎逐行回调) */
|
||||||
|
findStream(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number>;
|
||||||
update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number>;
|
update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number>;
|
||||||
delete(tableName: string, query: QueryPlan): Promise<number>;
|
delete(tableName: string, query: QueryPlan): Promise<number>;
|
||||||
count(tableName: string, query?: QueryPlan): Promise<number>;
|
count(tableName: string, query?: QueryPlan): Promise<number>;
|
||||||
@@ -1131,7 +1216,7 @@ declare class OPFSBackend implements IStorageBackend {
|
|||||||
/**
|
/**
|
||||||
* metona-sqlark — 入口文件
|
* metona-sqlark — 入口文件
|
||||||
* @module metona-sqlark
|
* @module metona-sqlark
|
||||||
* @version 0.2.5
|
* @version 0.4.1
|
||||||
*
|
*
|
||||||
* 前端关系型数据库,内存与磁盘双模式。
|
* 前端关系型数据库,内存与磁盘双模式。
|
||||||
* 支持 Query Builder 链式 API 和 SQL 字符串查询。
|
* 支持 Query Builder 链式 API 和 SQL 字符串查询。
|
||||||
|
|||||||
Vendored
+1010
-141
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
+1010
-141
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
Generated
+8111
-8111
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@metona-team/metona-sqlark",
|
"name": "@metona-team/metona-sqlark",
|
||||||
"version": "0.3.2",
|
"version": "0.4.1",
|
||||||
"description": "Frontend SQL database with in-memory and disk dual-mode storage",
|
"description": "Frontend SQL database with in-memory and disk dual-mode storage",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/metona-sqlark.cjs",
|
"main": "dist/metona-sqlark.cjs",
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>⚡ 性能基准 — MetonaSqlark v0.3.2</title>
|
<title>⚡ 性能基准 — MetonaSqlark v0.4.1</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>">
|
<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>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
+121
-54
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>🧪 在线演示 — MetonaSqlark v0.3.2</title>
|
<title>🧪 在线演示 — MetonaSqlark v0.4.1</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>">
|
<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>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
@@ -84,13 +84,15 @@
|
|||||||
<a href="demo.html" class="nav-active">演示</a>
|
<a href="demo.html" class="nav-active">演示</a>
|
||||||
<a href="benchmark.html">基准</a>
|
<a href="benchmark.html">基准</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="status"><span class="dot"></span> Memory 模式 — v0.3.2</div>
|
<div class="status"><span class="dot" id="engine-dot"></span> <span id="engine-status">Memory</span> 模式 — v0.4.1</div>
|
||||||
|
<button class="btn btn-preset" onclick="switchEngine('memory')" id="btn-memory" style="margin:6px 4px 6px 0;padding:6px 12px;">⚡ Memory</button>
|
||||||
|
<button class="btn btn-preset" onclick="switchEngine('aria')" id="btn-aria" style="margin:6px 0;padding:6px 12px;color:#ec4899;border-color:#ec4899;">🌲 Aria</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<div class="editor-panel">
|
<div class="editor-panel">
|
||||||
<div class="editor-area">
|
<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.3.2 在线演示
|
<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.4.1 在线演示
|
||||||
-- 已预置 users / orders / products 表数据
|
-- 已预置 users / orders / products 表数据
|
||||||
-- 新特性: ALTER TABLE · TRUNCATE TABLE · WAL同步 · MVCC · SQL注入防护
|
-- 新特性: ALTER TABLE · TRUNCATE TABLE · WAL同步 · MVCC · SQL注入防护
|
||||||
|
|
||||||
@@ -128,6 +130,7 @@
|
|||||||
<button class="btn btn-preset" onclick="loadPreset('exists')">🔍 EXISTS</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('index')">🗂 索引</button>
|
||||||
<button class="btn btn-preset" onclick="loadPreset('multistmt')">📜 多语句/事务</button>
|
<button class="btn btn-preset" onclick="loadPreset('multistmt')">📜 多语句/事务</button>
|
||||||
|
<button class="btn btn-preset" onclick="loadPreset('v040')" style="color:#22c55e;border-color:#22c55e;">🚰 v0.4.0 新特性</button>
|
||||||
<button class="btn btn-preset" onclick="loadPreset('aria')" style="color:#ec4899;border-color:#ec4899;">🌲 Aria</button>
|
<button class="btn btn-preset" onclick="loadPreset('aria')" style="color:#ec4899;border-color:#ec4899;">🌲 Aria</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,57 +164,84 @@ const rowCount = document.getElementById('row-count');
|
|||||||
const sqlInput = document.getElementById('sql-input');
|
const sqlInput = document.getElementById('sql-input');
|
||||||
|
|
||||||
// Init database
|
// Init database
|
||||||
|
let currentEngine = 'memory';
|
||||||
|
|
||||||
async function initDB() {
|
async function initDB() {
|
||||||
try {
|
try {
|
||||||
db = new DBClass({ name: 'demo', mode: 'memory' });
|
const engine = currentEngine;
|
||||||
|
// 关闭旧实例(预设重置/引擎切换时数据回到初始状态)
|
||||||
|
if (db) { try { await db.close(); } catch { /* ignore */ } }
|
||||||
|
db = new DBClass({ name: 'demo', mode: engine });
|
||||||
await db.init();
|
await db.init();
|
||||||
|
|
||||||
// Create tables
|
// v0.4.1: Aria 引擎持久化 — 每次加载清空上次演示数据,保证演示确定性
|
||||||
await db.defineTable('users', {
|
if (engine === 'aria' && typeof db.getEngine().clearAll === 'function') {
|
||||||
id: { type: 'string', primaryKey: true },
|
await db.getEngine().clearAll();
|
||||||
name: { type: 'string', required: true },
|
}
|
||||||
email: { type: 'string' },
|
|
||||||
age: { type: 'number', default: 0 },
|
|
||||||
});
|
|
||||||
await db.defineTable('orders', {
|
|
||||||
id: { type: 'string', primaryKey: true },
|
|
||||||
user_id: { type: 'string' },
|
|
||||||
product: { type: 'string' },
|
|
||||||
amount: { type: 'number' },
|
|
||||||
quantity: { type: 'number', default: 1 },
|
|
||||||
});
|
|
||||||
await db.defineTable('products', {
|
|
||||||
id: { type: 'string', primaryKey: true },
|
|
||||||
name: { type: 'string', required: true },
|
|
||||||
price: { type: 'number', default: 0 },
|
|
||||||
category: { type: 'string' },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Seed data
|
await seedDemoData(db);
|
||||||
await db.table('users').insertMany([
|
renderInfo(`✅ ${engine === 'aria' ? '🌲 AriaEngine(LSM-Tree + WAL + MVCC)' : '⚡ Memory'} 引擎已就绪`);
|
||||||
{ id: '1', name: 'Alice', email: 'alice@demo.com', age: 30 },
|
|
||||||
{ id: '2', name: 'Bob', email: 'bob@demo.com', age: 25 },
|
|
||||||
{ id: '3', name: 'Charlie', email: 'charlie@demo.com', age: 35 },
|
|
||||||
{ id: '4', name: 'Diana', email: 'diana@demo.com', age: 28 },
|
|
||||||
{ id: '5', name: 'Eve', email: 'eve@demo.com', age: 22 },
|
|
||||||
]);
|
|
||||||
await db.table('orders').insertMany([
|
|
||||||
{ id: 'o1', user_id: '1', product: 'Laptop', amount: 1200, quantity: 1 },
|
|
||||||
{ id: 'o2', user_id: '1', product: 'Mouse', amount: 50, quantity: 2 },
|
|
||||||
{ id: 'o3', user_id: '2', product: 'Keyboard', amount: 150, quantity: 1 },
|
|
||||||
{ id: 'o4', user_id: '3', product: 'Monitor', amount: 400, quantity: 1 },
|
|
||||||
{ id: 'o5', user_id: '3', product: 'Cable', amount: 20, quantity: 3 },
|
|
||||||
]);
|
|
||||||
await db.table('products').insertMany([
|
|
||||||
{ id: 'p1', name: 'Laptop', price: 1200, category: 'Electronics' },
|
|
||||||
{ id: 'p2', name: 'Mouse', price: 50, category: 'Accessories' },
|
|
||||||
{ id: 'p3', name: 'Keyboard', price: 150, category: 'Accessories' },
|
|
||||||
]);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
renderError('数据库初始化失败: ' + e.message);
|
renderError('数据库初始化失败: ' + e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 建表 + 种子数据(每次预设重置/引擎切换后重建,保证演示可预期)
|
||||||
|
async function seedDemoData(db) {
|
||||||
|
await db.defineTable('users', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
name: { type: 'string', required: true },
|
||||||
|
email: { type: 'string' },
|
||||||
|
age: { type: 'number', default: 0 },
|
||||||
|
});
|
||||||
|
await db.defineTable('orders', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
user_id: { type: 'string' },
|
||||||
|
product: { type: 'string' },
|
||||||
|
amount: { type: 'number' },
|
||||||
|
quantity: { type: 'number', default: 1 },
|
||||||
|
});
|
||||||
|
await db.defineTable('products', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
name: { type: 'string', required: true },
|
||||||
|
price: { type: 'number', default: 0 },
|
||||||
|
category: { type: 'string' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seed data
|
||||||
|
await db.table('users').insertMany([
|
||||||
|
{ id: '1', name: 'Alice', email: 'alice@demo.com', age: 30 },
|
||||||
|
{ id: '2', name: 'Bob', email: 'bob@demo.com', age: 25 },
|
||||||
|
{ id: '3', name: 'Charlie', email: 'charlie@demo.com', age: 35 },
|
||||||
|
{ id: '4', name: 'Diana', email: 'diana@demo.com', age: 28 },
|
||||||
|
{ id: '5', name: 'Eve', email: 'eve@demo.com', age: 22 },
|
||||||
|
]);
|
||||||
|
await db.table('orders').insertMany([
|
||||||
|
{ id: 'o1', user_id: '1', product: 'Laptop', amount: 1200, quantity: 1 },
|
||||||
|
{ id: 'o2', user_id: '1', product: 'Mouse', amount: 50, quantity: 2 },
|
||||||
|
{ id: 'o3', user_id: '2', product: 'Keyboard', amount: 150, quantity: 1 },
|
||||||
|
{ id: 'o4', user_id: '3', product: 'Monitor', amount: 400, quantity: 1 },
|
||||||
|
{ id: 'o5', user_id: '3', product: 'Cable', amount: 20, quantity: 3 },
|
||||||
|
]);
|
||||||
|
await db.table('products').insertMany([
|
||||||
|
{ id: 'p1', name: 'Laptop', price: 1200, category: 'Electronics' },
|
||||||
|
{ id: 'p2', name: 'Mouse', price: 50, category: 'Accessories' },
|
||||||
|
{ id: 'p3', name: 'Keyboard', price: 150, category: 'Accessories' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// v0.4.1: 切换存储引擎(重建数据库实例)
|
||||||
|
async function switchEngine(engine) {
|
||||||
|
if (engine === currentEngine) return;
|
||||||
|
currentEngine = engine;
|
||||||
|
document.getElementById('btn-memory').style.opacity = engine === 'memory' ? '1' : '0.6';
|
||||||
|
document.getElementById('btn-aria').style.opacity = engine === 'aria' ? '1' : '0.6';
|
||||||
|
document.getElementById('engine-status').textContent = engine === 'aria' ? '🌲 Aria' : '⚡ Memory';
|
||||||
|
document.getElementById('engine-dot').style.background = engine === 'aria' ? '#ec4899' : '';
|
||||||
|
renderInfo(`⏳ 正在初始化 ${engine === 'aria' ? 'Aria' : 'Memory'} 引擎…`);
|
||||||
|
await initDB();
|
||||||
|
}
|
||||||
|
|
||||||
// Run SQL
|
// Run SQL
|
||||||
async function runQuery() {
|
async function runQuery() {
|
||||||
const sql = sqlInput.value.trim();
|
const sql = sqlInput.value.trim();
|
||||||
@@ -316,6 +346,10 @@ function renderSuccess(msg) {
|
|||||||
resultArea.innerHTML = `<div class="success-box">${msg}</div>`;
|
resultArea.innerHTML = `<div class="success-box">${msg}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderInfo(msg) {
|
||||||
|
resultArea.innerHTML = `<div class="success-box" style="color:#22c55e;border-color:#22c55e;">${msg}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function clearResults() {
|
function clearResults() {
|
||||||
resultArea.innerHTML = `<div class="empty-state"><div class="icon">⚡</div><div>结果已清空</div></div>`;
|
resultArea.innerHTML = `<div class="empty-state"><div class="icon">⚡</div><div>结果已清空</div></div>`;
|
||||||
updateRowCount(0);
|
updateRowCount(0);
|
||||||
@@ -518,11 +552,13 @@ SELECT COUNT(*) as total FROM temp_logs;
|
|||||||
|
|
||||||
-- 清理
|
-- 清理
|
||||||
DROP TABLE temp_logs;`,
|
DROP TABLE temp_logs;`,
|
||||||
aria: `-- 🌲 AriaEngine 演示 (v0.2.5 → v0.3.2)
|
aria: `-- 🌲 AriaEngine 演示 (v0.4.1)
|
||||||
-- AriaEngine: LSM-Tree 自研存储引擎
|
-- 点击右上角「🌲 Aria」按钮切换数据库引擎到 AriaEngine
|
||||||
-- 支持 LSM-Tree · WAL CRC同步 · MVCC版本链 · BloomFilter · 二级索引 · AES-GCM · 836测试
|
-- 当前数据库即运行在 Aria 引擎上(LSM-Tree · WAL 崩溃恢复 · MVCC · BloomFilter)
|
||||||
|
-- 基础 CRUD 与 Memory 引擎完全兼容
|
||||||
|
|
||||||
-- 基础 CRUD 完全兼容
|
-- 当前引擎确认
|
||||||
|
-- (内存中 Aria 引擎实例,数据经 WAL + SSTable 持久化到 IndexedDB)
|
||||||
CREATE TABLE IF NOT EXISTS tasks (
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
id STRING PRIMARY KEY,
|
id STRING PRIMARY KEY,
|
||||||
title STRING NOT NULL,
|
title STRING NOT NULL,
|
||||||
@@ -556,8 +592,9 @@ DROP TABLE temp_logs;`,
|
|||||||
-- • Buffer Pool: SSTable LRU 缓存 (256页 ~ 1MB) ✅ 已生效
|
-- • Buffer Pool: SSTable LRU 缓存 (256页 ~ 1MB) ✅ 已生效
|
||||||
-- • Bloom Filter: FNV-1a + Murmur 双哈希
|
-- • Bloom Filter: FNV-1a + Murmur 双哈希
|
||||||
-- • 二级索引: 每列独立 LSM + 动态 CREATE INDEX
|
-- • 二级索引: 每列独立 LSM + 动态 CREATE INDEX
|
||||||
|
-- • 外键级联: CASCADE / SET NULL / RESTRICT (v0.4.1)
|
||||||
|
|
||||||
-- 生产环境: mode: 'aria' 激活自研引擎
|
-- 生产环境 API(与演示页右上角切换等价)
|
||||||
-- const db = await MetonaSqlark.create({
|
-- const db = await MetonaSqlark.create({
|
||||||
-- name: 'my-app', mode: 'aria'
|
-- name: 'my-app', mode: 'aria'
|
||||||
-- });`,
|
-- });`,
|
||||||
@@ -634,10 +671,10 @@ 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);`,
|
WHERE EXISTS (SELECT 1 FROM orders o2 WHERE o2.user_id = u.id AND o2.amount > 100);`,
|
||||||
index: `-- 🗂 动态索引 CREATE / DROP INDEX (v0.3.0)
|
index: `-- 🗂 动态索引 CREATE / DROP INDEX (v0.3.0)
|
||||||
|
|
||||||
-- 为 orders.user_id 创建索引
|
-- 为 orders.user_id 创建索引(已有数据自动回填)
|
||||||
CREATE INDEX idx_orders_user ON orders (user_id);
|
CREATE INDEX idx_orders_user ON orders (user_id);
|
||||||
|
|
||||||
-- 索引查找(走二级索引)
|
-- 索引查找(v0.4.1: JOIN 主表 WHERE 条件下推到引擎,真正走二级索引)
|
||||||
SELECT u.name, o.product, o.amount
|
SELECT u.name, o.product, o.amount
|
||||||
FROM orders o JOIN users u ON u.id = o.user_id
|
FROM orders o JOIN users u ON u.id = o.user_id
|
||||||
WHERE o.user_id = '1';
|
WHERE o.user_id = '1';
|
||||||
@@ -646,7 +683,10 @@ WHERE o.user_id = '1';
|
|||||||
DROP INDEX idx_orders_user ON orders (user_id);
|
DROP INDEX idx_orders_user ON orders (user_id);
|
||||||
|
|
||||||
-- 删除后回退全表扫描(结果不变)
|
-- 删除后回退全表扫描(结果不变)
|
||||||
SELECT * FROM orders WHERE user_id = '3';`,
|
SELECT * FROM orders WHERE user_id = '3';
|
||||||
|
|
||||||
|
-- DROP 不存在的索引会报错(INDEX_NOT_FOUND)
|
||||||
|
-- DROP INDEX idx_nonexist ON orders (user_id);`,
|
||||||
multistmt: `-- 📜 多语句 + 事务语句 (v0.3.0)
|
multistmt: `-- 📜 多语句 + 事务语句 (v0.3.0)
|
||||||
|
|
||||||
-- 分号分隔的多语句一次执行
|
-- 分号分隔的多语句一次执行
|
||||||
@@ -667,11 +707,35 @@ BEGIN;
|
|||||||
INSERT INTO audit VALUES ('a4', 'will be committed');
|
INSERT INTO audit VALUES ('a4', 'will be committed');
|
||||||
COMMIT;
|
COMMIT;
|
||||||
SELECT COUNT(*) as total FROM audit;`,
|
SELECT COUNT(*) as total FROM audit;`,
|
||||||
|
v040: `-- 🚰 v0.4.0 新特性
|
||||||
|
|
||||||
|
-- 派生表:FROM (SELECT ...) 子查询作为行源
|
||||||
|
SELECT category, total FROM (
|
||||||
|
SELECT category, SUM(price) AS total FROM products GROUP BY category
|
||||||
|
) AS t WHERE total > 100 ORDER BY total DESC;
|
||||||
|
|
||||||
|
-- COUNT(DISTINCT col):去重计数
|
||||||
|
SELECT COUNT(DISTINCT category) AS categories FROM products;
|
||||||
|
|
||||||
|
-- NULLS FIRST / LAST:NULL 排序位置控制
|
||||||
|
SELECT name FROM users ORDER BY age ASC NULLS FIRST LIMIT 3;
|
||||||
|
|
||||||
|
-- 普通列别名 + ORDER BY 别名
|
||||||
|
SELECT name AS n FROM users ORDER BY n DESC;
|
||||||
|
|
||||||
|
-- SQL 标准 '' 字符串转义
|
||||||
|
SELECT 'it''s a test' AS escaped;
|
||||||
|
|
||||||
|
-- 哈希连接(等值 ON 走 O(N+M);多列等值 ON 同样支持)
|
||||||
|
SELECT u.name, o.product FROM users u
|
||||||
|
INNER JOIN orders o ON o.user_id = u.id;`,
|
||||||
};
|
};
|
||||||
|
|
||||||
function loadPreset(name) {
|
// v0.4.1: 点击预设前自动重置数据(前序预设可能修改/删除种子数据,重置保证每个预设可预期演示)
|
||||||
|
async function loadPreset(name) {
|
||||||
if (presets[name]) {
|
if (presets[name]) {
|
||||||
sqlInput.value = presets[name];
|
sqlInput.value = presets[name];
|
||||||
|
await initDB();
|
||||||
runQuery();
|
runQuery();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -685,8 +749,11 @@ document.addEventListener('keydown', e => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Boot
|
// Boot
|
||||||
|
document.getElementById('btn-memory').style.opacity = '1';
|
||||||
|
document.getElementById('btn-aria').style.opacity = '0.6';
|
||||||
|
document.getElementById('engine-status').textContent = '⚡ Memory';
|
||||||
initDB().then(() => {
|
initDB().then(() => {
|
||||||
console.log('✅ MetonaSqlark v0.3.2 demo ready');
|
console.log('✅ MetonaSqlark v0.4.1 demo ready');
|
||||||
setTimeout(runQuery, 300);
|
setTimeout(runQuery, 300);
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
renderError('初始化失败: ' + err.message);
|
renderError('初始化失败: ' + err.message);
|
||||||
|
|||||||
+19
-3
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>📖 API 文档 — MetonaSqlark v0.3.2</title>
|
<title>📖 API 文档 — MetonaSqlark v0.4.1</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>">
|
<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>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
@@ -203,6 +203,21 @@ db.<span class="f">isReady</span>(); <span class="c">// true</span>
|
|||||||
|
|
||||||
<h2 id="sql-query">🔍 SQL 查询</h2>
|
<h2 id="sql-query">🔍 SQL 查询</h2>
|
||||||
<p><code>db.query(sql)</code> — 执行标准 SQL 字符串,返回查询结果。</p>
|
<p><code>db.query(sql)</code> — 执行标准 SQL 字符串,返回查询结果。</p>
|
||||||
|
<p><code>db.queryStream(sql, onRow)</code> — 流式查询(v0.4.0):逐行回调不物化结果集,大表友好。支持简单 SELECT(WHERE/LIMIT/OFFSET/列投影);JOIN/GROUP BY/UNION/聚合/ORDER BY 自动回退物化。</p>
|
||||||
|
|
||||||
|
<pre><span class="c">// 流式查询 — 大表逐行处理</span>
|
||||||
|
<span class="k">let</span> count = <span class="n">0</span>;
|
||||||
|
<span class="k">await</span> db.<span class="f">queryStream</span>(<span class="s">"SELECT * FROM logs WHERE level = 'error'"</span>, (row) => {
|
||||||
|
count++;
|
||||||
|
processRow(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
<span class="c">// 派生表 / COUNT(DISTINCT) / NULLS 排序(v0.4.0)</span>
|
||||||
|
<span class="k">const</span> top = <span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT dept, total FROM
|
||||||
|
(SELECT dept, SUM(salary) AS total FROM emp GROUP BY dept) AS t
|
||||||
|
WHERE total > 100 ORDER BY total DESC`</span>);
|
||||||
|
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"SELECT COUNT(DISTINCT city) AS n FROM users"</span>);
|
||||||
|
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"SELECT name FROM users ORDER BY age ASC NULLS FIRST"</span>);</pre>
|
||||||
|
|
||||||
<h3>完整 SQL 语法支持</h3>
|
<h3>完整 SQL 语法支持</h3>
|
||||||
<pre><span class="c">// SELECT — 核心查询</span>
|
<pre><span class="c">// SELECT — 核心查询</span>
|
||||||
@@ -730,8 +745,9 @@ db.<span class="f">broadcastChange</span>(<span class="s">'users'</span>);</pre>
|
|||||||
|
|
||||||
<h2 id="aria-engine">🌲 AriaEngine 自研存储引擎</h2>
|
<h2 id="aria-engine">🌲 AriaEngine 自研存储引擎</h2>
|
||||||
<p><strong>v0.2.0 新增</strong> — AriaEngine 是专为 MetonaSqlark 设计的页面式存储引擎,对标 SQLite 设计理念。<br>
|
<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 · 837测试 · 零死代码。<br>
|
<strong>v0.2.4 生产级</strong> — 二级索引 · MVCC · BloomFilter · WAL CRC全同步 · AES-GCM加密 · Savepoint · EXPLAIN · ANALYZE · REINDEX · VACUUM · BufferPool · 零死代码。<br>
|
||||||
<strong>v0.3.2 表达式与并发</strong> — WAL full模式真正同步 · MVCC接入读写路径 · SSTableReader二分查找统一 · crypto实例化 · IndexedDB索引利用 · compactLevel public接口 · WAL大小阈值自动checkpoint · SQL注入防护 · ALTER TABLE · TRUNCATE TABLE · 837测试 44套件。</p>
|
<strong>v0.3.2 表达式与并发</strong> — WAL full模式真正同步 · MVCC接入读写路径 · SSTableReader二分查找统一 · crypto实例化 · IndexedDB索引利用 · compactLevel public接口 · WAL大小阈值自动checkpoint · SQL注入防护 · ALTER TABLE · TRUNCATE TABLE · 多标签页同步 · IDB schema持久化。<br>
|
||||||
|
<strong>v0.4.1 Aria 级联与演示页引擎切换</strong> — AriaEngine 外键级联(CASCADE/SET NULL/RESTRICT)· `clearAll()` 重置 API · 演示页 ⚡Memory/🌲Aria 引擎切换器 · 894测试 47套件。</p>
|
||||||
|
|
||||||
<h3>存储模式对比</h3>
|
<h3>存储模式对比</h3>
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
+11
-6
@@ -153,7 +153,7 @@
|
|||||||
<!-- Hero -->
|
<!-- Hero -->
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="badge" style="margin-bottom:24px;"><span class="dot"></span> v0.3.2 表达式与并发 — 837测试 44套件 · 五模式全覆盖 · WAL同步修复 · ALTER/TRUNCATE · SQL注入防护 · 零回归</div>
|
<div class="badge" style="margin-bottom:24px;"><span class="dot"></span> v0.4.1 Aria 级联与演示页引擎切换 — 894测试 47套件 · 流式查询 · 派生表 · 崩溃恢复修复 · 多列哈希连接 · 零回归</div>
|
||||||
<h1>前端的 <span class="gradient-text">SQL 数据库</span></h1>
|
<h1>前端的 <span class="gradient-text">SQL 数据库</span></h1>
|
||||||
<p>TypeScript 原生构建,5 种存储引擎,支持完整 SQL 查询。<br>零运行时依赖,开箱即用。AriaEngine 自研引擎:LSM-Tree + WAL 同步 + MVCC。</p>
|
<p>TypeScript 原生构建,5 种存储引擎,支持完整 SQL 查询。<br>零运行时依赖,开箱即用。AriaEngine 自研引擎:LSM-Tree + WAL 同步 + MVCC。</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -243,8 +243,8 @@ npm install @metona-team/metona-sqlark
|
|||||||
</div>
|
</div>
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<div class="icon">🌲</div>
|
<div class="icon">🌲</div>
|
||||||
<h3>AriaEngine <span style="font-size:0.65rem;color:var(--accent);vertical-align:super;">v0.3.2</span></h3>
|
<h3>AriaEngine <span style="font-size:0.65rem;color:var(--accent);vertical-align:super;">v0.4.1</span></h3>
|
||||||
<p>自研 LSM-Tree 页面式存储引擎。MemTable 红黑树 + 多级 SSTable、Bloom Filter 快速判存、WAL full模式真正同步、MVCC 版本链接入读写路径。</p>
|
<p>自研 LSM-Tree 页面式存储引擎。MemTable 红黑树 + 多级 SSTable、Bloom Filter 快速判存、WAL full模式真正同步、MVCC 版本链接入读写路径、崩溃恢复 DROP_TABLE 回放修复。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<div class="icon">🔒</div>
|
<div class="icon">🔒</div>
|
||||||
@@ -296,6 +296,11 @@ npm install @metona-team/metona-sqlark
|
|||||||
<h3>Query Builder</h3>
|
<h3>Query Builder</h3>
|
||||||
<p>链式 API + TypeScript 泛型。.select().where().innerJoin().orderBy().limit() — 类型安全,IDE 友好。</p>
|
<p>链式 API + TypeScript 泛型。.select().where().innerJoin().orderBy().limit() — 类型安全,IDE 友好。</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="icon">🚰</div>
|
||||||
|
<h3>流式查询 + 派生表</h3>
|
||||||
|
<p>queryStream 逐行回调不物化结果集(大表友好)。FROM (SELECT ...) 派生表、多列哈希连接、COUNT(DISTINCT)、NULLS FIRST/LAST。</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -394,12 +399,12 @@ npm install @metona-team/metona-sqlark
|
|||||||
<p>MetonaSqlark 的核心指标</p>
|
<p>MetonaSqlark 的核心指标</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
<div class="stat-card"><div class="num">837</div><div class="label">测试用例</div></div>
|
<div class="stat-card"><div class="num">894</div><div class="label">测试用例</div></div>
|
||||||
<div class="stat-card"><div class="num">81.1%</div><div class="label">行覆盖率</div></div>
|
<div class="stat-card"><div class="num">81.5%</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">~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">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">36</div><div class="label">SQL 关键字</div></div>
|
||||||
<div class="stat-card"><div class="num">44</div><div class="label">测试套件</div></div>
|
<div class="stat-card"><div class="num">46</div><div class="label">测试套件</div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+3
-1
@@ -140,6 +140,8 @@ export interface OrderBy {
|
|||||||
column: string;
|
column: string;
|
||||||
/** 排序方向 */
|
/** 排序方向 */
|
||||||
direction: SortDirection;
|
direction: SortDirection;
|
||||||
|
/** v0.4.0: NULL 值排序位置(first 排最前 / last 排最后,默认同引擎行为) */
|
||||||
|
nulls?: 'first' | 'last';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -212,4 +214,4 @@ export class DatabaseError extends Error {
|
|||||||
// 版本
|
// 版本
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const VERSION = '0.3.2';
|
export const VERSION = '0.4.1';
|
||||||
|
|||||||
+85
@@ -202,6 +202,91 @@ export class MetonaSqlark {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 流式查询(v0.4.0) ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 流式查询:逐行回调,不一次性物化全部结果(大表友好)。
|
||||||
|
* 支持简单 SELECT(WHERE/LIMIT/OFFSET/列投影);
|
||||||
|
* JOIN/GROUP BY/UNION/聚合/ORDER BY 自动回退为物化查询后逐行回调。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* let total = 0;
|
||||||
|
* await db.queryStream('SELECT * FROM logs WHERE level = \'error\'', (row) => {
|
||||||
|
* total++;
|
||||||
|
* processRow(row);
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
async queryStream<T extends Record<string, unknown> = Record<string, unknown>>(
|
||||||
|
sql: string,
|
||||||
|
onRow: (row: T) => void,
|
||||||
|
): Promise<number> {
|
||||||
|
this.ensureReady();
|
||||||
|
const stmt = parseAll(sql)[0];
|
||||||
|
if (!stmt || stmt.type !== 'SELECT') {
|
||||||
|
throw new DatabaseError('queryStream only supports SELECT statements', 'NOT_SUPPORTED');
|
||||||
|
}
|
||||||
|
const select = stmt as import('./query/ast').SelectStatement;
|
||||||
|
|
||||||
|
// 不可流式场景:JOIN / GROUP BY / HAVING / DISTINCT / 聚合 / UNION / 关联子查询 / ORDER BY
|
||||||
|
const aggregate = select.columns.some((c) => /^(COUNT|SUM|AVG|MIN|MAX)\(/i.test(c));
|
||||||
|
const streamable = !select.joins && !select.groupBy && !select.having && !select.distinct
|
||||||
|
&& !aggregate && !(select.orderBy && select.orderBy.length > 0)
|
||||||
|
&& !(select.where && select.where['$exists'] !== undefined);
|
||||||
|
|
||||||
|
if (streamable && typeof this.engine.findStream === 'function') {
|
||||||
|
// 用户回调为 async(返回 Promise)时引擎同步扫描无法 await → 回退物化
|
||||||
|
const isAsync = (onRow as { constructor?: { name?: string } }).constructor?.name === 'AsyncFunction';
|
||||||
|
if (!isAsync) {
|
||||||
|
const where = this.normalizeWhereForStream(select);
|
||||||
|
const plainCols = select.columns.filter((c) => !/\s+AS\s+\w+$/i.test(c));
|
||||||
|
return this.engine.findStream(select.from, {
|
||||||
|
table: select.from,
|
||||||
|
columns: plainCols.length > 0 && plainCols[0] !== '*' ? plainCols : ['*'],
|
||||||
|
where: where && Object.keys(where).length > 0 ? where : undefined,
|
||||||
|
limit: select.limit,
|
||||||
|
offset: select.offset,
|
||||||
|
}, onRow as (row: Record<string, unknown>) => void);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退:物化后逐行回调
|
||||||
|
const result = await this.query(sql);
|
||||||
|
if (Array.isArray(result)) {
|
||||||
|
for (const row of result as T[]) {
|
||||||
|
await onRow(row);
|
||||||
|
}
|
||||||
|
return result.length;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 流式查询用:剥离主表别名前缀(复用 query 路径的规范化逻辑) */
|
||||||
|
private normalizeWhereForStream(select: import('./query/ast').SelectStatement): import('./constants').WhereCondition | undefined {
|
||||||
|
const aliases = [select.alias ?? select.from].filter(Boolean);
|
||||||
|
const strip = (col: string): string => {
|
||||||
|
for (const a of aliases) {
|
||||||
|
if (col.startsWith(`${a}.`)) return col.slice(a.length + 1);
|
||||||
|
}
|
||||||
|
return col;
|
||||||
|
};
|
||||||
|
const walk = (w: import('./constants').WhereCondition): import('./constants').WhereCondition => {
|
||||||
|
const out: import('./constants').WhereCondition = {};
|
||||||
|
for (const [k, v] of Object.entries(w)) {
|
||||||
|
if (k === '$and' || k === '$or') {
|
||||||
|
out[k] = (v as import('./constants').WhereCondition[]).map(walk);
|
||||||
|
} else if (k === '$not' && typeof v === 'object' && v !== null) {
|
||||||
|
out.$not = walk(v as import('./constants').WhereCondition);
|
||||||
|
} else {
|
||||||
|
out[strip(k)] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
return walk(select.where ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 事务 ----
|
// ---- 事务 ----
|
||||||
|
|
||||||
/** 执行事务 */
|
/** 执行事务 */
|
||||||
|
|||||||
+384
-28
@@ -2,7 +2,7 @@
|
|||||||
* AriaEngine — 自研页面式存储引擎主类
|
* AriaEngine — 自研页面式存储引擎主类
|
||||||
* @module engine/aria/index
|
* @module engine/aria/index
|
||||||
*
|
*
|
||||||
* v0.2.5: WAL 同步修复 + MVCC 接入 + 版本统一 + 生产加固
|
* v0.4.1: 外键级联 + ALTER TABLE 重写 + clearAll 重置 + 崩溃恢复加固
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { IStorageEngine } from '../interface';
|
import type { IStorageEngine } from '../interface';
|
||||||
@@ -161,10 +161,22 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
// 第二遍:仅应用 txnId==0(非事务)或已提交事务的数据
|
// 第二遍:仅应用 txnId==0(非事务)或已提交事务的数据
|
||||||
for (const r of allRecords) {
|
for (const r of allRecords) {
|
||||||
if (r.txnId === 0 || committedTxns.has(r.txnId)) {
|
if (r.txnId === 0 || committedTxns.has(r.txnId)) {
|
||||||
this.applyWALRecord(r);
|
if (r.type === WALRecordType.DROP_TABLE) {
|
||||||
|
// v0.3.3: DROP_TABLE 回放(异步:需预加载 SSTable 后清除残留数据)
|
||||||
|
await this.applyDropTableRecovery(r.tableName);
|
||||||
|
} else {
|
||||||
|
this.applyWALRecord(r);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.3.3: 恢复完成后将回放数据落盘并截断 WAL,
|
||||||
|
// 避免每次重启重复回放 + WAL 无限膨胀
|
||||||
|
if (allRecords.length > 0) {
|
||||||
|
await this.lsm.flush();
|
||||||
|
await this.wal.checkpoint();
|
||||||
|
}
|
||||||
|
|
||||||
// 8. Checkpoint Manager(接入 WAL 大小阈值)
|
// 8. Checkpoint Manager(接入 WAL 大小阈值)
|
||||||
this.checkpointManager = new CheckpointManager(
|
this.checkpointManager = new CheckpointManager(
|
||||||
this.lsm,
|
this.lsm,
|
||||||
@@ -187,6 +199,29 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
this.opened = false;
|
this.opened = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.4.1: 重置数据库 — 清空全部数据与表结构(演示页刷新/重新初始化用)。
|
||||||
|
* 清空存储后端、LSM、WAL、MVCC 与二级索引,后续可继续使用本实例。
|
||||||
|
*/
|
||||||
|
async clearAll(): Promise<void> {
|
||||||
|
this.ensureOpen();
|
||||||
|
// 清空存储后端(页面文件 / WAL 记录 / schema 记录 / 元数据)
|
||||||
|
await this.backend.clear();
|
||||||
|
this.schemas.clear();
|
||||||
|
this.tablePKs.clear();
|
||||||
|
this.secondaryIndexes.clear();
|
||||||
|
this.lsm.clear();
|
||||||
|
this.mvcc = new MVCCManager();
|
||||||
|
this.currentTxnId = null;
|
||||||
|
this.txnSnapshot = null;
|
||||||
|
this.savepoints.clear();
|
||||||
|
this.opCounter = 0;
|
||||||
|
// 持久化空 schema(防止旧 schema 记录残留)
|
||||||
|
await this.persistSchemas();
|
||||||
|
// 重置 WAL 状态(backend.clear 已清记录,同步内存计数)
|
||||||
|
await this.wal.checkpoint();
|
||||||
|
}
|
||||||
|
|
||||||
isOpen(): boolean { return this.opened; }
|
isOpen(): boolean { return this.opened; }
|
||||||
|
|
||||||
// =======================================================================
|
// =======================================================================
|
||||||
@@ -203,8 +238,9 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
this.tablePKs.set(schema.name, this.getPK(schema));
|
this.tablePKs.set(schema.name, this.getPK(schema));
|
||||||
|
|
||||||
// 为索引列创建二级索引 LSM(每个索引使用独立命名空间的 SSTableStore,避免 id/meta 冲突)
|
// 为索引列创建二级索引 LSM(每个索引使用独立命名空间的 SSTableStore,避免 id/meta 冲突)
|
||||||
|
// v0.3.3: 主键列不建冗余二级索引(主 LSM 本身就是 PK 索引,范围查询走前缀扫描)
|
||||||
for (const [colName, colDef] of Object.entries(schema.columns)) {
|
for (const [colName, colDef] of Object.entries(schema.columns)) {
|
||||||
if (colDef.index || colDef.unique || colDef.primaryKey) {
|
if (colDef.index || colDef.unique) {
|
||||||
const idxKey = `${schema.name}:idx:${colName}`;
|
const idxKey = `${schema.name}:idx:${colName}`;
|
||||||
if (!this.secondaryIndexes.has(idxKey)) {
|
if (!this.secondaryIndexes.has(idxKey)) {
|
||||||
const idxLsm = new LSM({
|
const idxLsm = new LSM({
|
||||||
@@ -344,24 +380,8 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
rows = await this.getAllRows(tableName);
|
rows = await this.getAllRows(tableName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge transaction snapshot writes (uncommitted data visible within txn)
|
// v0.3.3: 事务内合并未提交快照(统一在 mergeTxnSnapshot 处理)
|
||||||
if (this.currentTxnId && this.txnSnapshot) {
|
rows = this.mergeTxnSnapshot(tableName, rows);
|
||||||
const pkCol = this.tablePKs.get(tableName)!;
|
|
||||||
const prefix = `${tableName}:`;
|
|
||||||
for (const [key, value] of this.txnSnapshot) {
|
|
||||||
if (!key.startsWith(prefix)) continue;
|
|
||||||
const pk = key.slice(prefix.length);
|
|
||||||
const del = (value as unknown as Record<string, unknown>).__txn_deleted;
|
|
||||||
const idx = rows.findIndex((r) => r[pkCol] === pk);
|
|
||||||
if (del) {
|
|
||||||
if (idx >= 0) rows.splice(idx, 1);
|
|
||||||
} else {
|
|
||||||
const row = { ...value, [pkCol]: pk };
|
|
||||||
if (idx >= 0) rows[idx] = row;
|
|
||||||
else rows.push(row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WHERE filter
|
// WHERE filter
|
||||||
if (query.where && Object.keys(query.where).length > 0) {
|
if (query.where && Object.keys(query.where).length > 0) {
|
||||||
@@ -447,12 +467,16 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
let count = 0;
|
let count = 0;
|
||||||
// v0.3.1: 批量 WAL 写入(组提交)
|
// v0.3.1: 批量 WAL 写入(组提交)
|
||||||
const walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[] = [];
|
const walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[] = [];
|
||||||
|
// v0.4.1: 外键级联(环路保护)
|
||||||
|
const visited = new Set<string>();
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const pkCol = this.tablePKs.get(tableName)!;
|
const pkCol = this.tablePKs.get(tableName)!;
|
||||||
const key = `${tableName}:${row[pkCol]}`;
|
const key = `${tableName}:${row[pkCol]}`;
|
||||||
|
|
||||||
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
|
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
|
||||||
|
// v0.4.1: 外键规则(RESTRICT 抛错 / CASCADE 递归删 / SET NULL 置空)
|
||||||
|
count += await this.applyForeignKeyRules(tableName, String(row[pkCol]), walRecords, visited);
|
||||||
if (this.currentTxnId && this.txnSnapshot) {
|
if (this.currentTxnId && this.txnSnapshot) {
|
||||||
// Buffer delete in snapshot + MVCC tombstone
|
// Buffer delete in snapshot + MVCC tombstone
|
||||||
this.txnSnapshot.set(key, { __txn_deleted: true } as unknown as Record<string, unknown>);
|
this.txnSnapshot.set(key, { __txn_deleted: true } as unknown as Record<string, unknown>);
|
||||||
@@ -482,6 +506,154 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.4.1: 外键级联规则 — 对齐 MemoryEngine.cascadeDelete 行为。
|
||||||
|
* 删除 tableName 主键为 pkValue 的行前,检查引用它的所有表:
|
||||||
|
* - RESTRICT: 存在引用行 → 抛 FOREIGN_KEY_VIOLATION
|
||||||
|
* - CASCADE: 递归删除引用行(含索引/WAL)
|
||||||
|
* - SET NULL: 引用行外键列置 null(含索引/WAL)
|
||||||
|
* @returns 级联影响的行数(CASCADE 删除行数 + SET NULL 更新行数)
|
||||||
|
*/
|
||||||
|
private async applyForeignKeyRules(
|
||||||
|
tableName: string,
|
||||||
|
pkValue: string,
|
||||||
|
walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[],
|
||||||
|
visited: Set<string>,
|
||||||
|
): Promise<number> {
|
||||||
|
let total = 0;
|
||||||
|
const visitKey = `${tableName}:${pkValue}`;
|
||||||
|
if (visited.has(visitKey)) return 0;
|
||||||
|
visited.add(visitKey);
|
||||||
|
|
||||||
|
for (const [refTableName, refSchema] of this.schemas) {
|
||||||
|
if (refTableName === tableName) continue;
|
||||||
|
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
|
||||||
|
if (!colDef.references || !colDef.onDelete) continue;
|
||||||
|
const [refTable] = colDef.references.split('.');
|
||||||
|
if (refTable !== tableName) continue;
|
||||||
|
|
||||||
|
const refRows = await this.getAllRows(refTableName);
|
||||||
|
const matched = refRows.filter((r) => String(r[colName]) === pkValue);
|
||||||
|
|
||||||
|
if (colDef.onDelete === 'RESTRICT' && matched.length > 0) {
|
||||||
|
throw new DatabaseError(
|
||||||
|
`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" has dependent rows`,
|
||||||
|
'FOREIGN_KEY_VIOLATION',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (colDef.onDelete === 'CASCADE') {
|
||||||
|
const refPkCol = this.tablePKs.get(refTableName)!;
|
||||||
|
for (const refRow of matched) {
|
||||||
|
const refPk = String(refRow[refPkCol]);
|
||||||
|
// 递归级联(先处理更深层引用)
|
||||||
|
total += await this.applyForeignKeyRules(refTableName, refPk, walRecords, visited);
|
||||||
|
// 删除引用行
|
||||||
|
const refKey = `${refTableName}:${refPk}`;
|
||||||
|
if (this.currentTxnId && this.txnSnapshot) {
|
||||||
|
this.txnSnapshot.set(refKey, { __txn_deleted: true } as unknown as Record<string, unknown>);
|
||||||
|
this.mvcc.deleteVersion(refTableName, refPk, this.currentTxnId);
|
||||||
|
} else {
|
||||||
|
this.lsm.delete(refKey);
|
||||||
|
}
|
||||||
|
this.updateSecondaryIndexes(refTableName, refPk, null, refRow);
|
||||||
|
walRecords.push({
|
||||||
|
type: WALRecordType.DELETE,
|
||||||
|
txnId: this.currentTxnId ?? 0,
|
||||||
|
tableName: refTableName,
|
||||||
|
key: refPk,
|
||||||
|
});
|
||||||
|
total++;
|
||||||
|
}
|
||||||
|
} else if (colDef.onDelete === 'SET NULL') {
|
||||||
|
const refPkCol = this.tablePKs.get(refTableName)!;
|
||||||
|
for (const refRow of matched) {
|
||||||
|
const refPk = String(refRow[refPkCol]);
|
||||||
|
const updated = { ...refRow, [colName]: null };
|
||||||
|
const refKey = `${refTableName}:${refPk}`;
|
||||||
|
if (this.currentTxnId && this.txnSnapshot) {
|
||||||
|
this.txnSnapshot.set(refKey, updated);
|
||||||
|
this.mvcc.writeVersion(refTableName, refPk, updated, this.currentTxnId);
|
||||||
|
} else {
|
||||||
|
this.lsm.put(refKey, updated);
|
||||||
|
}
|
||||||
|
this.updateSecondaryIndexes(refTableName, refPk, updated, refRow);
|
||||||
|
walRecords.push({
|
||||||
|
type: WALRecordType.UPDATE,
|
||||||
|
txnId: this.currentTxnId ?? 0,
|
||||||
|
tableName: refTableName,
|
||||||
|
key: refPk,
|
||||||
|
data: updated,
|
||||||
|
});
|
||||||
|
// 对齐 Memory 语义:SET NULL 不影响返回的删除行数
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.4.0: 流式查询 — 逐行回调,不物化结果数组。
|
||||||
|
* 全表路径走 LSM rangeScanLazy 惰性扫描;索引等值/范围路径复用 tryIndexLookup。
|
||||||
|
* 事务中回退物化(快照合并需要全量行集)。
|
||||||
|
*/
|
||||||
|
async findStream(
|
||||||
|
tableName: string,
|
||||||
|
query: QueryPlan,
|
||||||
|
onRow: (row: Record<string, unknown>) => void,
|
||||||
|
): Promise<number> {
|
||||||
|
this.ensureOpen();
|
||||||
|
this.ensureTable(tableName);
|
||||||
|
|
||||||
|
const hasWhere = !!(query.where && Object.keys(query.where).length > 0);
|
||||||
|
const project = query.columns && query.columns.length > 0 && query.columns[0] !== '*'
|
||||||
|
? (row: Record<string, unknown>) => projectColumns(row, query.columns!)
|
||||||
|
: null;
|
||||||
|
const limit = query.limit ?? Infinity;
|
||||||
|
const offset = query.offset ?? 0;
|
||||||
|
const pkCol = this.tablePKs.get(tableName)!;
|
||||||
|
const prefix = `${tableName}:`;
|
||||||
|
let count = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
const emit = (row: Record<string, unknown>): boolean => {
|
||||||
|
if (hasWhere && !matchWhere(row, query.where!)) return true;
|
||||||
|
if (skipped < offset) { skipped++; return true; }
|
||||||
|
onRow(project ? project(row) : row);
|
||||||
|
count++;
|
||||||
|
return count < limit;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.currentTxnId && this.txnSnapshot) {
|
||||||
|
// 事务中:物化后逐行回调(快照合并需要全量行集)
|
||||||
|
const rows = await this.find(tableName, { ...query, orderBy: undefined, limit: undefined, offset: undefined });
|
||||||
|
for (const row of rows) {
|
||||||
|
onRow(project ? project(row) : row);
|
||||||
|
}
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 索引路径:等值/范围查找(结果行已过滤,直接回调)
|
||||||
|
const fastPath = await this.tryIndexLookup(tableName, query);
|
||||||
|
if (fastPath !== null) {
|
||||||
|
for (const row of fastPath) {
|
||||||
|
if (!emit(row)) break;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全表惰性扫描(含 WHERE 过滤,不物化)
|
||||||
|
await this.lsm.prefetchRange(prefix, `${prefix}\uffff`);
|
||||||
|
this.lsm.rangeScanLazy(prefix, `${prefix}\uffff`, (key, value) => {
|
||||||
|
if (count >= limit) return;
|
||||||
|
const row = { ...value };
|
||||||
|
row[pkCol] = key.slice(prefix.length);
|
||||||
|
emit(row);
|
||||||
|
});
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
async count(tableName: string, query?: QueryPlan): Promise<number> {
|
async count(tableName: string, query?: QueryPlan): Promise<number> {
|
||||||
this.ensureOpen();
|
this.ensureOpen();
|
||||||
this.ensureTable(tableName);
|
this.ensureTable(tableName);
|
||||||
@@ -495,13 +667,93 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
this.ensureOpen();
|
this.ensureOpen();
|
||||||
this.ensureTable(tableName);
|
this.ensureTable(tableName);
|
||||||
const rows = await this.getAllRows(tableName);
|
const rows = await this.getAllRows(tableName);
|
||||||
|
// v0.3.3: 事务内清空走快照(删除标记),提交时生效;并写入 WAL
|
||||||
|
const walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[] = [];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const pkCol = this.tablePKs.get(tableName)!;
|
const pkCol = this.tablePKs.get(tableName)!;
|
||||||
this.lsm.delete(`${tableName}:${row[pkCol]}`);
|
const key = `${tableName}:${row[pkCol]}`;
|
||||||
|
if (this.currentTxnId && this.txnSnapshot) {
|
||||||
|
this.txnSnapshot.set(key, { __txn_deleted: true } as unknown as Record<string, unknown>);
|
||||||
|
this.mvcc.deleteVersion(tableName, String(row[pkCol]), this.currentTxnId);
|
||||||
|
} else {
|
||||||
|
this.lsm.delete(key);
|
||||||
|
}
|
||||||
|
walRecords.push({
|
||||||
|
type: WALRecordType.DELETE,
|
||||||
|
txnId: this.currentTxnId ?? 0,
|
||||||
|
tableName,
|
||||||
|
key: String(row[pkCol]),
|
||||||
|
});
|
||||||
|
// 移除二级索引
|
||||||
|
this.updateSecondaryIndexes(tableName, String(row[pkCol]), null, row);
|
||||||
}
|
}
|
||||||
|
await this.wal.appendBatch(walRecords);
|
||||||
|
this.opCounter += rows.length;
|
||||||
|
await this.checkpointManager.tick();
|
||||||
|
this.tryGC();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 动态索引(v0.3.0) ----
|
// ---- ALTER TABLE(v0.4.1) ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.4.1: ALTER TABLE — 结构变更真正生效于存储:
|
||||||
|
* - ADD: 持久化 schema(persistSchemas),行无需修改
|
||||||
|
* - DROP: 持久化 schema + 遍历主 LSM 重写所有行(移除该列键)+ WAL UPDATE 记录
|
||||||
|
* (通用路径 getTableSchema 返回副本,Executor 的引用修改对 Aria 无效)
|
||||||
|
*/
|
||||||
|
async alterTable(
|
||||||
|
tableName: string,
|
||||||
|
action: 'ADD' | 'DROP',
|
||||||
|
column: import('../../constants').ColumnDef & { name: string },
|
||||||
|
): Promise<void> {
|
||||||
|
this.ensureOpen();
|
||||||
|
this.ensureTable(tableName);
|
||||||
|
const schema = this.schemas.get(tableName)!;
|
||||||
|
|
||||||
|
if (action === 'ADD') {
|
||||||
|
if (schema.columns[column.name]) {
|
||||||
|
throw new DatabaseError(`Column "${column.name}" already exists in table "${tableName}"`, 'COLUMN_EXISTS');
|
||||||
|
}
|
||||||
|
schema.columns[column.name] = column;
|
||||||
|
await this.persistSchemas();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DROP
|
||||||
|
if (!schema.columns[column.name]) {
|
||||||
|
throw new DatabaseError(`Column "${column.name}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND');
|
||||||
|
}
|
||||||
|
delete schema.columns[column.name];
|
||||||
|
await this.persistSchemas();
|
||||||
|
|
||||||
|
// 重写主 LSM:移除所有行的该列键(find 副本无法就地删除,必须重写存储)
|
||||||
|
const pkCol = this.tablePKs.get(tableName)!;
|
||||||
|
const prefix = `${tableName}:`;
|
||||||
|
const endKey = `${prefix}\uffff`;
|
||||||
|
await this.lsm.prefetchRange(prefix, endKey);
|
||||||
|
const entries = this.lsm.rangeScan(prefix, endKey);
|
||||||
|
const walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[] = [];
|
||||||
|
for (const [key, value] of entries) {
|
||||||
|
if (!(column.name in value)) continue;
|
||||||
|
const updated = { ...value };
|
||||||
|
delete updated[column.name];
|
||||||
|
this.lsm.put(key, updated);
|
||||||
|
// 二级索引列被删时同步清理索引
|
||||||
|
const pk = key.slice(prefix.length);
|
||||||
|
this.updateSecondaryIndexes(tableName, pk, updated, value);
|
||||||
|
walRecords.push({
|
||||||
|
type: WALRecordType.UPDATE,
|
||||||
|
txnId: this.currentTxnId ?? 0,
|
||||||
|
tableName,
|
||||||
|
key: pk,
|
||||||
|
data: updated,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.wal.appendBatch(walRecords);
|
||||||
|
this.opCounter += walRecords.length;
|
||||||
|
await this.checkpointManager.tick();
|
||||||
|
this.trimAllCaches();
|
||||||
|
}
|
||||||
|
|
||||||
async createIndex(tableName: string, column: string, unique?: boolean): Promise<void> {
|
async createIndex(tableName: string, column: string, unique?: boolean): Promise<void> {
|
||||||
this.ensureOpen();
|
this.ensureOpen();
|
||||||
@@ -551,6 +803,10 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
if (colDef.primaryKey) {
|
if (colDef.primaryKey) {
|
||||||
throw new DatabaseError(`Cannot drop primary key index on column "${column}"`, 'NOT_SUPPORTED');
|
throw new DatabaseError(`Cannot drop primary key index on column "${column}"`, 'NOT_SUPPORTED');
|
||||||
}
|
}
|
||||||
|
// v0.4.1: DROP 不存在的索引应报错(此前静默成功)
|
||||||
|
if (!colDef.index && !colDef.unique && !this.secondaryIndexes.has(`${tableName}:idx:${column}`)) {
|
||||||
|
throw new DatabaseError(`Index on column "${column}" does not exist in table "${tableName}"`, 'INDEX_NOT_FOUND');
|
||||||
|
}
|
||||||
colDef.index = false;
|
colDef.index = false;
|
||||||
colDef.unique = false;
|
colDef.unique = false;
|
||||||
|
|
||||||
@@ -610,6 +866,15 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
async rollbackTransaction(): Promise<void> {
|
async rollbackTransaction(): Promise<void> {
|
||||||
if (!this.currentTxnId) throw new DatabaseError('No active transaction', 'TX_NONE');
|
if (!this.currentTxnId) throw new DatabaseError('No active transaction', 'TX_NONE');
|
||||||
|
|
||||||
|
// v0.3.3: 记录事务涉及的表(用于回滚后重建索引,消除索引残留)
|
||||||
|
const affectedTables = new Set<string>();
|
||||||
|
if (this.txnSnapshot) {
|
||||||
|
for (const key of this.txnSnapshot.keys()) {
|
||||||
|
const idx = key.indexOf(':');
|
||||||
|
if (idx > 0) affectedTables.add(key.slice(0, idx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.mvcc.rollbackTransaction(this.currentTxnId);
|
this.mvcc.rollbackTransaction(this.currentTxnId);
|
||||||
this.txnSnapshot = null;
|
this.txnSnapshot = null;
|
||||||
|
|
||||||
@@ -621,6 +886,13 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.currentTxnId = null;
|
this.currentTxnId = null;
|
||||||
|
|
||||||
|
// v0.3.3: 事务内直接写入了二级索引 LSM,回滚后全量重建受影响表的索引
|
||||||
|
for (const tableName of affectedTables) {
|
||||||
|
if (this.schemas.has(tableName)) {
|
||||||
|
await this.reindexTable(tableName);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Savepoint 嵌套事务 ----
|
// ---- Savepoint 嵌套事务 ----
|
||||||
@@ -642,6 +914,9 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
if (!sp) throw new DatabaseError(`Savepoint "${name}" not found`, 'SAVEPOINT_NOT_FOUND');
|
if (!sp) throw new DatabaseError(`Savepoint "${name}" not found`, 'SAVEPOINT_NOT_FOUND');
|
||||||
// 恢复到 savepoint 时的快照
|
// 恢复到 savepoint 时的快照
|
||||||
this.txnSnapshot = sp.snapshot ? new Map(sp.snapshot) : null;
|
this.txnSnapshot = sp.snapshot ? new Map(sp.snapshot) : null;
|
||||||
|
// v0.3.3: 清理该事务在 MVCC 版本链中的全部记录(快照已含正确数据,
|
||||||
|
// 版本链仅作 undo 记录,清空后 commit 时 LSM 写入与快照保持一致)
|
||||||
|
this.mvcc.discardVersions(this.currentTxnId!);
|
||||||
// 清除此 savepoint 之后的所有 savepoint
|
// 清除此 savepoint 之后的所有 savepoint
|
||||||
let found = false;
|
let found = false;
|
||||||
for (const [k] of this.savepoints) {
|
for (const [k] of this.savepoints) {
|
||||||
@@ -676,11 +951,37 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
// 预加载范围内涉及的 SSTable,避免 rangeScan 时缓存未命中静默丢数据
|
// 预加载范围内涉及的 SSTable,避免 rangeScan 时缓存未命中静默丢数据
|
||||||
await this.lsm.prefetchRange(prefix, `${prefix}\uffff`);
|
await this.lsm.prefetchRange(prefix, `${prefix}\uffff`);
|
||||||
const entries = this.lsm.rangeScan(prefix, `${prefix}\uffff`);
|
const entries = this.lsm.rangeScan(prefix, `${prefix}\uffff`);
|
||||||
return entries.map(([key, value]) => {
|
const rows = entries.map(([key, value]) => {
|
||||||
const row = { ...value };
|
const row = { ...value };
|
||||||
row[pkCol] = key.slice(prefix.length);
|
row[pkCol] = key.slice(prefix.length);
|
||||||
return row;
|
return row;
|
||||||
});
|
});
|
||||||
|
// v0.3.3: 事务内合并未提交快照(update/delete/count/clear 也能看到本事务的写入)
|
||||||
|
return this.mergeTxnSnapshot(tableName, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.3.3: 将事务未提交快照的变更合并到行列表(新增/更新/删除标记)。
|
||||||
|
* 幂等操作:行已是最新时不重复修改。
|
||||||
|
*/
|
||||||
|
private mergeTxnSnapshot(tableName: string, rows: Record<string, unknown>[]): Record<string, unknown>[] {
|
||||||
|
if (!this.currentTxnId || !this.txnSnapshot) return rows;
|
||||||
|
const pkCol = this.tablePKs.get(tableName)!;
|
||||||
|
const prefix = `${tableName}:`;
|
||||||
|
for (const [key, value] of this.txnSnapshot) {
|
||||||
|
if (!key.startsWith(prefix)) continue;
|
||||||
|
const pk = key.slice(prefix.length);
|
||||||
|
const del = (value as unknown as Record<string, unknown>).__txn_deleted;
|
||||||
|
const idx = rows.findIndex((r) => r[pkCol] === pk);
|
||||||
|
if (del) {
|
||||||
|
if (idx >= 0) rows.splice(idx, 1);
|
||||||
|
} else {
|
||||||
|
const row = { ...value, [pkCol]: pk };
|
||||||
|
if (idx >= 0) rows[idx] = row;
|
||||||
|
else rows.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPK(schema: TableSchema): string {
|
private getPK(schema: TableSchema): string {
|
||||||
@@ -872,11 +1173,31 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
case WALRecordType.COMMIT:
|
case WALRecordType.COMMIT:
|
||||||
case WALRecordType.ROLLBACK:
|
case WALRecordType.ROLLBACK:
|
||||||
case WALRecordType.BEGIN:
|
case WALRecordType.BEGIN:
|
||||||
case WALRecordType.DROP_TABLE:
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.3.3: DROP_TABLE 恢复 — 删除 schema 并清除主 LSM 中该表的所有残留数据。
|
||||||
|
*
|
||||||
|
* 此前 DROP_TABLE 在恢复时被忽略,而 CREATE_TABLE 回放会重建 schema,
|
||||||
|
* 导致崩溃后"已删除的表和数据复活"(实证 P0 bug)。
|
||||||
|
*/
|
||||||
|
private async applyDropTableRecovery(tableName: string): Promise<void> {
|
||||||
|
if (!tableName) return;
|
||||||
|
this.schemas.delete(tableName);
|
||||||
|
this.tablePKs.delete(tableName);
|
||||||
|
|
||||||
|
// 清除主 LSM 中该表前缀的所有数据(含 SSTable 中的旧数据)
|
||||||
|
const prefix = `${tableName}:`;
|
||||||
|
const endKey = `${prefix}\uffff`;
|
||||||
|
await this.lsm.prefetchRange(prefix, endKey);
|
||||||
|
const entries = this.lsm.rangeScan(prefix, endKey);
|
||||||
|
for (const [key] of entries) {
|
||||||
|
this.lsm.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =======================================================================
|
// =======================================================================
|
||||||
// 二级索引
|
// 二级索引
|
||||||
// =======================================================================
|
// =======================================================================
|
||||||
@@ -891,7 +1212,8 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
if (!schema) return;
|
if (!schema) return;
|
||||||
|
|
||||||
for (const [colName, colDef] of Object.entries(schema.columns)) {
|
for (const [colName, colDef] of Object.entries(schema.columns)) {
|
||||||
if (!colDef.index && !colDef.unique && !colDef.primaryKey) continue;
|
// v0.3.3: 主键列不建冗余二级索引(主 LSM 即 PK 索引)
|
||||||
|
if (!colDef.index && !colDef.unique) continue;
|
||||||
const idxKey = `${tableName}:idx:${colName}`;
|
const idxKey = `${tableName}:idx:${colName}`;
|
||||||
const idxLsm = this.secondaryIndexes.get(idxKey);
|
const idxLsm = this.secondaryIndexes.get(idxKey);
|
||||||
if (!idxLsm) continue;
|
if (!idxLsm) continue;
|
||||||
@@ -947,6 +1269,32 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
const value = this.lsm.get(key);
|
const value = this.lsm.get(key);
|
||||||
return value ? [{ ...value, [pkCol]: cond.$eq }] : [];
|
return value ? [{ ...value, [pkCol]: cond.$eq }] : [];
|
||||||
}
|
}
|
||||||
|
// v0.3.3: PK $in → 主 LSM 多次精确查找(替代冗余 PK 二级索引)
|
||||||
|
if ('$in' in cond && Array.isArray(cond.$in)) {
|
||||||
|
const keys = cond.$in.map((v) => `${tableName}:${v}`);
|
||||||
|
await this.lsm.prefetchKeys(keys);
|
||||||
|
const rows: Record<string, unknown>[] = [];
|
||||||
|
const seen = new Set<string>(); // v0.4.1: IN 子查询可能含重复值,按 pk 去重
|
||||||
|
for (const v of cond.$in) {
|
||||||
|
const pk = String(v);
|
||||||
|
if (seen.has(pk)) continue;
|
||||||
|
const value = this.lsm.get(`${tableName}:${pk}`);
|
||||||
|
if (value) { seen.add(pk); rows.push({ ...value, [pkCol]: pk }); }
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
// v0.3.3: PK 范围查询 → 主 LSM 前缀扫描 + 条件过滤(修复字符串算术 bug)
|
||||||
|
if ('$gt' in cond || '$gte' in cond || '$lt' in cond || '$lte' in cond) {
|
||||||
|
const prefix = `${tableName}:`;
|
||||||
|
await this.lsm.prefetchRange(prefix, `${prefix}\uffff`);
|
||||||
|
const entries = this.lsm.rangeScan(prefix, `${prefix}\uffff`);
|
||||||
|
const rows: Record<string, unknown>[] = [];
|
||||||
|
for (const [key, value] of entries) {
|
||||||
|
const candidate = { ...value, [pkCol]: key.slice(prefix.length) };
|
||||||
|
if (matchWhere(candidate, { [pkCol]: condition })) rows.push(candidate);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 二级索引查找
|
// 二级索引查找
|
||||||
@@ -966,9 +1314,16 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
// $in → 多次精确查找
|
// $in → 多次精确查找
|
||||||
if ('$in' in c && Array.isArray(c.$in)) {
|
if ('$in' in c && Array.isArray(c.$in)) {
|
||||||
const results: Record<string, unknown>[] = [];
|
const results: Record<string, unknown>[] = [];
|
||||||
|
const seenPks = new Set<string>(); // v0.4.1: IN 值可能重复,按 pk 去重
|
||||||
for (const val of c.$in) {
|
for (const val of c.$in) {
|
||||||
const rows = await this.indexScanToRows(tableName, pkCol, idxLsm, String(val), String(val));
|
const rows = await this.indexScanToRows(tableName, pkCol, idxLsm, String(val), String(val));
|
||||||
results.push(...rows);
|
for (const row of rows) {
|
||||||
|
const pk = String(row[pkCol]);
|
||||||
|
if (!seenPks.has(pk)) {
|
||||||
|
seenPks.add(pk);
|
||||||
|
results.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
@@ -1091,7 +1446,8 @@ export class AriaEngine implements IStorageEngine {
|
|||||||
let rebuiltCount = 0;
|
let rebuiltCount = 0;
|
||||||
|
|
||||||
for (const [colName, colDef] of Object.entries(schema.columns)) {
|
for (const [colName, colDef] of Object.entries(schema.columns)) {
|
||||||
if (!colDef.index && !colDef.unique && !colDef.primaryKey) continue;
|
// v0.3.3: 主键列不建冗余二级索引(主 LSM 即 PK 索引)
|
||||||
|
if (!colDef.index && !colDef.unique) continue;
|
||||||
const idxKey = `${tableName}:idx:${colName}`;
|
const idxKey = `${tableName}:idx:${colName}`;
|
||||||
const idxLsm = this.secondaryIndexes.get(idxKey);
|
const idxLsm = this.secondaryIndexes.get(idxKey);
|
||||||
if (!idxLsm) continue;
|
if (!idxLsm) continue;
|
||||||
|
|||||||
@@ -183,6 +183,21 @@ export class MVCCManager {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.3.3: 丢弃指定事务的所有版本记录,但保留事务登记(Savepoint 回滚用)。
|
||||||
|
* 快照数据由调用方(引擎 txnSnapshot)负责恢复。
|
||||||
|
*/
|
||||||
|
discardVersions(txnId: number): void {
|
||||||
|
for (const [tableKey, versions] of this.versionStore) {
|
||||||
|
const filtered = versions.filter((v) => v.txnId !== txnId);
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
this.versionStore.delete(tableKey);
|
||||||
|
} else {
|
||||||
|
this.versionStore.set(tableKey, filtered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清理过旧版本(GC)。
|
* 清理过旧版本(GC)。
|
||||||
* 保留每个 key 的最新 N 个已提交版本。
|
* 保留每个 key 的最新 N 个已提交版本。
|
||||||
|
|||||||
@@ -48,9 +48,14 @@ export class CheckpointManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 估算 WAL 大小 */
|
/** 估算 WAL 大小(优先真实字节数,回退到缓冲计数估算) */
|
||||||
private getWALEstimatedSize(): number {
|
private getWALEstimatedSize(): number {
|
||||||
const count = typeof this.wal.getBufferedCount === 'function' ? this.wal.getBufferedCount() : 0;
|
const wal = this.wal as unknown as { getBufferedBytes?: () => number; getBufferedCount?: () => number };
|
||||||
|
if (typeof wal.getBufferedBytes === 'function') {
|
||||||
|
const bytes = wal.getBufferedBytes();
|
||||||
|
if (bytes > 0) return bytes;
|
||||||
|
}
|
||||||
|
const count = typeof wal.getBufferedCount === 'function' ? wal.getBufferedCount() : 0;
|
||||||
return count * 200;
|
return count * 200;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export class WAL {
|
|||||||
private enabled: boolean;
|
private enabled: boolean;
|
||||||
private buffer: Uint8Array[] = [];
|
private buffer: Uint8Array[] = [];
|
||||||
private syncMode: 'full' | 'batch' | 'none';
|
private syncMode: 'full' | 'batch' | 'none';
|
||||||
|
/** v0.3.3: 未 checkpoint 的 WAL 累计字节数(full/batch/none 通用) */
|
||||||
|
private bufferedBytes = 0;
|
||||||
|
|
||||||
constructor(store: WALStore, enabled: boolean = true, syncMode: 'full' | 'batch' | 'none' = 'batch') {
|
constructor(store: WALStore, enabled: boolean = true, syncMode: 'full' | 'batch' | 'none' = 'batch') {
|
||||||
this.store = store;
|
this.store = store;
|
||||||
@@ -74,12 +76,14 @@ export class WAL {
|
|||||||
if (this.syncMode === 'full') {
|
if (this.syncMode === 'full') {
|
||||||
try {
|
try {
|
||||||
await this.store.append(bytes);
|
await this.store.append(bytes);
|
||||||
|
this.bufferedBytes += bytes.byteLength;
|
||||||
} catch {
|
} catch {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.warn('[AriaEngine WAL] Failed to append record');
|
console.warn('[AriaEngine WAL] Failed to append record');
|
||||||
}
|
}
|
||||||
} else if (this.syncMode === 'batch') {
|
} else if (this.syncMode === 'batch') {
|
||||||
this.buffer.push(bytes);
|
this.buffer.push(bytes);
|
||||||
|
this.bufferedBytes += bytes.byteLength;
|
||||||
}
|
}
|
||||||
// 'none' mode: 不写 WAL
|
// 'none' mode: 不写 WAL
|
||||||
}
|
}
|
||||||
@@ -98,12 +102,14 @@ export class WAL {
|
|||||||
if (this.syncMode === 'full') {
|
if (this.syncMode === 'full') {
|
||||||
try {
|
try {
|
||||||
await this.store.append(combined);
|
await this.store.append(combined);
|
||||||
|
this.bufferedBytes += combined.byteLength;
|
||||||
} catch {
|
} catch {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.warn('[AriaEngine WAL] Failed to append batch record');
|
console.warn('[AriaEngine WAL] Failed to append batch record');
|
||||||
}
|
}
|
||||||
} else if (this.syncMode === 'batch') {
|
} else if (this.syncMode === 'batch') {
|
||||||
this.buffer.push(combined);
|
this.buffer.push(combined);
|
||||||
|
this.bufferedBytes += combined.byteLength;
|
||||||
}
|
}
|
||||||
// 'none' mode: 不写 WAL
|
// 'none' mode: 不写 WAL
|
||||||
}
|
}
|
||||||
@@ -165,6 +171,7 @@ export class WAL {
|
|||||||
await this.flush();
|
await this.flush();
|
||||||
await this.store.truncate();
|
await this.store.truncate();
|
||||||
this.lsn = 0;
|
this.lsn = 0;
|
||||||
|
this.bufferedBytes = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =======================================================================
|
// =======================================================================
|
||||||
@@ -183,6 +190,11 @@ export class WAL {
|
|||||||
return this.buffer.length;
|
return this.buffer.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** v0.3.3: 未 checkpoint 的 WAL 累计字节数(full/batch/none 通用) */
|
||||||
|
getBufferedBytes(): number {
|
||||||
|
return this.bufferedBytes;
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// 编解码
|
// 编解码
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -163,6 +163,20 @@ export class IndexedDBEngine implements IStorageEngine {
|
|||||||
return this.idbFind(tableName, query);
|
return this.idbFind(tableName, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** v0.4.0: 流式查询 — IDB 批量读入后逐行回调(保持接口一致性) */
|
||||||
|
async findStream(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number> {
|
||||||
|
if (this.txActive) {
|
||||||
|
return this.memoryCache.findStream(tableName, query, onRow);
|
||||||
|
}
|
||||||
|
const rows = await this.idbFind(tableName, { ...query, orderBy: undefined, limit: undefined, offset: undefined });
|
||||||
|
let count = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
onRow(row);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
|
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
|
||||||
const count = await this.memoryCache.update(tableName, query, updates);
|
const count = await this.memoryCache.update(tableName, query, updates);
|
||||||
if (this.txActive) return count;
|
if (this.txActive) return count;
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ export interface IStorageEngine {
|
|||||||
/** 查询行 */
|
/** 查询行 */
|
||||||
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
||||||
|
|
||||||
|
/** v0.4.0: 流式查询 — 逐行回调扫描(有 where/limit/projection,无 orderBy 语义;有 orderBy 时实现可回退物化) */
|
||||||
|
findStream?(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number>;
|
||||||
|
|
||||||
/** 更新行,返回影响行数 */
|
/** 更新行,返回影响行数 */
|
||||||
update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number>;
|
update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number>;
|
||||||
|
|
||||||
@@ -55,6 +58,9 @@ export interface IStorageEngine {
|
|||||||
/** 清空表数据(保留结构) */
|
/** 清空表数据(保留结构) */
|
||||||
clear(tableName: string): Promise<void>;
|
clear(tableName: string): Promise<void>;
|
||||||
|
|
||||||
|
/** v0.4.1: ALTER TABLE(可选)— 引擎级结构变更(Aria 需重写存储行,其余引擎走 Executor 通用路径) */
|
||||||
|
alterTable?(tableName: string, action: 'ADD' | 'DROP', column: import('../constants').ColumnDef & { name: string }): Promise<void>;
|
||||||
|
|
||||||
// ---- 动态索引(可选,v0.3.0) ----
|
// ---- 动态索引(可选,v0.3.0) ----
|
||||||
|
|
||||||
/** 创建二级索引(CREATE INDEX) */
|
/** 创建二级索引(CREATE INDEX) */
|
||||||
|
|||||||
+74
-9
@@ -97,6 +97,29 @@ export class MemoryEngine implements IStorageEngine {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** v0.4.0: 流式查询 — 逐行回调(单次迭代,不物化结果数组) */
|
||||||
|
async findStream(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number> {
|
||||||
|
this.ensureTable(tableName);
|
||||||
|
const table = this.tables.get(tableName)!;
|
||||||
|
const hasWhere = !!(query.where && Object.keys(query.where).length > 0);
|
||||||
|
const limit = query.limit ?? Infinity;
|
||||||
|
const offset = query.offset ?? 0;
|
||||||
|
const project = query.columns && query.columns.length > 0 && query.columns[0] !== '*'
|
||||||
|
? (row: Record<string, unknown>) => projectColumns(row, query.columns!)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
for (const row of table.values()) {
|
||||||
|
if (hasWhere && !matchWhere(row, query.where!)) continue;
|
||||||
|
if (skipped < offset) { skipped++; continue; }
|
||||||
|
onRow(project ? project(row) : row);
|
||||||
|
count++;
|
||||||
|
if (count >= limit) break;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
|
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
|
||||||
this.ensureTable(tableName);
|
this.ensureTable(tableName);
|
||||||
const schema = this.schemas.get(tableName)!;
|
const schema = this.schemas.get(tableName)!;
|
||||||
@@ -104,9 +127,13 @@ export class MemoryEngine implements IStorageEngine {
|
|||||||
let count = 0;
|
let count = 0;
|
||||||
for (const [pk, row] of table) {
|
for (const [pk, row] of table) {
|
||||||
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
|
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
|
||||||
|
// v0.3.3: 先移除旧值索引条目(修复 update 后唯一约束被绕过、按新值查索引丢行)
|
||||||
|
this.removeIndexEntries(tableName, row, pk);
|
||||||
const updated = { ...row, ...updates };
|
const updated = { ...row, ...updates };
|
||||||
this.validateRow(schema, updated);
|
this.validateRow(schema, updated);
|
||||||
|
this.checkUniqueness(schema, updated);
|
||||||
table.set(pk, updated);
|
table.set(pk, updated);
|
||||||
|
this.updateIndexes(tableName, updated, pk);
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,6 +146,8 @@ export class MemoryEngine implements IStorageEngine {
|
|||||||
const toDelete: string[] = [];
|
const toDelete: string[] = [];
|
||||||
for (const [pk, row] of table) {
|
for (const [pk, row] of table) {
|
||||||
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
|
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
|
||||||
|
// v0.3.3: 删除行前清理其索引条目(修复删除后索引残留)
|
||||||
|
this.removeIndexEntries(tableName, row, pk);
|
||||||
toDelete.push(pk);
|
toDelete.push(pk);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,6 +206,10 @@ export class MemoryEngine implements IStorageEngine {
|
|||||||
const schema = this.schemas.get(tableName)!;
|
const schema = this.schemas.get(tableName)!;
|
||||||
const colDef = schema.columns[column];
|
const colDef = schema.columns[column];
|
||||||
if (!colDef) throw new DatabaseError(`Column "${column}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND');
|
if (!colDef) throw new DatabaseError(`Column "${column}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND');
|
||||||
|
// v0.4.1: DROP 不存在的索引应报错(此前静默成功)
|
||||||
|
if (!colDef.index && !colDef.unique) {
|
||||||
|
throw new DatabaseError(`Index on column "${column}" does not exist in table "${tableName}"`, 'INDEX_NOT_FOUND');
|
||||||
|
}
|
||||||
colDef.index = false;
|
colDef.index = false;
|
||||||
colDef.unique = false;
|
colDef.unique = false;
|
||||||
const tableIndexes = this.indexes.get(tableName);
|
const tableIndexes = this.indexes.get(tableName);
|
||||||
@@ -288,17 +321,24 @@ export class MemoryEngine implements IStorageEngine {
|
|||||||
const tableIndexes = this.indexes.get(tableName);
|
const tableIndexes = this.indexes.get(tableName);
|
||||||
if (!tableIndexes || !query.where) return Array.from(table.values());
|
if (!tableIndexes || !query.where) return Array.from(table.values());
|
||||||
for (const [col, condition] of Object.entries(query.where)) {
|
for (const [col, condition] of Object.entries(query.where)) {
|
||||||
|
// v0.4.1: 支持 { $eq: value } 形式(SQL 解析器生成的等值条件)走索引
|
||||||
|
let targetValue: unknown;
|
||||||
if (typeof condition !== 'object' || condition === null) {
|
if (typeof condition !== 'object' || condition === null) {
|
||||||
const colIndex = tableIndexes.get(col);
|
targetValue = condition;
|
||||||
if (colIndex) {
|
} else if ('$eq' in (condition as Record<string, unknown>) && Object.keys(condition as Record<string, unknown>).length === 1) {
|
||||||
const pks = colIndex.get(condition);
|
targetValue = (condition as Record<string, unknown>).$eq;
|
||||||
if (pks) {
|
} else {
|
||||||
const result: Record<string, unknown>[] = [];
|
continue;
|
||||||
for (const pk of pks) { const r = table.get(pk); if (r) result.push(r); }
|
}
|
||||||
return result;
|
const colIndex = tableIndexes.get(col);
|
||||||
}
|
if (colIndex) {
|
||||||
return [];
|
const pks = colIndex.get(targetValue);
|
||||||
|
if (pks) {
|
||||||
|
const result: Record<string, unknown>[] = [];
|
||||||
|
for (const pk of pks) { const r = table.get(pk); if (r) result.push(r); }
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Array.from(table.values());
|
return Array.from(table.values());
|
||||||
@@ -317,6 +357,22 @@ export class MemoryEngine implements IStorageEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** v0.3.3: 从所有索引中移除一行的条目(update/delete 前调用,修复索引过期/残留) */
|
||||||
|
private removeIndexEntries(tableName: string, row: Record<string, unknown>, pk: string): void {
|
||||||
|
const tableIndexes = this.indexes.get(tableName);
|
||||||
|
if (!tableIndexes) return;
|
||||||
|
for (const [colName, colIndex] of tableIndexes) {
|
||||||
|
const value = row[colName];
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
const pks = colIndex.get(value);
|
||||||
|
if (pks) {
|
||||||
|
pks.delete(pk);
|
||||||
|
if (pks.size === 0) colIndex.delete(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 外键级联 ----
|
// ---- 外键级联 ----
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -359,6 +415,8 @@ export class MemoryEngine implements IStorageEngine {
|
|||||||
for (const refPk of toDelete) {
|
for (const refPk of toDelete) {
|
||||||
const refRow = refTableData.get(refPk);
|
const refRow = refTableData.get(refPk);
|
||||||
if (refRow) {
|
if (refRow) {
|
||||||
|
// v0.3.3: 级联删除前清理索引条目
|
||||||
|
this.removeIndexEntries(refTableName, refRow, refPk);
|
||||||
totalCascade += await this.cascadeDelete(refTableName, refPk, refRow);
|
totalCascade += await this.cascadeDelete(refTableName, refPk, refRow);
|
||||||
}
|
}
|
||||||
refTableData.delete(refPk);
|
refTableData.delete(refPk);
|
||||||
@@ -368,6 +426,13 @@ export class MemoryEngine implements IStorageEngine {
|
|||||||
for (const refPk of toDelete) {
|
for (const refPk of toDelete) {
|
||||||
const refRow = refTableData.get(refPk);
|
const refRow = refTableData.get(refPk);
|
||||||
if (refRow) {
|
if (refRow) {
|
||||||
|
// v0.3.3: 外键列置空后同步更新索引
|
||||||
|
if (refRow[colName] !== undefined && refRow[colName] !== null) {
|
||||||
|
const pks = this.indexes.get(refTableName)?.get(colName);
|
||||||
|
if (pks) {
|
||||||
|
pks.get(refRow[colName])?.delete(refPk);
|
||||||
|
}
|
||||||
|
}
|
||||||
refRow[colName] = null;
|
refRow[colName] = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,11 @@ export class OPFSEngine implements IStorageEngine {
|
|||||||
return this.memoryCache.find(tableName, query);
|
return this.memoryCache.find(tableName, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** v0.4.0: 流式查询(委托内存缓存) */
|
||||||
|
async findStream(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number> {
|
||||||
|
return this.memoryCache.findStream(tableName, query, onRow);
|
||||||
|
}
|
||||||
|
|
||||||
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
|
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
|
||||||
const count = await this.memoryCache.update(tableName, query, updates);
|
const count = await this.memoryCache.update(tableName, query, updates);
|
||||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||||
|
|||||||
@@ -125,6 +125,11 @@ export class HybridEngine implements IStorageEngine {
|
|||||||
return this.memoryEngine.find(tableName, query);
|
return this.memoryEngine.find(tableName, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** v0.4.0: 流式查询(内存引擎逐行回调) */
|
||||||
|
async findStream(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number> {
|
||||||
|
return this.memoryEngine.findStream(tableName, query, onRow);
|
||||||
|
}
|
||||||
|
|
||||||
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
|
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
|
||||||
const count = await this.memoryEngine.update(tableName, query, updates);
|
const count = await this.memoryEngine.update(tableName, query, updates);
|
||||||
// write-through: 同步更新磁盘
|
// write-through: 同步更新磁盘
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* metona-sqlark — 入口文件
|
* metona-sqlark — 入口文件
|
||||||
* @module metona-sqlark
|
* @module metona-sqlark
|
||||||
* @version 0.2.5
|
* @version 0.4.1
|
||||||
*
|
*
|
||||||
* 前端关系型数据库,内存与磁盘双模式。
|
* 前端关系型数据库,内存与磁盘双模式。
|
||||||
* 支持 Query Builder 链式 API 和 SQL 字符串查询。
|
* 支持 Query Builder 链式 API 和 SQL 字符串查询。
|
||||||
|
|||||||
@@ -168,6 +168,8 @@ export interface SelectStatement {
|
|||||||
columns: ColumnRef[];
|
columns: ColumnRef[];
|
||||||
distinct?: boolean;
|
distinct?: boolean;
|
||||||
from: string;
|
from: string;
|
||||||
|
/** v0.4.0: FROM (SELECT ...) 派生表(存在时 from 为占位,行源取此子查询结果) */
|
||||||
|
fromSubquery?: SelectStatement | SelectUnionStatement;
|
||||||
/** 主表别名 */
|
/** 主表别名 */
|
||||||
alias?: string;
|
alias?: string;
|
||||||
/** JOIN 子句列表 */
|
/** JOIN 子句列表 */
|
||||||
|
|||||||
+243
-60
@@ -218,8 +218,26 @@ export class QueryExecutor {
|
|||||||
// 引擎层取全行,投影统一在 executor 端完成
|
// 引擎层取全行,投影统一在 executor 端完成
|
||||||
const needsRawRows = this.hasCaseColumn(stmt.columns) ||
|
const needsRawRows = this.hasCaseColumn(stmt.columns) ||
|
||||||
(!!stmt.where && this.whereHasCase(stmt.where));
|
(!!stmt.where && this.whereHasCase(stmt.where));
|
||||||
|
// v0.3.3: ORDER BY 引用 SELECT 别名 → 引擎层不排序/不截断,投影后再排序
|
||||||
|
const orderByAlias = this.orderByUsesSelectAlias(stmt);
|
||||||
|
// v0.3.3: SELECT 列含 `col AS alias` → 引擎层投影会丢失源列,统一取原始行由 executor 投影
|
||||||
|
const hasSelectAlias = stmt.columns.some((c) => /\s+AS\s+\w+$/i.test(c));
|
||||||
|
|
||||||
if (isJoinQuery) {
|
if (stmt.fromSubquery) {
|
||||||
|
// v0.4.0: FROM (SELECT ...) 派生表 — 子查询结果作为行源
|
||||||
|
const subRows = await this.executeSelectPart(stmt.fromSubquery);
|
||||||
|
rows = isJoinQuery
|
||||||
|
? await this.executeJoinSelect(stmt, subRows.map((row) => this.prefixRow(row, stmt.alias ?? '')))
|
||||||
|
: subRows;
|
||||||
|
if (!isJoinQuery && stmt.where && Object.keys(stmt.where).length > 0) {
|
||||||
|
// 非 JOIN:WHERE 在 executor 端过滤(子查询结果不经引擎)
|
||||||
|
stmt.where = await this.resolveSubqueries(stmt.where);
|
||||||
|
rows = rows.filter((row) => matchWhere(row, stmt.where));
|
||||||
|
}
|
||||||
|
} else if (!stmt.from && !isJoinQuery) {
|
||||||
|
// v0.4.0: 无表查询(SELECT 1 / SELECT 'lit')— 单行空上下文,常量列投影
|
||||||
|
rows = [{}];
|
||||||
|
} else if (isJoinQuery) {
|
||||||
// JOIN 路径:行带表别名前缀(如 'd.id'),WHERE 保持原名不剥离
|
// JOIN 路径:行带表别名前缀(如 'd.id'),WHERE 保持原名不剥离
|
||||||
rows = await this.executeJoinSelect(stmt);
|
rows = await this.executeJoinSelect(stmt);
|
||||||
} else {
|
} else {
|
||||||
@@ -227,11 +245,31 @@ export class QueryExecutor {
|
|||||||
if (stmt.where && Object.keys(stmt.where).length > 0) {
|
if (stmt.where && Object.keys(stmt.where).length > 0) {
|
||||||
stmt.where = this.normalizeWhereColumns(stmt.where, [stmt.alias ?? stmt.from]);
|
stmt.where = this.normalizeWhereColumns(stmt.where, [stmt.alias ?? stmt.from]);
|
||||||
}
|
}
|
||||||
|
// v0.4.0: ORDER BY / GROUP BY 带表前缀同样剥离(如 ORDER BY u.age)
|
||||||
|
const mainAliases = [stmt.alias ?? stmt.from].filter(Boolean);
|
||||||
|
if (stmt.orderBy && stmt.orderBy.length > 0) {
|
||||||
|
stmt.orderBy = stmt.orderBy.map((o) => ({ ...o, column: this.stripAlias(o.column, mainAliases) }));
|
||||||
|
}
|
||||||
|
if (stmt.groupBy && stmt.groupBy.length > 0) {
|
||||||
|
stmt.groupBy = stmt.groupBy.map((c) => this.stripAlias(c, mainAliases));
|
||||||
|
}
|
||||||
|
// v0.4.0: SELECT 列带表前缀剥离(SELECT u.name → name,行键无前缀)
|
||||||
|
stmt.columns = stmt.columns.map((c) => {
|
||||||
|
if (c === '*' || /^(COUNT|SUM|AVG|MIN|MAX)\(/i.test(c) || /^\s*CASE\b/i.test(c) || /^'/.test(c)) return c;
|
||||||
|
const m = c.match(/^(.+?)\s+AS\s+(\w+)$/i);
|
||||||
|
if (m) {
|
||||||
|
const stripped = this.stripAlias(m[1].trim(), mainAliases);
|
||||||
|
return stripped === m[1].trim() ? c : `${stripped} AS ${m[2]}`;
|
||||||
|
}
|
||||||
|
return this.stripAlias(c, mainAliases);
|
||||||
|
});
|
||||||
|
|
||||||
// WHERE 含关联子查询($col 引用外层行)→ 逐行绑定上下文求值
|
// WHERE 含关联子查询($col 引用外层行)→ 逐行绑定上下文求值
|
||||||
if (stmt.where && this.hasCorrelatedRefs(stmt.where)) {
|
if (stmt.where && this.hasCorrelatedRefs(stmt.where)) {
|
||||||
const plan = compileStatement(hasGroupBy || hasAggregate ? { ...stmt, columns: ['*'] } : stmt);
|
const plan = compileStatement(hasGroupBy || hasAggregate ? { ...stmt, columns: ['*'] } : stmt);
|
||||||
if (needsRawRows) plan.columns = ['*'];
|
// v0.4.0 修复: 关联子查询需要完整外层行(SELECT 列可能不含被 $col 引用的列,如 EXISTS 绑定的主键)
|
||||||
|
plan.columns = ['*'];
|
||||||
|
if (orderByAlias) { plan.orderBy = undefined; plan.limit = undefined; plan.offset = undefined; }
|
||||||
rows = await this.engine.find(plan.table, { ...plan, where: this.stripCorrelatedExists(stmt.where) });
|
rows = await this.engine.find(plan.table, { ...plan, where: this.stripCorrelatedExists(stmt.where) });
|
||||||
rows = await this.filterCorrelated(rows, stmt.where);
|
rows = await this.filterCorrelated(rows, stmt.where);
|
||||||
} else {
|
} else {
|
||||||
@@ -240,7 +278,8 @@ export class QueryExecutor {
|
|||||||
stmt.where = await this.resolveSubqueries(stmt.where);
|
stmt.where = await this.resolveSubqueries(stmt.where);
|
||||||
}
|
}
|
||||||
const plan = compileStatement(hasGroupBy || hasAggregate ? { ...stmt, columns: ['*'] } : stmt);
|
const plan = compileStatement(hasGroupBy || hasAggregate ? { ...stmt, columns: ['*'] } : stmt);
|
||||||
if (needsRawRows) plan.columns = ['*'];
|
if (needsRawRows || hasSelectAlias) plan.columns = ['*'];
|
||||||
|
if (orderByAlias) { plan.orderBy = undefined; plan.limit = undefined; plan.offset = undefined; }
|
||||||
rows = await this.engine.find(plan.table, plan);
|
rows = await this.engine.find(plan.table, plan);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,15 +292,30 @@ export class QueryExecutor {
|
|||||||
if (hasGroupBy) rows = this.executeGroupBy(rows, stmt);
|
if (hasGroupBy) rows = this.executeGroupBy(rows, stmt);
|
||||||
if (stmt.distinct) rows = this.executeDistinct(rows);
|
if (stmt.distinct) rows = this.executeDistinct(rows);
|
||||||
if (stmt.having && Object.keys(stmt.having).length > 0) {
|
if (stmt.having && Object.keys(stmt.having).length > 0) {
|
||||||
|
// v0.4.0 修复: HAVING 中的标量子查询(HAVING SUM(o.amount) > (SELECT AVG(...)))需先解析
|
||||||
|
stmt.having = await this.resolveSubqueries(stmt.having);
|
||||||
|
// v0.4.0: HAVING 引用聚合表达式键(如 SUM(o.amount))时归一为别名键(如 spent)
|
||||||
|
const aliasMap = (stmt as unknown as { _aggAliasMap?: Map<string, string> })._aggAliasMap;
|
||||||
|
if (aliasMap && aliasMap.size > 0) {
|
||||||
|
const normalized: WhereCondition = {};
|
||||||
|
for (const [k, v] of Object.entries(stmt.having)) {
|
||||||
|
normalized[aliasMap.get(k) ?? k] = v;
|
||||||
|
}
|
||||||
|
stmt.having = normalized;
|
||||||
|
}
|
||||||
rows = rows.filter((row) => matchWhere(row, stmt.having!));
|
rows = rows.filter((row) => matchWhere(row, stmt.having!));
|
||||||
}
|
}
|
||||||
if (stmt.orderBy && stmt.orderBy.length > 0) rows = applyOrderBy(rows, stmt.orderBy);
|
if (stmt.orderBy && stmt.orderBy.length > 0) rows = applyOrderBy(rows, stmt.orderBy);
|
||||||
const offset = stmt.offset ?? 0;
|
|
||||||
const limit = stmt.limit ?? rows.length;
|
|
||||||
rows = rows.slice(offset, offset + limit);
|
|
||||||
if (!hasGroupBy && !hasAggregate && stmt.columns.length > 0 && stmt.columns[0] !== '*') {
|
if (!hasGroupBy && !hasAggregate && stmt.columns.length > 0 && stmt.columns[0] !== '*') {
|
||||||
rows = rows.map((row) => this.projectRow(row, stmt.columns));
|
rows = rows.map((row) => this.projectRow(row, stmt.columns));
|
||||||
}
|
}
|
||||||
|
// v0.3.3: ORDER BY 别名 → 投影后才存在,需在投影后重新排序
|
||||||
|
if (orderByAlias && stmt.orderBy && stmt.orderBy.length > 0) {
|
||||||
|
rows = applyOrderBy(rows, stmt.orderBy);
|
||||||
|
}
|
||||||
|
const offset = stmt.offset ?? 0;
|
||||||
|
const limit = stmt.limit ?? rows.length;
|
||||||
|
rows = rows.slice(offset, offset + limit);
|
||||||
|
|
||||||
// 全局行数上限保护
|
// 全局行数上限保护
|
||||||
if (this.maxRowsPerQuery > 0 && rows.length > this.maxRowsPerQuery) {
|
if (this.maxRowsPerQuery > 0 && rows.length > this.maxRowsPerQuery) {
|
||||||
@@ -273,10 +327,20 @@ export class QueryExecutor {
|
|||||||
|
|
||||||
// ---- JOIN ----
|
// ---- JOIN ----
|
||||||
|
|
||||||
private async executeJoinSelect(stmt: SelectStatement): Promise<Record<string, unknown>[]> {
|
private async executeJoinSelect(stmt: SelectStatement, preloadedMain?: Record<string, unknown>[]): Promise<Record<string, unknown>[]> {
|
||||||
const mainAlias = stmt.alias ?? stmt.from;
|
const mainAlias = stmt.alias ?? stmt.from;
|
||||||
const mainRows = (await this.engine.find(stmt.from, { table: stmt.from }))
|
// v0.4.0: 派生表行源已预加载(行带别名前缀)
|
||||||
.map((row) => this.prefixRow(row, mainAlias));
|
let mainRows: Record<string, unknown>[];
|
||||||
|
if (preloadedMain) {
|
||||||
|
mainRows = preloadedMain;
|
||||||
|
} else {
|
||||||
|
// v0.4.1: WHERE 中主表前缀等值条件下推到引擎(走二级索引,如 WHERE o.user_id = '1')
|
||||||
|
const { pushable } = this.extractPushableWhere(stmt.where ?? {}, mainAlias);
|
||||||
|
mainRows = (await this.engine.find(stmt.from, {
|
||||||
|
table: stmt.from,
|
||||||
|
where: Object.keys(pushable).length > 0 ? pushable : undefined,
|
||||||
|
})).map((row) => this.prefixRow(row, mainAlias));
|
||||||
|
}
|
||||||
let resultRows = mainRows;
|
let resultRows = mainRows;
|
||||||
|
|
||||||
for (const join of stmt.joins!) {
|
for (const join of stmt.joins!) {
|
||||||
@@ -312,7 +376,28 @@ export class QueryExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 哈希连接(v0.3.2):ON 为单一等值条件且右表列为索引/主键时,
|
* v0.4.1: 提取可下推的 WHERE 条件 — 主表别名前缀的普通条件(如 o.user_id = '1')。
|
||||||
|
* 下推到引擎可走二级索引;$col/$subquery/$and/$or/$not 等复杂条件保守不下推。
|
||||||
|
*/
|
||||||
|
private extractPushableWhere(where: WhereCondition, mainAlias: string): { pushable: WhereCondition } {
|
||||||
|
const pushable: WhereCondition = {};
|
||||||
|
if (!mainAlias) return { pushable };
|
||||||
|
const prefix = `${mainAlias}.`;
|
||||||
|
for (const [key, value] of Object.entries(where)) {
|
||||||
|
if (!key.startsWith(prefix)) continue;
|
||||||
|
const v = value as Record<string, unknown> | null;
|
||||||
|
if (typeof v === 'object' && v !== null &&
|
||||||
|
('$col' in v || '$subquery' in v || '$and' in v || '$or' in v || '$not' in v)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pushable[key.slice(prefix.length)] = value;
|
||||||
|
}
|
||||||
|
return { pushable };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 哈希连接(v0.3.2 单等值 / v0.4.0 多列等值):
|
||||||
|
* ON 为等值条件(单列或多列)且右表任一列为索引/主键时,
|
||||||
* 收集左表连接值 → 一次 $in 查询右表 → 哈希映射匹配。
|
* 收集左表连接值 → 一次 $in 查询右表 → 哈希映射匹配。
|
||||||
* 替代嵌套循环,大表 INNER/LEFT JOIN 复杂度 O(N + M)。
|
* 替代嵌套循环,大表 INNER/LEFT JOIN 复杂度 O(N + M)。
|
||||||
* 不适用时返回 null(回退嵌套循环)。
|
* 不适用时返回 null(回退嵌套循环)。
|
||||||
@@ -325,52 +410,64 @@ export class QueryExecutor {
|
|||||||
): Promise<Record<string, unknown>[] | null> {
|
): Promise<Record<string, unknown>[] | null> {
|
||||||
if (join.type === 'CROSS' || join.type === 'RIGHT') return null;
|
if (join.type === 'CROSS' || join.type === 'RIGHT') return null;
|
||||||
|
|
||||||
// 提取单一等值条件:{ colA: { $eq: { $col: colB } } } 或 { colA: { $col: colB } }
|
// 解析 ON 为 (leftCol, rightCol) 等值对列表(v0.4.0 支持多列,含顶层 $and 展开)
|
||||||
const keys = Object.keys(join.on);
|
const pairs: { leftCol: string; rightCol: string }[] = [];
|
||||||
if (keys.length !== 1) return null;
|
const collectPairs = (on: WhereCondition): boolean => {
|
||||||
const keyCol = keys[0];
|
for (const [keyCol, cond] of Object.entries(on)) {
|
||||||
const cond = join.on[keyCol] as Record<string, unknown> | null | undefined;
|
if (keyCol === '$and') {
|
||||||
|
if (!(cond as WhereCondition[]).every(collectPairs)) return false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (keyCol === '$or' || keyCol === '$not') return false; // 非等值逻辑不适用
|
||||||
|
let refCol: string | null = null;
|
||||||
|
if (typeof cond === 'object' && cond !== null) {
|
||||||
|
const c = cond as Record<string, unknown>;
|
||||||
|
if ('$eq' in c && typeof c.$eq === 'object' && c.$eq !== null && '$col' in (c.$eq as Record<string, unknown>)) {
|
||||||
|
refCol = String((c.$eq as Record<string, unknown>).$col);
|
||||||
|
} else if ('$col' in c && Object.keys(c).length === 1) {
|
||||||
|
refCol = String(c.$col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!refCol) return false; // 非等值条件不适用哈希连接
|
||||||
|
|
||||||
let refCol: string | null = null;
|
const keyIsLeft = mainAlias ? keyCol.startsWith(`${mainAlias}.`) : false;
|
||||||
if (typeof cond === 'object' && cond !== null) {
|
pairs.push({
|
||||||
if ('$eq' in cond && typeof cond.$eq === 'object' && cond.$eq !== null && '$col' in (cond.$eq as Record<string, unknown>)) {
|
leftCol: keyIsLeft ? keyCol : refCol,
|
||||||
refCol = String((cond.$eq as Record<string, unknown>).$col);
|
rightCol: keyIsLeft ? refCol : keyCol,
|
||||||
} else if ('$col' in cond && Object.keys(cond).length === 1) {
|
});
|
||||||
refCol = String(cond.$col);
|
|
||||||
}
|
}
|
||||||
}
|
return true;
|
||||||
if (!refCol) return null;
|
};
|
||||||
|
if (!collectPairs(join.on)) return null;
|
||||||
|
if (pairs.length === 0) return null;
|
||||||
|
|
||||||
// 方向判定:键/值哪个属于左表(mainAlias 前缀)?
|
// 右表列必须是主键/索引列(确保 $in 走索引)——任一列即可
|
||||||
// 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);
|
const schema = await this.engine.getTableSchema(join.table);
|
||||||
if (!schema) return null;
|
if (!schema) return null;
|
||||||
const bareRightCol = rightCol.split('.').pop()!;
|
const probePair = pairs.find((p) => {
|
||||||
const colDef = schema.columns[bareRightCol];
|
const bare = p.rightCol.split('.').pop()!;
|
||||||
if (!colDef || (!colDef.primaryKey && !colDef.index && !colDef.unique)) return null;
|
const colDef = schema.columns[bare];
|
||||||
|
return colDef && (colDef.primaryKey || colDef.index || colDef.unique);
|
||||||
|
});
|
||||||
|
if (!probePair) return null;
|
||||||
|
|
||||||
// 收集左表连接值(去重)
|
// 收集左表连接值(去重)——用探测列的值缩小候选集
|
||||||
const values = Array.from(new Set(leftRows.map((r) => r[leftCol]).filter((v) => v !== undefined && v !== null)));
|
const probeRightBare = probePair.rightCol.split('.').pop()!;
|
||||||
|
const values = Array.from(new Set(leftRows.map((r) => r[probePair.leftCol]).filter((v) => v !== undefined && v !== null)));
|
||||||
if (values.length === 0) return null;
|
if (values.length === 0) return null;
|
||||||
|
|
||||||
// 一次 $in 查询右表
|
// 一次 $in 查询右表(缩小候选集)
|
||||||
const rightRows = await this.engine.find(join.table, {
|
const rightRows = await this.engine.find(join.table, {
|
||||||
table: join.table,
|
table: join.table,
|
||||||
where: { [bareRightCol]: { $in: values } },
|
where: { [probeRightBare]: { $in: values } },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 构建哈希映射:右列值 → 行列表
|
// 构建复合键哈希映射:右表多列值 → 行列表
|
||||||
const hash = new Map<unknown, Record<string, unknown>[]>();
|
const hash = new Map<string, Record<string, unknown>[]>();
|
||||||
for (const rr of rightRows) {
|
for (const rr of rightRows) {
|
||||||
const v = rr[bareRightCol];
|
const key = pairs.map((p) => String(rr[p.rightCol.split('.').pop()!] ?? '\0')).join('\x1f');
|
||||||
if (v === undefined || v === null) continue;
|
if (!hash.has(key)) hash.set(key, []);
|
||||||
if (!hash.has(v)) hash.set(v, []);
|
hash.get(key)!.push(rr);
|
||||||
hash.get(v)!.push(rr);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const nullRight: Record<string, unknown> = {};
|
const nullRight: Record<string, unknown> = {};
|
||||||
@@ -378,8 +475,8 @@ export class QueryExecutor {
|
|||||||
|
|
||||||
const result: Record<string, unknown>[] = [];
|
const result: Record<string, unknown>[] = [];
|
||||||
for (const l of leftRows) {
|
for (const l of leftRows) {
|
||||||
const lv = l[leftCol];
|
const key = pairs.map((p) => String(l[p.leftCol] ?? '\0')).join('\x1f');
|
||||||
const matches = hash.get(lv);
|
const matches = hash.get(key);
|
||||||
if (matches && matches.length > 0) {
|
if (matches && matches.length > 0) {
|
||||||
for (const r of matches) {
|
for (const r of matches) {
|
||||||
result.push({ ...l, ...this.prefixRow(r, joinAlias) });
|
result.push({ ...l, ...this.prefixRow(r, joinAlias) });
|
||||||
@@ -449,6 +546,8 @@ export class QueryExecutor {
|
|||||||
groups.get(key)!.push(row);
|
groups.get(key)!.push(row);
|
||||||
}
|
}
|
||||||
const result: Record<string, unknown>[] = [];
|
const result: Record<string, unknown>[] = [];
|
||||||
|
// v0.4.0: 聚合表达式键 → 输出键 映射(HAVING SUM(...) 引用表达式时归一为别名键)
|
||||||
|
const aliasMap = new Map<string, string>();
|
||||||
for (const groupRows of groups.values()) {
|
for (const groupRows of groups.values()) {
|
||||||
const aggregated: Record<string, unknown> = {};
|
const aggregated: Record<string, unknown> = {};
|
||||||
for (const col of stmt.groupBy!) aggregated[col] = groupRows[0][col];
|
for (const col of stmt.groupBy!) aggregated[col] = groupRows[0][col];
|
||||||
@@ -457,7 +556,11 @@ export class QueryExecutor {
|
|||||||
const m = colExpr.match(/^(COUNT|SUM|AVG|MIN|MAX)\((.+?)\)(?:\s+AS\s+(\w+))?$/i);
|
const m = colExpr.match(/^(COUNT|SUM|AVG|MIN|MAX)\((.+?)\)(?:\s+AS\s+(\w+))?$/i);
|
||||||
if (m) {
|
if (m) {
|
||||||
const [, func, arg, alias] = m;
|
const [, func, arg, alias] = m;
|
||||||
aggregated[alias || colExpr] = this.computeAggregate(func.toUpperCase(), groupRows, arg.trim());
|
const value = this.computeAggregate(func.toUpperCase(), groupRows, arg.trim());
|
||||||
|
const exprKey = `${func.toUpperCase()}(${arg.trim()})`;
|
||||||
|
const outputKey = alias || colExpr;
|
||||||
|
if (outputKey !== exprKey) aliasMap.set(exprKey, outputKey);
|
||||||
|
aggregated[outputKey] = value;
|
||||||
} else if (/^\s*CASE\b/i.test(colExpr)) {
|
} else if (/^\s*CASE\b/i.test(colExpr)) {
|
||||||
// v0.3.2: 非聚合的 CASE WHEN 列取组内第一行求值
|
// v0.3.2: 非聚合的 CASE WHEN 列取组内第一行求值
|
||||||
const expr = parseCaseExpression(colExpr);
|
const expr = parseCaseExpression(colExpr);
|
||||||
@@ -468,22 +571,34 @@ export class QueryExecutor {
|
|||||||
}
|
}
|
||||||
result.push(aggregated);
|
result.push(aggregated);
|
||||||
}
|
}
|
||||||
|
(stmt as unknown as { _aggAliasMap?: Map<string, string> })._aggAliasMap = aliasMap;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private computeAggregate(func: string, rows: Record<string, unknown>[], col: string): number {
|
private computeAggregate(func: string, rows: Record<string, unknown>[], col: string): number {
|
||||||
// v0.3.2: 聚合参数支持 CASE WHEN 表达式(如 SUM(CASE WHEN age > 18 THEN 1 ELSE 0 END))
|
// 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 caseExpr = /^\s*CASE\b/i.test(col) ? parseCaseExpression(col) : null;
|
||||||
const nums = rows
|
// v0.4.0: COUNT(DISTINCT col) 等去重聚合
|
||||||
.map((r) => (caseExpr ? evaluateCase(caseExpr, r) : r[col]))
|
const distinctArg = !caseExpr && /^\s*DISTINCT\s+/i.test(col);
|
||||||
.filter((v) => v !== null && v !== undefined)
|
const argCol = distinctArg ? col.replace(/^\s*DISTINCT\s+/i, '').trim() : col;
|
||||||
.map(Number);
|
const rawValues = rows
|
||||||
|
.map((r) => (caseExpr ? evaluateCase(caseExpr, r) : r[argCol]))
|
||||||
|
.filter((v) => v !== null && v !== undefined);
|
||||||
|
// v0.4.0: COUNT 对原始值去重(任意类型);数值聚合在类型转换后去重
|
||||||
|
if (func === 'COUNT') {
|
||||||
|
if (argCol === '*') return rows.length;
|
||||||
|
if (distinctArg) {
|
||||||
|
return new Set(rawValues.map((v) => (typeof v === 'object' ? JSON.stringify(v) : String(v)))).size;
|
||||||
|
}
|
||||||
|
return rawValues.length;
|
||||||
|
}
|
||||||
|
const nums = rawValues.map(Number);
|
||||||
|
const distinctNums = distinctArg ? Array.from(new Set(nums)) : nums;
|
||||||
switch (func) {
|
switch (func) {
|
||||||
case 'COUNT': return col === '*' ? rows.length : nums.length;
|
case 'SUM': return distinctNums.reduce((a: number, b) => a + b, 0);
|
||||||
case 'SUM': return nums.reduce((a: number, b) => a + b, 0);
|
case 'AVG': return distinctNums.length === 0 ? 0 : distinctNums.reduce((a: number, b) => a + b, 0) / distinctNums.length;
|
||||||
case 'AVG': return nums.length === 0 ? 0 : nums.reduce((a: number, b) => a + b, 0) / nums.length;
|
case 'MIN': return distinctNums.length === 0 ? 0 : Math.min(...distinctNums);
|
||||||
case 'MIN': return nums.length === 0 ? 0 : Math.min(...nums);
|
case 'MAX': return distinctNums.length === 0 ? 0 : Math.max(...distinctNums);
|
||||||
case 'MAX': return nums.length === 0 ? 0 : Math.max(...nums);
|
|
||||||
default: return 0;
|
default: return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -512,11 +627,26 @@ export class QueryExecutor {
|
|||||||
// INSERT INTO ... SELECT ...(v0.3.0)
|
// INSERT INTO ... SELECT ...(v0.3.0)
|
||||||
if (stmt.select) {
|
if (stmt.select) {
|
||||||
const selectRows = await this.executeSelectPart(stmt.select);
|
const selectRows = await this.executeSelectPart(stmt.select);
|
||||||
|
// v0.4.0 修复:源列顺序不能依赖行键(validateRow 会跳过 undefined 导致行键缺失/乱序)。
|
||||||
|
// 以 SELECT 列列表 / 源表 schema 列顺序为准,按位置对齐目标列,缺列不填。
|
||||||
|
let srcCols: string[] = [];
|
||||||
|
const sel = stmt.select;
|
||||||
|
if (sel.type === 'SELECT') {
|
||||||
|
if (sel.columns && sel.columns.length > 0 && sel.columns[0] !== '*') {
|
||||||
|
srcCols = sel.columns.map((c) => c.split('.').pop()!);
|
||||||
|
} else if (sel.from) {
|
||||||
|
const srcSchema = await this.engine.getTableSchema(sel.from);
|
||||||
|
srcCols = srcSchema ? Object.keys(srcSchema.columns) : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (srcCols.length === 0 && selectRows.length > 0) {
|
||||||
|
srcCols = Object.keys(selectRows[0]);
|
||||||
|
}
|
||||||
const rows: Record<string, unknown>[] = selectRows.map((row) => {
|
const rows: Record<string, unknown>[] = selectRows.map((row) => {
|
||||||
const mapped: Record<string, unknown> = {};
|
const mapped: Record<string, unknown> = {};
|
||||||
const values = Object.values(row);
|
|
||||||
for (let i = 0; i < colNames.length; i++) {
|
for (let i = 0; i < colNames.length; i++) {
|
||||||
if (i < values.length) mapped[colNames[i]] = values[i];
|
const src = i < srcCols.length ? srcCols[i] : null;
|
||||||
|
if (src && src in row) mapped[colNames[i]] = row[src];
|
||||||
}
|
}
|
||||||
return mapped;
|
return mapped;
|
||||||
});
|
});
|
||||||
@@ -566,6 +696,11 @@ export class QueryExecutor {
|
|||||||
const schema = await this.engine.getTableSchema(stmt.name);
|
const schema = await this.engine.getTableSchema(stmt.name);
|
||||||
if (!schema) return;
|
if (!schema) return;
|
||||||
|
|
||||||
|
// v0.4.1: 引擎级 alterTable(Aria 需重写存储行 + 持久化 schema;其余引擎走通用引用路径)
|
||||||
|
if (typeof this.engine.alterTable === 'function') {
|
||||||
|
return this.engine.alterTable(stmt.name, stmt.action, { ...astColumnToColumnDef(stmt.column), name: stmt.column.name });
|
||||||
|
}
|
||||||
|
|
||||||
if (stmt.action === 'ADD') {
|
if (stmt.action === 'ADD') {
|
||||||
if (schema.columns[stmt.column.name]) {
|
if (schema.columns[stmt.column.name]) {
|
||||||
throw new DatabaseError(`Column "${stmt.column.name}" already exists in table "${stmt.name}"`, 'COLUMN_EXISTS');
|
throw new DatabaseError(`Column "${stmt.column.name}" already exists in table "${stmt.name}"`, 'COLUMN_EXISTS');
|
||||||
@@ -646,6 +781,25 @@ export class QueryExecutor {
|
|||||||
return columns.some((col) => /^\s*CASE\b/i.test(col));
|
return columns.some((col) => /^\s*CASE\b/i.test(col));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.3.3: ORDER BY 是否引用 SELECT 别名(如 `SELECT name AS n ... ORDER BY n`)。
|
||||||
|
* 别名列在引擎层投影前不存在,需投影后重新排序。
|
||||||
|
*/
|
||||||
|
private orderByUsesSelectAlias(stmt: SelectStatement): boolean {
|
||||||
|
if (!stmt.orderBy || stmt.orderBy.length === 0) return false;
|
||||||
|
const aliases = new Set<string>();
|
||||||
|
for (const col of stmt.columns) {
|
||||||
|
const m = col.match(/\s+AS\s+(\w+)$/i);
|
||||||
|
if (m) aliases.add(m[1]);
|
||||||
|
else if (/^\s*CASE\b/i.test(col)) {
|
||||||
|
const expr = parseCaseExpression(col);
|
||||||
|
if (expr?.alias) aliases.add(expr.alias);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (aliases.size === 0) return false;
|
||||||
|
return stmt.orderBy.some((o) => aliases.has(o.column));
|
||||||
|
}
|
||||||
|
|
||||||
/** WHERE 是否包含 CASE WHEN 表达式键 */
|
/** WHERE 是否包含 CASE WHEN 表达式键 */
|
||||||
private whereHasCase(where: WhereCondition): boolean {
|
private whereHasCase(where: WhereCondition): boolean {
|
||||||
for (const [key, value] of Object.entries(where)) {
|
for (const [key, value] of Object.entries(where)) {
|
||||||
@@ -665,18 +819,47 @@ export class QueryExecutor {
|
|||||||
getEngine(): IStorageEngine { return this.engine; }
|
getEngine(): IStorageEngine { return this.engine; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 列投影(v0.3.1):普通列走 projectColumns,CASE WHEN 表达式逐行求值
|
* 列投影(v0.3.1):普通列走 projectColumns,CASE WHEN 表达式逐行求值;
|
||||||
|
* v0.3.3: 支持 `col AS alias` 列别名
|
||||||
*/
|
*/
|
||||||
private projectRow(row: Record<string, unknown>, columns: string[]): Record<string, unknown> {
|
private projectRow(row: Record<string, unknown>, columns: string[]): Record<string, unknown> {
|
||||||
const plain: string[] = [];
|
const plain: string[] = [];
|
||||||
|
const aliasCols: { alias: string; source: string }[] = [];
|
||||||
const caseCols: { alias: string; expr: CaseExpression }[] = [];
|
const caseCols: { alias: string; expr: CaseExpression }[] = [];
|
||||||
|
const constCols: { key: string; value: unknown }[] = [];
|
||||||
for (const col of columns) {
|
for (const col of columns) {
|
||||||
if (col === '*') continue;
|
if (col === '*') continue;
|
||||||
const expr = parseCaseExpression(col);
|
const expr = parseCaseExpression(col);
|
||||||
if (expr) caseCols.push({ alias: expr.alias ?? col, expr });
|
if (expr) {
|
||||||
else plain.push(col);
|
caseCols.push({ alias: expr.alias ?? col, expr });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const m = col.match(/^(.+?)\s+AS\s+(\w+)$/i);
|
||||||
|
if (m) {
|
||||||
|
aliasCols.push({ alias: m[2], source: m[1].trim() });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// v0.4.0: 字符串常量列 SELECT 'lit' → 常量输出
|
||||||
|
const lit = col.match(/^'(.*)'$/s);
|
||||||
|
if (lit) {
|
||||||
|
const value = lit[1].replace(/\\'/g, "'");
|
||||||
|
constCols.push({ key: col, value });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
plain.push(col);
|
||||||
}
|
}
|
||||||
const projected = plain.length > 0 ? projectColumns(row, plain) : {};
|
const projected = plain.length > 0 ? projectColumns(row, plain) : {};
|
||||||
|
for (const { alias, source } of aliasCols) {
|
||||||
|
if (source === '*') {
|
||||||
|
Object.assign(projected, row);
|
||||||
|
} else {
|
||||||
|
const lit = source.match(/^'(.*)'$/s);
|
||||||
|
projected[alias] = lit ? lit[1].replace(/\\'/g, "'") : row[source];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const { key, value } of constCols) {
|
||||||
|
projected[key] = value;
|
||||||
|
}
|
||||||
for (const { alias, expr } of caseCols) {
|
for (const { alias, expr } of caseCols) {
|
||||||
projected[alias] = evaluateCase(expr, row);
|
projected[alias] = evaluateCase(expr, row);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,7 +149,15 @@ function matchOperator(value: unknown, op: string, operand: unknown): boolean {
|
|||||||
|
|
||||||
export function applyOrderBy(rows: Record<string, unknown>[], orderBy: OrderBy[]): Record<string, unknown>[] {
|
export function applyOrderBy(rows: Record<string, unknown>[], orderBy: OrderBy[]): Record<string, unknown>[] {
|
||||||
return [...rows].sort((a, b) => {
|
return [...rows].sort((a, b) => {
|
||||||
for (const { column, direction } of orderBy) {
|
for (const { column, direction, nulls } of orderBy) {
|
||||||
|
const aNull = a[column] === null || a[column] === undefined;
|
||||||
|
const bNull = b[column] === null || b[column] === undefined;
|
||||||
|
// v0.4.0: NULLS FIRST/LAST 时 NULL 位置固定,不受升降序反转
|
||||||
|
if (nulls && (aNull || bNull)) {
|
||||||
|
if (aNull && bNull) continue;
|
||||||
|
const cmp = nulls === 'first' ? (aNull ? -1 : 1) : (aNull ? 1 : -1);
|
||||||
|
return cmp;
|
||||||
|
}
|
||||||
const cmp = compare(a[column], b[column]);
|
const cmp = compare(a[column], b[column]);
|
||||||
if (cmp !== 0) return direction === 'desc' ? -cmp : cmp;
|
if (cmp !== 0) return direction === 'desc' ? -cmp : cmp;
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-5
@@ -195,18 +195,28 @@ export class Lexer {
|
|||||||
this.readChar(); // 跳过开始引号
|
this.readChar(); // 跳过开始引号
|
||||||
let value = '';
|
let value = '';
|
||||||
|
|
||||||
while (this.ch !== quote && this.ch !== '') {
|
while (this.ch !== '') {
|
||||||
// 处理转义
|
if (this.ch === quote) {
|
||||||
|
// v0.3.3: 支持 SQL 标准 '' 转义(两个连续引号 = 一个引号)
|
||||||
|
if (this.peekChar() === quote) {
|
||||||
|
value += quote;
|
||||||
|
this.readChar(); // 跳过第二个引号
|
||||||
|
this.readChar();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break; // 结束引号(由 nextToken 的 readChar 跳过)
|
||||||
|
}
|
||||||
|
// 反斜杠转义(兼容旧语法)
|
||||||
if (this.ch === '\\' && this.peekChar() === quote) {
|
if (this.ch === '\\' && this.peekChar() === quote) {
|
||||||
this.readChar();
|
this.readChar();
|
||||||
value += quote;
|
value += quote;
|
||||||
} else {
|
this.readChar();
|
||||||
value += this.ch;
|
continue;
|
||||||
}
|
}
|
||||||
|
value += this.ch;
|
||||||
this.readChar();
|
this.readChar();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 跳过结束引号(在 readChar 之后才会调用,所以这里不需要处理)
|
|
||||||
return {
|
return {
|
||||||
type: TokenType.STRING,
|
type: TokenType.STRING,
|
||||||
value,
|
value,
|
||||||
|
|||||||
+106
-20
@@ -212,18 +212,37 @@ export class Parser {
|
|||||||
columns.push(...this.parseColumnList());
|
columns.push(...this.parseColumnList());
|
||||||
}
|
}
|
||||||
|
|
||||||
// FROM
|
// FROM(v0.4.0 可选:SELECT 1 / SELECT 'lit' 无表查询)
|
||||||
this.expect(TokenType.FROM);
|
let fromSubquery: SelectStatement | SelectUnionStatement | undefined;
|
||||||
const tableName = this.expectIdentifier('table name');
|
let tableName = '';
|
||||||
|
|
||||||
// 表别名(可选)
|
|
||||||
let alias: string | undefined;
|
let alias: string | undefined;
|
||||||
if (this.curTokenIs(TokenType.AS)) {
|
if (this.curTokenIs(TokenType.FROM)) {
|
||||||
this.nextToken();
|
|
||||||
alias = this.expectIdentifier('alias');
|
|
||||||
} else if (this.curToken.type === TokenType.IDENTIFIER && !this._isReservedAfterFrom()) {
|
|
||||||
alias = this.curToken.value;
|
|
||||||
this.nextToken();
|
this.nextToken();
|
||||||
|
|
||||||
|
// v0.4.0: FROM (SELECT ...) AS alias 派生表
|
||||||
|
if (this.curTokenIs(TokenType.LPAREN)) {
|
||||||
|
this.nextToken();
|
||||||
|
fromSubquery = this.parseSelect();
|
||||||
|
this.expect(TokenType.RPAREN);
|
||||||
|
if (this.curTokenIs(TokenType.AS)) {
|
||||||
|
this.nextToken();
|
||||||
|
alias = this.expectIdentifier('alias');
|
||||||
|
} else if (this.curToken.type === TokenType.IDENTIFIER && !this._isReservedAfterFrom()) {
|
||||||
|
alias = this.curToken.value;
|
||||||
|
this.nextToken();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tableName = this.expectIdentifier('table name');
|
||||||
|
|
||||||
|
// 表别名(可选)
|
||||||
|
if (this.curTokenIs(TokenType.AS)) {
|
||||||
|
this.nextToken();
|
||||||
|
alias = this.expectIdentifier('alias');
|
||||||
|
} else if (this.curToken.type === TokenType.IDENTIFIER && !this._isReservedAfterFrom()) {
|
||||||
|
alias = this.curToken.value;
|
||||||
|
this.nextToken();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const stmt: SelectStatement = {
|
const stmt: SelectStatement = {
|
||||||
@@ -234,6 +253,9 @@ export class Parser {
|
|||||||
alias,
|
alias,
|
||||||
where: {},
|
where: {},
|
||||||
};
|
};
|
||||||
|
if (fromSubquery) {
|
||||||
|
stmt.fromSubquery = fromSubquery;
|
||||||
|
}
|
||||||
|
|
||||||
// JOIN 子句(可选,支持多个)
|
// JOIN 子句(可选,支持多个)
|
||||||
const joins = this.parseJoinClauses();
|
const joins = this.parseJoinClauses();
|
||||||
@@ -824,6 +846,17 @@ export class Parser {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.4.1: 裸布尔列条件(WHERE done / CASE WHEN done THEN)— 列后直接是终止符时视为真值判断
|
||||||
|
if (
|
||||||
|
this.curTokenIs(TokenType.AND) || this.curTokenIs(TokenType.OR) ||
|
||||||
|
this.curTokenIs(TokenType.RPAREN) || this.curTokenIs(TokenType.EOF) ||
|
||||||
|
(this.curToken.type === TokenType.IDENTIFIER && ['THEN', 'END', 'ELSE', 'NULLS', 'LIMIT', 'OFFSET', 'ORDER', 'GROUP', 'HAVING', 'UNION', 'WHERE'].includes(this.curToken.value.toUpperCase()))
|
||||||
|
) {
|
||||||
|
const result: WhereCondition = {};
|
||||||
|
result[column] = { $eq: true };
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// 比较运算符
|
// 比较运算符
|
||||||
const op = this.parseComparisonOp();
|
const op = this.parseComparisonOp();
|
||||||
|
|
||||||
@@ -897,15 +930,30 @@ export class Parser {
|
|||||||
|
|
||||||
private parseColumnList(): string[] {
|
private parseColumnList(): string[] {
|
||||||
const cols: string[] = [];
|
const cols: string[] = [];
|
||||||
cols.push(this.parseColumnRef());
|
cols.push(this.parseColumnWithAlias());
|
||||||
while (this.curTokenIs(TokenType.COMMA)) {
|
while (this.curTokenIs(TokenType.COMMA)) {
|
||||||
this.nextToken();
|
this.nextToken();
|
||||||
cols.push(this.parseColumnRef());
|
cols.push(this.parseColumnWithAlias());
|
||||||
}
|
}
|
||||||
return cols;
|
return cols;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 解析列引用:支持 'col'、'table.col'、'COUNT(*)'/'SUM(col)'、数字常量列(SELECT 1)和 CASE WHEN 表达式(v0.3.1) */
|
/** v0.3.3: 解析列(支持 `col AS alias` 显式别名与 `col alias` 隐式别名) */
|
||||||
|
private parseColumnWithAlias(): string {
|
||||||
|
let col = this.parseColumnRef();
|
||||||
|
if (this.curTokenIs(TokenType.AS)) {
|
||||||
|
this.nextToken();
|
||||||
|
const alias = this.expectIdentifier('alias');
|
||||||
|
col = `${col} AS ${alias}`;
|
||||||
|
} else if (this.curToken.type === TokenType.IDENTIFIER && !this._isReservedAfterFrom() && !this._isJoinKeyword()) {
|
||||||
|
const alias = this.curToken.value;
|
||||||
|
this.nextToken();
|
||||||
|
col = `${col} AS ${alias}`;
|
||||||
|
}
|
||||||
|
return col;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析列引用:支持 'col'、'table.col'、'COUNT(*)'/'SUM(col)'、数字常量列(SELECT 1)、字符串常量列(SELECT 'x',v0.4.0)和 CASE WHEN 表达式(v0.3.1) */
|
||||||
private parseColumnRef(): string {
|
private parseColumnRef(): string {
|
||||||
// CASE WHEN 表达式(v0.3.1)
|
// CASE WHEN 表达式(v0.3.1)
|
||||||
if (this.curTokenIs(TokenType.CASE)) {
|
if (this.curTokenIs(TokenType.CASE)) {
|
||||||
@@ -919,6 +967,13 @@ export class Parser {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.4.0: 字符串常量列:SELECT 'value' FROM t
|
||||||
|
if (this.curTokenIs(TokenType.STRING)) {
|
||||||
|
const value = this.curToken.value;
|
||||||
|
this.nextToken();
|
||||||
|
return `'${value}'`;
|
||||||
|
}
|
||||||
|
|
||||||
// 聚合函数?
|
// 聚合函数?
|
||||||
if (
|
if (
|
||||||
this.curTokenIs(TokenType.COUNT) ||
|
this.curTokenIs(TokenType.COUNT) ||
|
||||||
@@ -973,11 +1028,19 @@ export class Parser {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 解析聚合函数调用: COUNT(*), SUM(col), AVG(col), MIN(col), MAX(col) */ private parseAggregateCall(): string {
|
/** 解析聚合函数调用: COUNT(*), SUM(col), AVG(col), MIN(col), MAX(col),v0.4.0 支持 COUNT(DISTINCT col) */
|
||||||
|
private parseAggregateCall(): string {
|
||||||
const func = this.curToken.value.toUpperCase();
|
const func = this.curToken.value.toUpperCase();
|
||||||
this.nextToken();
|
this.nextToken();
|
||||||
this.expect(TokenType.LPAREN);
|
this.expect(TokenType.LPAREN);
|
||||||
|
|
||||||
|
// v0.4.0: COUNT(DISTINCT col) 等去重聚合
|
||||||
|
let distinct = false;
|
||||||
|
if (this.curTokenIs(TokenType.DISTINCT)) {
|
||||||
|
distinct = true;
|
||||||
|
this.nextToken();
|
||||||
|
}
|
||||||
|
|
||||||
let arg: string;
|
let arg: string;
|
||||||
if (this.curTokenIs(TokenType.STAR)) {
|
if (this.curTokenIs(TokenType.STAR)) {
|
||||||
arg = '*';
|
arg = '*';
|
||||||
@@ -998,10 +1061,11 @@ export class Parser {
|
|||||||
this.nextToken();
|
this.nextToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inner = distinct ? `DISTINCT ${arg}` : arg;
|
||||||
if (alias) {
|
if (alias) {
|
||||||
return `${func}(${arg}) AS ${alias}`;
|
return `${func}(${inner}) AS ${alias}`;
|
||||||
}
|
}
|
||||||
return `${func}(${arg})`;
|
return `${func}(${inner})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _isAggregateAlias(): boolean {
|
private _isAggregateAlias(): boolean {
|
||||||
@@ -1010,10 +1074,10 @@ export class Parser {
|
|||||||
|
|
||||||
private parseIdentifierList(): string[] {
|
private parseIdentifierList(): string[] {
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
ids.push(this.expectIdentifier('identifier'));
|
ids.push(this.parseIdentifierWithDot());
|
||||||
while (this.curTokenIs(TokenType.COMMA)) {
|
while (this.curTokenIs(TokenType.COMMA)) {
|
||||||
this.nextToken();
|
this.nextToken();
|
||||||
ids.push(this.expectIdentifier('identifier'));
|
ids.push(this.parseIdentifierWithDot());
|
||||||
}
|
}
|
||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
@@ -1039,7 +1103,7 @@ export class Parser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private parseOrderBy(): OrderBy {
|
private parseOrderBy(): OrderBy {
|
||||||
const column = this.expectIdentifier('column name');
|
const column = this.parseIdentifierWithDot();
|
||||||
let direction: SortDirection = 'asc';
|
let direction: SortDirection = 'asc';
|
||||||
if (this.curTokenIs(TokenType.ASC)) {
|
if (this.curTokenIs(TokenType.ASC)) {
|
||||||
this.nextToken();
|
this.nextToken();
|
||||||
@@ -1047,7 +1111,29 @@ export class Parser {
|
|||||||
direction = 'desc';
|
direction = 'desc';
|
||||||
this.nextToken();
|
this.nextToken();
|
||||||
}
|
}
|
||||||
return { column, direction };
|
// v0.4.0: NULLS FIRST / NULLS LAST
|
||||||
|
let nulls: 'first' | 'last' | undefined;
|
||||||
|
if (this.curTokenIs(TokenType.IDENTIFIER) && this.curToken.value.toUpperCase() === 'NULLS') {
|
||||||
|
this.nextToken();
|
||||||
|
if (this.curTokenIs(TokenType.IDENTIFIER) && this.curToken.value.toUpperCase() === 'FIRST') {
|
||||||
|
nulls = 'first';
|
||||||
|
this.nextToken();
|
||||||
|
} else if (this.curTokenIs(TokenType.IDENTIFIER) && this.curToken.value.toUpperCase() === 'LAST') {
|
||||||
|
nulls = 'last';
|
||||||
|
this.nextToken();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { column, direction, ...(nulls ? { nulls } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** v0.4.0: 标识符(支持 'table.column' 带表前缀引用,用于 ORDER BY / GROUP BY) */
|
||||||
|
private parseIdentifierWithDot(): string {
|
||||||
|
const first = this.expectIdentifier('identifier');
|
||||||
|
if (this.curTokenIs(TokenType.DOT)) {
|
||||||
|
this.nextToken();
|
||||||
|
return `${first}.${this.expectIdentifier('identifier')}`;
|
||||||
|
}
|
||||||
|
return first;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 解析字面量值 */
|
/** 解析字面量值 */
|
||||||
|
|||||||
@@ -59,6 +59,31 @@ export class Table<T = Record<string, unknown>> {
|
|||||||
return new SelectQueryBuilder(this.engine, this.name, columns, this.executor);
|
return new SelectQueryBuilder(this.engine, this.name, columns, this.executor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** v0.4.0: 流式查询 — 逐行回调,不物化全部结果 */
|
||||||
|
async stream(
|
||||||
|
onRow: (row: T & Record<string, unknown>) => void,
|
||||||
|
query: { where?: Record<string, unknown>; limit?: number; offset?: number; columns?: string[] } = {},
|
||||||
|
): Promise<number> {
|
||||||
|
if (typeof this.engine.findStream !== 'function') {
|
||||||
|
const rows = await this.engine.find(this.name, {
|
||||||
|
table: this.name,
|
||||||
|
where: query.where,
|
||||||
|
limit: query.limit,
|
||||||
|
offset: query.offset,
|
||||||
|
columns: query.columns,
|
||||||
|
});
|
||||||
|
for (const row of rows) onRow(row as T & Record<string, unknown>);
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
return this.engine.findStream(this.name, {
|
||||||
|
table: this.name,
|
||||||
|
where: query.where,
|
||||||
|
limit: query.limit,
|
||||||
|
offset: query.offset,
|
||||||
|
columns: query.columns,
|
||||||
|
}, onRow as (row: Record<string, unknown>) => void);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 更新 ----
|
// ---- 更新 ----
|
||||||
|
|
||||||
update(updates: Partial<T> & Record<string, unknown>): UpdateQueryBuilder {
|
update(updates: Partial<T> & Record<string, unknown>): UpdateQueryBuilder {
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* v0.4.1 测试 — AriaEngine 外键级联 + clearAll 重置
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'fake-indexeddb/auto';
|
||||||
|
import { AriaEngine } from '../src/engine/aria/index';
|
||||||
|
|
||||||
|
const mkEngine = async (name: string): Promise<AriaEngine> => {
|
||||||
|
const e = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
|
||||||
|
await e.open(`cascade-${name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, 1);
|
||||||
|
return e;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('[v0.4.1] AriaEngine 外键级联', () => {
|
||||||
|
test('CASCADE:删除主表行时级联删除引用行', async () => {
|
||||||
|
const e = await mkEngine('cas');
|
||||||
|
await e.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true } } });
|
||||||
|
await e.createTable({ name: 'orders', columns: { id: { type: 'string', primaryKey: true }, user_id: { type: 'string', references: 'users.id', onDelete: 'CASCADE' } } });
|
||||||
|
await e.insert('users', [{ id: 'u1' }, { id: 'u2' }]);
|
||||||
|
await e.insert('orders', [{ id: 'o1', user_id: 'u1' }, { id: 'o2', user_id: 'u1' }, { id: 'o3', user_id: 'u2' }]);
|
||||||
|
|
||||||
|
const count = await e.delete('users', { table: 'users', where: { id: 'u1' } });
|
||||||
|
expect(count).toBe(3); // u1 + o1 + o2
|
||||||
|
expect(await e.count('users')).toBe(1);
|
||||||
|
expect(await e.count('orders')).toBe(1);
|
||||||
|
const rest = await e.find('orders', { table: 'orders' });
|
||||||
|
expect(rest[0].user_id).toBe('u2');
|
||||||
|
await e.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CASCADE:多层递归(users → orders → order_items)', async () => {
|
||||||
|
const e = await mkEngine('cas2');
|
||||||
|
await e.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true } } });
|
||||||
|
await e.createTable({ name: 'orders', columns: { id: { type: 'string', primaryKey: true }, user_id: { type: 'string', references: 'users.id', onDelete: 'CASCADE' } } });
|
||||||
|
await e.createTable({ name: 'order_items', columns: { id: { type: 'string', primaryKey: true }, order_id: { type: 'string', references: 'orders.id', onDelete: 'CASCADE' } } });
|
||||||
|
await e.insert('users', [{ id: 'u1' }]);
|
||||||
|
await e.insert('orders', [{ id: 'o1', user_id: 'u1' }]);
|
||||||
|
await e.insert('order_items', [{ id: 'i1', order_id: 'o1' }, { id: 'i2', order_id: 'o1' }]);
|
||||||
|
|
||||||
|
const count = await e.delete('users', { table: 'users', where: { id: 'u1' } });
|
||||||
|
expect(count).toBe(4); // u1 + o1 + i1 + i2
|
||||||
|
expect(await e.count('order_items')).toBe(0);
|
||||||
|
await e.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SET NULL:删除主表行时引用列置 null', async () => {
|
||||||
|
const e = await mkEngine('sn');
|
||||||
|
await e.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true } } });
|
||||||
|
await e.createTable({ name: 'orders', columns: { id: { type: 'string', primaryKey: true }, user_id: { type: 'string', references: 'users.id', onDelete: 'SET NULL' } } });
|
||||||
|
await e.insert('users', [{ id: 'u1' }]);
|
||||||
|
await e.insert('orders', [{ id: 'o1', user_id: 'u1' }]);
|
||||||
|
|
||||||
|
const count = await e.delete('users', { table: 'users', where: { id: 'u1' } });
|
||||||
|
expect(count).toBe(1); // 仅 u1,引用行保留
|
||||||
|
const rows = await e.find('orders', { table: 'orders' });
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].user_id).toBeNull();
|
||||||
|
await e.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('RESTRICT:存在引用行时禁止删除', async () => {
|
||||||
|
const e = await mkEngine('res');
|
||||||
|
await e.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true } } });
|
||||||
|
await e.createTable({ name: 'orders', columns: { id: { type: 'string', primaryKey: true }, user_id: { type: 'string', references: 'users.id', onDelete: 'RESTRICT' } } });
|
||||||
|
await e.insert('users', [{ id: 'u1' }]);
|
||||||
|
await e.insert('orders', [{ id: 'o1', user_id: 'u1' }]);
|
||||||
|
|
||||||
|
await expect(e.delete('users', { table: 'users', where: { id: 'u1' } })).rejects.toThrow('Cannot delete');
|
||||||
|
// 数据未被删除
|
||||||
|
expect(await e.count('users')).toBe(1);
|
||||||
|
expect(await e.count('orders')).toBe(1);
|
||||||
|
await e.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CASCADE 同时清理二级索引(删除后按索引查不到)', async () => {
|
||||||
|
const e = await mkEngine('cidx');
|
||||||
|
await e.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true } } });
|
||||||
|
await e.createTable({ name: 'orders', columns: { id: { type: 'string', primaryKey: true }, user_id: { type: 'string', index: true, references: 'users.id', onDelete: 'CASCADE' } } });
|
||||||
|
await e.insert('users', [{ id: 'u1' }]);
|
||||||
|
await e.insert('orders', [{ id: 'o1', user_id: 'u1' }]);
|
||||||
|
await e.delete('users', { table: 'users', where: { id: 'u1' } });
|
||||||
|
const rows = await e.find('orders', { table: 'orders', where: { user_id: 'u1' } });
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
await e.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('事务内级联删除可提交/回滚', async () => {
|
||||||
|
const e = await mkEngine('ctx');
|
||||||
|
await e.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true } } });
|
||||||
|
await e.createTable({ name: 'orders', columns: { id: { type: 'string', primaryKey: true }, user_id: { type: 'string', references: 'users.id', onDelete: 'CASCADE' } } });
|
||||||
|
await e.insert('users', [{ id: 'u1' }]);
|
||||||
|
await e.insert('orders', [{ id: 'o1', user_id: 'u1' }]);
|
||||||
|
|
||||||
|
// rollback:级联删除被回滚
|
||||||
|
await e.beginTransaction();
|
||||||
|
await e.delete('users', { table: 'users', where: { id: 'u1' } });
|
||||||
|
await e.rollbackTransaction();
|
||||||
|
expect(await e.count('users')).toBe(1);
|
||||||
|
expect(await e.count('orders')).toBe(1);
|
||||||
|
|
||||||
|
// commit:级联删除生效
|
||||||
|
await e.beginTransaction();
|
||||||
|
await e.delete('users', { table: 'users', where: { id: 'u1' } });
|
||||||
|
await e.commitTransaction();
|
||||||
|
expect(await e.count('users')).toBe(0);
|
||||||
|
expect(await e.count('orders')).toBe(0);
|
||||||
|
await e.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('[v0.4.1] AriaEngine clearAll', () => {
|
||||||
|
test('clearAll 清空全部表与数据,实例可继续使用', async () => {
|
||||||
|
const e = await mkEngine('clr');
|
||||||
|
await e.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true }, name: { type: 'string', index: true } } });
|
||||||
|
await e.insert('users', [{ id: '1', name: 'Alice' }]);
|
||||||
|
|
||||||
|
await e.clearAll();
|
||||||
|
expect(await e.getTableNames()).toHaveLength(0);
|
||||||
|
|
||||||
|
// 实例可继续建表使用
|
||||||
|
await e.createTable({ name: 't2', columns: { id: { type: 'string', primaryKey: true } } });
|
||||||
|
await e.insert('t2', [{ id: 'x' }]);
|
||||||
|
expect(await e.count('t2')).toBe(1);
|
||||||
|
await e.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearAll 后重启(模拟刷新)无残留数据', async () => {
|
||||||
|
const name = `clr2-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
let e = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
|
||||||
|
await e.open(name, 1);
|
||||||
|
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true } } });
|
||||||
|
await e.insert('t', [{ id: '1' }]);
|
||||||
|
await e.clearAll();
|
||||||
|
await e.close();
|
||||||
|
|
||||||
|
// 重新打开(模拟页面刷新):不应有残留表
|
||||||
|
e = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
|
||||||
|
await e.open(name, 1);
|
||||||
|
expect(await e.getTableNames()).toHaveLength(0);
|
||||||
|
await e.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('[v0.4.1] AriaEngine ALTER TABLE', () => {
|
||||||
|
test('DROP COLUMN 真正清除存储中的列值(find 副本不再残留)', async () => {
|
||||||
|
const e = await mkEngine('alt');
|
||||||
|
await e.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true }, name: { type: 'string' }, email: { type: 'string' }, age: { type: 'number', default: 0 } } });
|
||||||
|
await e.insert('users', [{ id: '1', name: 'Alice', email: 'a@x.com', age: 30 }]);
|
||||||
|
await e.alterTable('users', 'ADD', { name: 'phone', type: 'string' });
|
||||||
|
await e.insert('users', [{ id: '2', name: 'Frank', age: 33, phone: '123' }]);
|
||||||
|
await e.alterTable('users', 'DROP', { name: 'phone', type: 'string' });
|
||||||
|
|
||||||
|
const rows = await e.find('users', { table: 'users' });
|
||||||
|
for (const row of rows) {
|
||||||
|
expect('phone' in row).toBe(false);
|
||||||
|
}
|
||||||
|
await e.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ALTER 持久化:重启后 schema 与行一致', async () => {
|
||||||
|
const name = `alt2-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
let e = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
|
||||||
|
await e.open(name, 1);
|
||||||
|
await e.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true }, name: { type: 'string' } } });
|
||||||
|
await e.insert('users', [{ id: '1', name: 'Alice' }]);
|
||||||
|
await e.alterTable('users', 'ADD', { name: 'phone', type: 'string' });
|
||||||
|
await e.insert('users', [{ id: '2', name: 'Frank', phone: '123' }]);
|
||||||
|
await e.alterTable('users', 'DROP', { name: 'phone', type: 'string' });
|
||||||
|
await e.close();
|
||||||
|
|
||||||
|
e = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
|
||||||
|
await e.open(name, 1);
|
||||||
|
const schema = await e.getTableSchema('users');
|
||||||
|
expect(Object.keys(schema!.columns)).not.toContain('phone');
|
||||||
|
const rows = await e.find('users', { table: 'users' });
|
||||||
|
for (const row of rows) expect('phone' in row).toBe(false);
|
||||||
|
await e.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ALTER 模拟崩溃(不 close)重启:schema 与行一致', async () => {
|
||||||
|
const name = `alt3-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
let e = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
|
||||||
|
await e.open(name, 1);
|
||||||
|
await e.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true }, name: { type: 'string' } } });
|
||||||
|
await e.insert('users', [{ id: '1', name: 'Alice' }]);
|
||||||
|
await e.alterTable('users', 'ADD', { name: 'phone', type: 'string' });
|
||||||
|
await e.insert('users', [{ id: '2', name: 'Frank', phone: '123' }]);
|
||||||
|
await e.alterTable('users', 'DROP', { name: 'phone', type: 'string' });
|
||||||
|
// 不 close,模拟崩溃
|
||||||
|
|
||||||
|
e = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
|
||||||
|
await e.open(name, 1);
|
||||||
|
const schema = await e.getTableSchema('users');
|
||||||
|
expect(Object.keys(schema!.columns)).not.toContain('phone');
|
||||||
|
const rows = await e.find('users', { table: 'users' });
|
||||||
|
for (const row of rows) expect('phone' in row).toBe(false);
|
||||||
|
await e.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,8 +23,8 @@ import { createSchema } from '../src/table/schema';
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('[v0.2.5] P0-1: 版本号统一', () => {
|
describe('[v0.2.5] P0-1: 版本号统一', () => {
|
||||||
test('VERSION 常量为当前版本(0.3.2)', () => {
|
test('VERSION 常量为当前版本(0.4.1)', () => {
|
||||||
expect(VERSION).toBe('0.3.2');
|
expect(VERSION).toBe('0.4.1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
/**
|
||||||
|
* v0.3.3 修复验证测试
|
||||||
|
* 验证所有 P0/P1 修复点:
|
||||||
|
* - P0-1: Aria WAL DROP_TABLE 崩溃恢复(表/数据不复活)+ WAL 恢复后截断
|
||||||
|
* - P0-2: MemoryEngine update/delete 索引维护(unique 约束 + 索引查询 + 级联清理)
|
||||||
|
* - P1-3: Aria 事务内读到自己写入的变更(insert 后 update/delete)
|
||||||
|
* - P1-4: Aria clear() 写 WAL + 事务化
|
||||||
|
* - P1-5: WAL 字节数跟踪(full 模式 walSizeThreshold 生效)
|
||||||
|
* - P1-6: Aria 主键列不建冗余二级索引(PK $in / 范围查询走主 LSM)
|
||||||
|
* - P1-7: ORDER BY 支持 SELECT 别名
|
||||||
|
* - P1-8: SQL 字符串 '' 标准转义
|
||||||
|
* - P1-9: Savepoint 回滚后 MVCC 版本链一致 + rollback 索引重建
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'fake-indexeddb/auto';
|
||||||
|
import { VERSION } from '../src/constants';
|
||||||
|
import { MetonaSqlark } from '../src/core';
|
||||||
|
import { AriaEngine } from '../src/engine/aria/index';
|
||||||
|
import { MemoryEngine } from '../src/engine/memory';
|
||||||
|
import { WAL } from '../src/engine/aria/wal/log';
|
||||||
|
import { tokenize } from '../src/sql/lexer';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// P0-1: Aria WAL DROP_TABLE 崩溃恢复
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('[v0.3.3] P0-1: WAL DROP_TABLE 崩溃恢复', () => {
|
||||||
|
const mkEngine = async (name: string): Promise<AriaEngine> => {
|
||||||
|
const e = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
|
||||||
|
await e.open(name, 1);
|
||||||
|
return e;
|
||||||
|
};
|
||||||
|
|
||||||
|
test('删表后崩溃(不 close),重启后表与数据不复活', async () => {
|
||||||
|
const dbName = `crash-drop-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
let e = await mkEngine(dbName);
|
||||||
|
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true } } });
|
||||||
|
await e.insert('t', [{ id: '1' }, { id: '2' }]);
|
||||||
|
await e.dropTable('t');
|
||||||
|
|
||||||
|
// 模拟崩溃:不 close,直接重建引擎(WAL 未 checkpoint)
|
||||||
|
e = await mkEngine(dbName);
|
||||||
|
const names = await e.getTableNames();
|
||||||
|
expect(names).not.toContain('t');
|
||||||
|
await e.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('删表崩溃后重建同名表,旧数据不复活', async () => {
|
||||||
|
const dbName = `crash-drop2-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
let e = await mkEngine(dbName);
|
||||||
|
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true } } });
|
||||||
|
await e.insert('t', [{ id: '1' }, { id: '2' }]);
|
||||||
|
await e.dropTable('t');
|
||||||
|
|
||||||
|
e = await mkEngine(dbName);
|
||||||
|
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true } } });
|
||||||
|
const rows = await e.find('t', { table: 't' });
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
await e.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('WAL 恢复后截断:重启不再重复回放(checkpoint 后 WAL 空)', async () => {
|
||||||
|
const dbName = `crash-wal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
let e = await mkEngine(dbName);
|
||||||
|
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true }, v: { type: 'number' } } });
|
||||||
|
await e.insert('t', [{ id: '1', v: 1 }, { id: '2', v: 2 }]);
|
||||||
|
await e.dropTable('t');
|
||||||
|
|
||||||
|
// 第一次恢复:WAL 回放 + 截断
|
||||||
|
e = await mkEngine(dbName);
|
||||||
|
expect(await e.getTableNames()).not.toContain('t');
|
||||||
|
|
||||||
|
// 第二次恢复:WAL 已空,不再有任何回放副作用
|
||||||
|
await e.close();
|
||||||
|
e = await mkEngine(dbName);
|
||||||
|
expect(await e.getTableNames()).not.toContain('t');
|
||||||
|
await e.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// P0-2: MemoryEngine update/delete 索引维护
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('[v0.3.3] P0-2: Memory 引擎索引维护', () => {
|
||||||
|
test('update 修改唯一列后,重复值插入被拦截(unique 约束不被绕过)', async () => {
|
||||||
|
const e = new MemoryEngine();
|
||||||
|
await e.open('x', 1);
|
||||||
|
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true }, email: { type: 'string', unique: true, index: true } } });
|
||||||
|
await e.insert('t', [{ id: '1', email: 'a@x.com' }]);
|
||||||
|
await e.update('t', { table: 't', where: { id: '1' } }, { email: 'b@x.com' });
|
||||||
|
await expect(e.insert('t', [{ id: '2', email: 'b@x.com' }])).rejects.toThrow('Unique constraint');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update 后按新值索引查询命中', async () => {
|
||||||
|
const e = new MemoryEngine();
|
||||||
|
await e.open('x', 1);
|
||||||
|
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true }, email: { type: 'string', index: true } } });
|
||||||
|
await e.insert('t', [{ id: '1', email: 'a@x.com' }]);
|
||||||
|
await e.update('t', { table: 't', where: { id: '1' } }, { email: 'b@x.com' });
|
||||||
|
const rows = await e.find('t', { table: 't', where: { email: 'b@x.com' } });
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].id).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update 到已存在的唯一值时抛 UNIQUE_VIOLATION', async () => {
|
||||||
|
const e = new MemoryEngine();
|
||||||
|
await e.open('x', 1);
|
||||||
|
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true }, email: { type: 'string', unique: true, index: true } } });
|
||||||
|
await e.insert('t', [{ id: '1', email: 'a@x.com' }, { id: '2', email: 'b@x.com' }]);
|
||||||
|
await expect(e.update('t', { table: 't', where: { id: '1' } }, { email: 'b@x.com' })).rejects.toThrow('Unique constraint');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete 后索引清理:同值可重新插入且索引查询正确', async () => {
|
||||||
|
const e = new MemoryEngine();
|
||||||
|
await e.open('x', 1);
|
||||||
|
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true }, email: { type: 'string', unique: true, index: true } } });
|
||||||
|
await e.insert('t', [{ id: '1', email: 'a@x.com' }]);
|
||||||
|
await e.delete('t', { table: 't', where: { id: '1' } });
|
||||||
|
await e.insert('t', [{ id: '2', email: 'a@x.com' }]);
|
||||||
|
const rows = await e.find('t', { table: 't', where: { email: 'a@x.com' } });
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].id).toBe('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('级联删除清理子表索引条目', async () => {
|
||||||
|
const e = new MemoryEngine();
|
||||||
|
await e.open('x', 1);
|
||||||
|
await e.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true } } });
|
||||||
|
await e.createTable({ name: 'orders', columns: { id: { type: 'string', primaryKey: true }, user_id: { type: 'string', index: true, references: 'users.id', onDelete: 'CASCADE' } } });
|
||||||
|
await e.insert('users', [{ id: 'u1' }]);
|
||||||
|
await e.insert('orders', [{ id: 'o1', user_id: 'u1' }]);
|
||||||
|
await e.delete('users', { table: 'users', where: { id: 'u1' } });
|
||||||
|
// u1 已删,o1 级联删除 → 按 user_id 索引查询应为空
|
||||||
|
const rows = await e.find('orders', { table: 'orders', where: { user_id: 'u1' } });
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// P1-3: Aria 事务内读到自己写入的变更
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('[v0.3.3] P1-3: Aria 事务内读写一致', () => {
|
||||||
|
test('事务内 insert 后 update 同一行生效', async () => {
|
||||||
|
const e = new AriaEngine({ storageBackend: 'memory', walSyncMode: 'full' });
|
||||||
|
await e.open('txn1', 1);
|
||||||
|
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true }, name: { type: 'string' } } });
|
||||||
|
await e.beginTransaction();
|
||||||
|
await e.insert('t', [{ id: '1', name: 'Alice' }]);
|
||||||
|
const n = await e.update('t', { table: 't', where: { id: '1' } }, { name: 'Bob' });
|
||||||
|
await e.commitTransaction();
|
||||||
|
const rows = await e.find('t', { table: 't' });
|
||||||
|
expect(n).toBe(1);
|
||||||
|
expect(rows[0].name).toBe('Bob');
|
||||||
|
await e.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('事务内 insert 后 delete 该行生效', async () => {
|
||||||
|
const e = new AriaEngine({ storageBackend: 'memory', walSyncMode: 'full' });
|
||||||
|
await e.open('txn2', 1);
|
||||||
|
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true } } });
|
||||||
|
await e.beginTransaction();
|
||||||
|
await e.insert('t', [{ id: '1' }, { id: '2' }]);
|
||||||
|
const d = await e.delete('t', { table: 't', where: { id: '2' } });
|
||||||
|
await e.commitTransaction();
|
||||||
|
const rows = await e.find('t', { table: 't' });
|
||||||
|
expect(d).toBe(1);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
await e.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rollback 后索引无残留(事务内写入的索引被重建清理)', async () => {
|
||||||
|
const e = new AriaEngine({ storageBackend: 'memory', walSyncMode: 'full' });
|
||||||
|
await e.open('txn3', 1);
|
||||||
|
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true }, name: { type: 'string', index: true } } });
|
||||||
|
await e.beginTransaction();
|
||||||
|
await e.insert('t', [{ id: '1', name: 'Dave' }]);
|
||||||
|
await e.rollbackTransaction();
|
||||||
|
const rows = await e.find('t', { table: 't', where: { name: 'Dave' } });
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
await e.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rollback 更新后索引恢复旧值', async () => {
|
||||||
|
const e = new AriaEngine({ storageBackend: 'memory', walSyncMode: 'full' });
|
||||||
|
await e.open('txn4', 1);
|
||||||
|
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true }, name: { type: 'string', index: true } } });
|
||||||
|
await e.insert('t', [{ id: '1', name: 'Bob' }]);
|
||||||
|
await e.beginTransaction();
|
||||||
|
await e.update('t', { table: 't', where: { id: '1' } }, { name: 'Eve' });
|
||||||
|
await e.rollbackTransaction();
|
||||||
|
const rows = await e.find('t', { table: 't', where: { name: 'Bob' } });
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
const stale = await e.find('t', { table: 't', where: { name: 'Eve' } });
|
||||||
|
expect(stale).toHaveLength(0);
|
||||||
|
await e.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('事务内 clear 生效且可提交', async () => {
|
||||||
|
const e = new AriaEngine({ storageBackend: 'memory', walSyncMode: 'full' });
|
||||||
|
await e.open('txn5', 1);
|
||||||
|
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true } } });
|
||||||
|
await e.insert('t', [{ id: '1' }, { id: '2' }]);
|
||||||
|
await e.beginTransaction();
|
||||||
|
await e.clear('t');
|
||||||
|
await e.commitTransaction();
|
||||||
|
expect(await e.count('t')).toBe(0);
|
||||||
|
await e.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// P1-5: WAL 字节数跟踪
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('[v0.3.3] P1-5: WAL 字节数跟踪', () => {
|
||||||
|
test('full 模式 append 后 getBufferedBytes 反映实际字节数', async () => {
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
const wal = new WAL({
|
||||||
|
append: async (d) => { chunks.push(d); },
|
||||||
|
readAll: async () => { const t = chunks.reduce((s, c) => s + c.byteLength, 0); const out = new Uint8Array(t); let o = 0; for (const c of chunks) { out.set(c, o); o += c.byteLength; } return out; },
|
||||||
|
truncate: async () => { chunks.length = 0; },
|
||||||
|
exists: async () => chunks.length > 0,
|
||||||
|
}, true, 'full');
|
||||||
|
|
||||||
|
await wal.append({ type: 1, txnId: 0, tableName: 't', key: '1', data: { a: 1 } } as never);
|
||||||
|
const bytesAfterAppend = wal.getBufferedBytes();
|
||||||
|
expect(bytesAfterAppend).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await wal.checkpoint();
|
||||||
|
expect(wal.getBufferedBytes()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('batch 模式 flush 后字节数保留(未 checkpoint 前)', async () => {
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
const wal = new WAL({
|
||||||
|
append: async (d) => { chunks.push(d); },
|
||||||
|
readAll: async () => new Uint8Array(0),
|
||||||
|
truncate: async () => { chunks.length = 0; },
|
||||||
|
exists: async () => chunks.length > 0,
|
||||||
|
}, true, 'batch');
|
||||||
|
|
||||||
|
await wal.append({ type: 1, txnId: 0, tableName: 't', key: '1', data: { a: 1 } } as never);
|
||||||
|
await wal.flush();
|
||||||
|
expect(wal.getBufferedBytes()).toBeGreaterThan(0); // 已写盘但未 checkpoint
|
||||||
|
await wal.checkpoint();
|
||||||
|
expect(wal.getBufferedBytes()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// P1-6: Aria 主键列走主 LSM(无冗余二级索引)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('[v0.3.3] P1-6: Aria 主键查询优化', () => {
|
||||||
|
test('PK $in 查询走主 LSM 返回正确行', async () => {
|
||||||
|
const e = new AriaEngine({ storageBackend: 'memory', walSyncMode: 'full' });
|
||||||
|
await e.open('pk1', 1);
|
||||||
|
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true }, v: { type: 'number' } } });
|
||||||
|
await e.insert('t', [{ id: '1', v: 10 }, { id: '2', v: 20 }, { id: '3', v: 30 }]);
|
||||||
|
const rows = await e.find('t', { table: 't', where: { id: { $in: ['1', '3'] } } });
|
||||||
|
expect(rows.map((r) => r.id).sort()).toEqual(['1', '3']);
|
||||||
|
await e.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PK 范围查询(字符串字典序)', async () => {
|
||||||
|
const e = new AriaEngine({ storageBackend: 'memory', walSyncMode: 'full' });
|
||||||
|
await e.open('pk2', 1);
|
||||||
|
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true }, v: { type: 'number' } } });
|
||||||
|
await e.insert('t', [{ id: 'a', v: 1 }, { id: 'b', v: 2 }, { id: 'c', v: 3 }]);
|
||||||
|
const rows = await e.find('t', { table: 't', where: { id: { $gte: 'b' } } });
|
||||||
|
expect(rows.map((r) => r.id)).toEqual(['b', 'c']);
|
||||||
|
const lt = await e.find('t', { table: 't', where: { id: { $lt: 'b' } } });
|
||||||
|
expect(lt.map((r) => r.id)).toEqual(['a']);
|
||||||
|
await e.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('主键列不再创建独立二级索引(dropIndex 仍保护主键)', async () => {
|
||||||
|
const e = new AriaEngine({ storageBackend: 'memory', walSyncMode: 'full' });
|
||||||
|
await e.open('pk3', 1);
|
||||||
|
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true }, name: { type: 'string', index: true } } });
|
||||||
|
// 主键索引保护仍在
|
||||||
|
await expect(e.dropIndex('t', 'id')).rejects.toThrow('Cannot drop primary key');
|
||||||
|
// 二级索引(name)仍可正常使用
|
||||||
|
await e.insert('t', [{ id: '1', name: 'x' }]);
|
||||||
|
const rows = await e.find('t', { table: 't', where: { name: 'x' } });
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
await e.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// P1-7: ORDER BY 别名
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('[v0.3.3] P1-7: ORDER BY 别名', () => {
|
||||||
|
test('SELECT 别名可被 ORDER BY 引用', async () => {
|
||||||
|
const db = new MetonaSqlark({ name: `alias-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, mode: 'memory' });
|
||||||
|
await db.init();
|
||||||
|
await db.defineTable('users', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
name: { type: 'string' },
|
||||||
|
age: { type: 'number' },
|
||||||
|
});
|
||||||
|
await db.query("INSERT INTO users VALUES ('1', 'Alice', 30)");
|
||||||
|
await db.query("INSERT INTO users VALUES ('2', 'Bob', 25)");
|
||||||
|
await db.query("INSERT INTO users VALUES ('3', 'Carol', 35)");
|
||||||
|
|
||||||
|
const rows = await db.query('SELECT name AS n FROM users ORDER BY n DESC') as Record<string, unknown>[];
|
||||||
|
expect(rows.map((r) => r.n)).toEqual(['Carol', 'Bob', 'Alice']);
|
||||||
|
|
||||||
|
const withLimit = await db.query('SELECT name AS n FROM users ORDER BY n ASC LIMIT 2') as Record<string, unknown>[];
|
||||||
|
expect(withLimit.map((r) => r.n)).toEqual(['Alice', 'Bob']);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GROUP BY 场景 ORDER BY 聚合别名', async () => {
|
||||||
|
const db = new MetonaSqlark({ name: `alias2-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, mode: 'memory' });
|
||||||
|
await db.init();
|
||||||
|
await db.defineTable('emp', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
dept: { type: 'string' },
|
||||||
|
salary: { type: 'number' },
|
||||||
|
});
|
||||||
|
await db.query("INSERT INTO emp VALUES ('1', 'eng', 100)");
|
||||||
|
await db.query("INSERT INTO emp VALUES ('2', 'eng', 200)");
|
||||||
|
await db.query("INSERT INTO emp VALUES ('3', 'ops', 300)");
|
||||||
|
|
||||||
|
const rows = await db.query('SELECT dept, COUNT(*) AS cnt FROM emp GROUP BY dept ORDER BY cnt DESC') as Record<string, unknown>[];
|
||||||
|
expect(rows[0]).toEqual({ dept: 'eng', cnt: 2 });
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// P1-8: SQL 字符串 '' 标准转义
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('[v0.3.3] P1-8: SQL 字符串转义', () => {
|
||||||
|
test("'' 双引号转义为单个引号", async () => {
|
||||||
|
const db = new MetonaSqlark({ name: `esc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, mode: 'memory' });
|
||||||
|
await db.init();
|
||||||
|
await db.defineTable('t', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
note: { type: 'string' },
|
||||||
|
});
|
||||||
|
await db.query("INSERT INTO t VALUES ('1', 'it''s a test')");
|
||||||
|
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
|
||||||
|
expect(rows[0].note).toBe("it's a test");
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('反斜杠转义仍兼容', () => {
|
||||||
|
const tokens = tokenize("SELECT 'a\\'b'");
|
||||||
|
expect(tokens[1].value).toBe("a'b");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// P1-9: Savepoint 回滚后 MVCC 一致
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('[v0.3.3] P1-9: Savepoint + MVCC 一致性', () => {
|
||||||
|
test('rollbackToSavepoint 后提交,数据为 savepoint 时状态', async () => {
|
||||||
|
const e = new AriaEngine({ storageBackend: 'memory', walSyncMode: 'full' });
|
||||||
|
await e.open('sp1', 1);
|
||||||
|
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true }, v: { type: 'number' } } });
|
||||||
|
await e.insert('t', [{ id: '1', v: 1 }]);
|
||||||
|
await e.beginTransaction();
|
||||||
|
await e.insert('t', [{ id: '2', v: 2 }]);
|
||||||
|
await e.savepoint('sp');
|
||||||
|
await e.insert('t', [{ id: '3', v: 3 }]);
|
||||||
|
await e.rollbackToSavepoint('sp');
|
||||||
|
await e.commitTransaction();
|
||||||
|
const rows = await e.find('t', { table: 't' });
|
||||||
|
expect(rows.map((r) => r.id).sort()).toEqual(['1', '2']);
|
||||||
|
await e.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('savepoint 回滚后事务仍可继续写入并提交', async () => {
|
||||||
|
const e = new AriaEngine({ storageBackend: 'memory', walSyncMode: 'full' });
|
||||||
|
await e.open('sp2', 1);
|
||||||
|
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true } } });
|
||||||
|
await e.beginTransaction();
|
||||||
|
await e.savepoint('sp');
|
||||||
|
await e.insert('t', [{ id: '1' }]);
|
||||||
|
await e.rollbackToSavepoint('sp');
|
||||||
|
await e.insert('t', [{ id: '2' }]);
|
||||||
|
await e.commitTransaction();
|
||||||
|
const rows = await e.find('t', { table: 't' });
|
||||||
|
expect(rows.map((r) => r.id)).toEqual(['2']);
|
||||||
|
await e.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 端到端冒烟
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('[v0.3.3] 端到端', () => {
|
||||||
|
test('全部修复点可共存于 MetonaSqlark API', async () => {
|
||||||
|
expect(VERSION).toBe('0.4.1');
|
||||||
|
const db = new MetonaSqlark({ name: `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, mode: 'memory' });
|
||||||
|
await db.init();
|
||||||
|
await db.defineTable('users', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
email: { type: 'string', unique: true, index: true },
|
||||||
|
age: { type: 'number' },
|
||||||
|
});
|
||||||
|
await db.query("INSERT INTO users VALUES ('1', 'a@x.com', 20)");
|
||||||
|
await db.query("INSERT INTO users VALUES ('2', 'b@x.com', 30)");
|
||||||
|
// 别名排序 + 唯一约束组合
|
||||||
|
const rows = await db.query('SELECT email AS e FROM users ORDER BY e DESC') as Record<string, unknown>[];
|
||||||
|
expect(rows.map((r) => r.e)).toEqual(['b@x.com', 'a@x.com']);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
/**
|
||||||
|
* v0.4.0 功能扩展测试
|
||||||
|
* - B-1: 流式查询 queryStream / findStream
|
||||||
|
* - B-2: 多列 ON 哈希连接
|
||||||
|
* - B-3: FROM 子查询(派生表)
|
||||||
|
* - B-4: COUNT(DISTINCT) + NULLS FIRST/LAST
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'fake-indexeddb/auto';
|
||||||
|
import { MetonaSqlark } from '../src/core';
|
||||||
|
import { AriaEngine } from '../src/engine/aria/index';
|
||||||
|
|
||||||
|
const uniqueName = (prefix: string): string =>
|
||||||
|
`${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
|
||||||
|
describe('[v0.4.0] B-1: 流式查询', () => {
|
||||||
|
test('Aria findStream 逐行回调 + limit 生效', async () => {
|
||||||
|
const e = new AriaEngine({ storageBackend: 'memory', walSyncMode: 'full' });
|
||||||
|
await e.open('stream1', 1);
|
||||||
|
await e.createTable({ name: 'logs', columns: { id: { type: 'string', primaryKey: true }, level: { type: 'string' } } });
|
||||||
|
const rows: Record<string, unknown>[] = [];
|
||||||
|
for (let i = 0; i < 5000; i++) rows.push({ id: `${i}`, level: i % 2 ? 'error' : 'info' });
|
||||||
|
await e.insert('logs', rows);
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
let sum = 0;
|
||||||
|
const n = await e.findStream('logs', { table: 'logs', where: { level: 'error' }, limit: 100 }, (r) => {
|
||||||
|
count++;
|
||||||
|
sum += Number(r.id);
|
||||||
|
});
|
||||||
|
expect(n).toBe(100);
|
||||||
|
expect(count).toBe(100);
|
||||||
|
expect(sum).toBeGreaterThan(0);
|
||||||
|
await e.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('db.queryStream 端到端(WHERE + 投影)', async () => {
|
||||||
|
const db = new MetonaSqlark({ name: uniqueName('qs'), mode: 'memory' });
|
||||||
|
await db.init();
|
||||||
|
await db.defineTable('users', { id: { type: 'string', primaryKey: true }, age: { type: 'number' } });
|
||||||
|
await db.query("INSERT INTO users VALUES ('1', 20), ('2', 30), ('3', 40), ('4', 30)");
|
||||||
|
const seen: Record<string, unknown>[] = [];
|
||||||
|
const total = await db.queryStream('SELECT id FROM users WHERE age >= 30', (row) => seen.push(row));
|
||||||
|
expect(total).toBe(3);
|
||||||
|
expect(seen.map((r) => r.id).sort()).toEqual(['2', '3', '4']);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('queryStream 对 async 回调回退物化(不吞 Promise)', async () => {
|
||||||
|
const db = new MetonaSqlark({ name: uniqueName('qa'), mode: 'memory' });
|
||||||
|
await db.init();
|
||||||
|
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
|
||||||
|
await db.query("INSERT INTO t VALUES ('1'), ('2')");
|
||||||
|
const seen: string[] = [];
|
||||||
|
const total = await db.queryStream('SELECT * FROM t', async (row) => {
|
||||||
|
await new Promise((r) => setTimeout(r, 1));
|
||||||
|
seen.push(String(row.id));
|
||||||
|
});
|
||||||
|
expect(total).toBe(2);
|
||||||
|
expect(seen.sort()).toEqual(['1', '2']);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('queryStream 不支持非 SELECT 语句', async () => {
|
||||||
|
const db = new MetonaSqlark({ name: uniqueName('qi'), mode: 'memory' });
|
||||||
|
await db.init();
|
||||||
|
await expect(db.queryStream('INSERT INTO x VALUES (1)', () => {})).rejects.toThrow('only supports SELECT');
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('db.table().stream() 逐行回调', async () => {
|
||||||
|
const db = new MetonaSqlark({ name: uniqueName('qt'), mode: 'memory' });
|
||||||
|
await db.init();
|
||||||
|
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
|
||||||
|
await db.query("INSERT INTO t VALUES ('1', 10), ('2', 20), ('3', 30)");
|
||||||
|
const seen: number[] = [];
|
||||||
|
const total = await db.table('t').stream((row) => seen.push(Number(row.v)), { where: { v: { $gte: 20 } } });
|
||||||
|
expect(total).toBe(2);
|
||||||
|
expect(seen.sort((a, b) => a - b)).toEqual([20, 30]);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('[v0.4.0] B-2: 多列 ON 哈希连接', () => {
|
||||||
|
test('复合键 INNER JOIN 结果正确', async () => {
|
||||||
|
const db = new MetonaSqlark({ name: uniqueName('hj'), mode: 'memory' });
|
||||||
|
await db.init();
|
||||||
|
await db.defineTable('a', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
tenant: { type: 'string', index: true },
|
||||||
|
key: { type: 'string' },
|
||||||
|
});
|
||||||
|
await db.defineTable('b', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
tenant: { type: 'string', index: true },
|
||||||
|
key: { type: 'string' },
|
||||||
|
val: { type: 'number' },
|
||||||
|
});
|
||||||
|
await db.query("INSERT INTO a VALUES ('a1', 't1', 'k1'), ('a2', 't2', 'k2'), ('a3', 't1', 'k9')");
|
||||||
|
await db.query("INSERT INTO b VALUES ('b1', 't1', 'k1', 100), ('b2', 't1', 'k2', 200), ('b3', 't2', 'k2', 300)");
|
||||||
|
|
||||||
|
// tenant + key 复合等值连接
|
||||||
|
const rows = await db.query(
|
||||||
|
"SELECT a.id AS aid, b.val FROM a INNER JOIN b ON a.tenant = b.tenant AND a.key = b.key",
|
||||||
|
) as Record<string, unknown>[];
|
||||||
|
expect(rows.length).toBe(2);
|
||||||
|
const byAid = Object.fromEntries(rows.map((r) => [r.aid, r['b.val']]));
|
||||||
|
expect(byAid['a1']).toBe(100); // t1/k1 匹配 b1
|
||||||
|
expect(byAid['a2']).toBe(300); // t2/k2 匹配 b3(t1/k2 的 b2 因 tenant 不同被排除)
|
||||||
|
expect(byAid['a3']).toBeUndefined(); // t1/k9 无匹配
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('复合键 LEFT JOIN 无匹配行置 null', async () => {
|
||||||
|
const db = new MetonaSqlark({ name: uniqueName('hl'), mode: 'memory' });
|
||||||
|
await db.init();
|
||||||
|
await db.defineTable('a', { id: { type: 'string', primaryKey: true }, k1: { type: 'string', index: true }, k2: { type: 'string' } });
|
||||||
|
await db.defineTable('b', { id: { type: 'string', primaryKey: true }, k1: { type: 'string', index: true }, k2: { type: 'string' }, v: { type: 'number' } });
|
||||||
|
await db.query("INSERT INTO a VALUES ('a1', 'x', 'y'), ('a2', 'x', 'z')");
|
||||||
|
await db.query("INSERT INTO b VALUES ('b1', 'x', 'y', 5)");
|
||||||
|
const rows = await db.query(
|
||||||
|
"SELECT a.id AS aid, b.v FROM a LEFT JOIN b ON a.k1 = b.k1 AND a.k2 = b.k2",
|
||||||
|
) as Record<string, unknown>[];
|
||||||
|
expect(rows.length).toBe(2);
|
||||||
|
expect(rows.find((r) => r.aid === 'a1')?.['b.v']).toBe(5);
|
||||||
|
expect(rows.find((r) => r.aid === 'a2')?.['b.v']).toBeNull();
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('[v0.4.0] B-3: FROM 子查询(派生表)', () => {
|
||||||
|
test('FROM (SELECT ...) 派生表 + WHERE + ORDER BY', async () => {
|
||||||
|
const db = new MetonaSqlark({ name: uniqueName('sub'), mode: 'memory' });
|
||||||
|
await db.init();
|
||||||
|
await db.defineTable('emp', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
dept: { type: 'string' },
|
||||||
|
salary: { type: 'number' },
|
||||||
|
});
|
||||||
|
await db.query("INSERT INTO emp VALUES ('1', 'eng', 100), ('2', 'eng', 300), ('3', 'ops', 200)");
|
||||||
|
|
||||||
|
const rows = await db.query(
|
||||||
|
"SELECT dept, total FROM (SELECT dept, SUM(salary) AS total FROM emp GROUP BY dept) AS t WHERE total > 150 ORDER BY total DESC",
|
||||||
|
) as Record<string, unknown>[];
|
||||||
|
expect(rows).toEqual([{ dept: 'eng', total: 400 }, { dept: 'ops', total: 200 }]);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('派生表无别名也可用', async () => {
|
||||||
|
const db = new MetonaSqlark({ name: uniqueName('sub2'), mode: 'memory' });
|
||||||
|
await db.init();
|
||||||
|
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
|
||||||
|
await db.query("INSERT INTO t VALUES ('1', 1), ('2', 2)");
|
||||||
|
const rows = await db.query("SELECT v FROM (SELECT * FROM t WHERE v > 1) WHERE v < 10") as Record<string, unknown>[];
|
||||||
|
expect(rows).toEqual([{ v: 2 }]);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('[v0.4.0] B-4: COUNT(DISTINCT) + NULLS FIRST/LAST', () => {
|
||||||
|
test('COUNT(DISTINCT col)', async () => {
|
||||||
|
const db = new MetonaSqlark({ name: uniqueName('cd'), mode: 'memory' });
|
||||||
|
await db.init();
|
||||||
|
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, dept: { type: 'string' } });
|
||||||
|
await db.query("INSERT INTO t VALUES ('1', 'a'), ('2', 'b'), ('3', 'a'), ('4', 'c')");
|
||||||
|
const rows = await db.query('SELECT COUNT(DISTINCT dept) AS n FROM t') as Record<string, unknown>[];
|
||||||
|
expect(rows[0].n).toBe(3);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SUM(DISTINCT col) 与 GROUP BY 组合', async () => {
|
||||||
|
const db = new MetonaSqlark({ name: uniqueName('sd'), mode: 'memory' });
|
||||||
|
await db.init();
|
||||||
|
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, g: { type: 'string' }, v: { type: 'number' } });
|
||||||
|
await db.query("INSERT INTO t VALUES ('1', 'x', 10), ('2', 'x', 10), ('3', 'y', 20)");
|
||||||
|
const rows = await db.query('SELECT g, SUM(DISTINCT v) AS s FROM t GROUP BY g ORDER BY g') as Record<string, unknown>[];
|
||||||
|
expect(rows).toEqual([{ g: 'x', s: 10 }, { g: 'y', s: 20 }]);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NULLS FIRST 排序', async () => {
|
||||||
|
const db = new MetonaSqlark({ name: uniqueName('nf'), mode: 'memory' });
|
||||||
|
await db.init();
|
||||||
|
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
|
||||||
|
await db.query("INSERT INTO t VALUES ('1', 10), ('2', NULL), ('3', 20)");
|
||||||
|
const rows = await db.query('SELECT v FROM t ORDER BY v ASC NULLS FIRST') as Record<string, unknown>[];
|
||||||
|
expect(rows).toEqual([{ v: null }, { v: 10 }, { v: 20 }]);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NULLS LAST 排序(降序时 NULL 排最后)', async () => {
|
||||||
|
const db = new MetonaSqlark({ name: uniqueName('nl'), mode: 'memory' });
|
||||||
|
await db.init();
|
||||||
|
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
|
||||||
|
await db.query("INSERT INTO t VALUES ('1', 10), ('2', NULL), ('3', 20)");
|
||||||
|
const rows = await db.query('SELECT v FROM t ORDER BY v DESC NULLS LAST') as Record<string, unknown>[];
|
||||||
|
expect(rows).toEqual([{ v: 20 }, { v: 10 }, { v: null }]);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('字符串常量列 + \'\' 转义(无 FROM 查询)', async () => {
|
||||||
|
const db = new MetonaSqlark({ name: uniqueName('lit'), mode: 'memory' });
|
||||||
|
await db.init();
|
||||||
|
const rows = await db.query("SELECT 'it''s a test' AS escaped") as Record<string, unknown>[];
|
||||||
|
expect(rows).toEqual([{ escaped: "it's a test" }]);
|
||||||
|
const mixed = await db.query("SELECT 'hello' AS greeting, 'world' AS subject") as Record<string, unknown>[];
|
||||||
|
expect(mixed).toEqual([{ greeting: 'hello', subject: 'world' }]);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ORDER BY / GROUP BY 支持表前缀列(u.name / o.amount)', async () => {
|
||||||
|
const db = new MetonaSqlark({ name: uniqueName('prefix'), mode: 'memory' });
|
||||||
|
await db.init();
|
||||||
|
await db.defineTable('users', { id: { type: 'string', primaryKey: true }, name: { type: 'string' }, age: { type: 'number' } });
|
||||||
|
await db.defineTable('orders', { id: { type: 'string', primaryKey: true }, user_id: { type: 'string' }, amount: { type: 'number' } });
|
||||||
|
await db.query("INSERT INTO users VALUES ('1', 'Alice', 30), ('2', 'Bob', 25)");
|
||||||
|
await db.query("INSERT INTO orders VALUES ('o1', '1', 1200), ('o2', '2', 50)");
|
||||||
|
|
||||||
|
// JOIN + ORDER BY 表前缀(演示页报错场景)
|
||||||
|
const join = await db.query(
|
||||||
|
'SELECT u.name, o.amount FROM users u INNER JOIN orders o ON u.id = o.user_id ORDER BY o.amount DESC',
|
||||||
|
) as Record<string, unknown>[];
|
||||||
|
expect(join.map((r) => r['u.name'])).toEqual(['Alice', 'Bob']);
|
||||||
|
|
||||||
|
// JOIN + GROUP BY 表前缀
|
||||||
|
const grouped = await db.query(
|
||||||
|
'SELECT u.name, SUM(o.amount) AS total FROM users u INNER JOIN orders o ON u.id = o.user_id GROUP BY u.name',
|
||||||
|
) as Record<string, unknown>[];
|
||||||
|
expect(grouped.map((r) => r.total).sort((a, b) => Number(b) - Number(a))).toEqual([1200, 50]);
|
||||||
|
|
||||||
|
// 非 JOIN + ORDER BY / GROUP BY 表前缀
|
||||||
|
const plain = await db.query('SELECT name FROM users ORDER BY users.age DESC') as Record<string, unknown>[];
|
||||||
|
expect(plain.map((r) => r.name)).toEqual(['Alice', 'Bob']);
|
||||||
|
const byAge = await db.query('SELECT users.age, COUNT(*) AS n FROM users GROUP BY users.age') as Record<string, unknown>[];
|
||||||
|
expect(byAge.length).toBe(2);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('INSERT ... SELECT 按源表列顺序映射(缺列不填,不报类型错)', async () => {
|
||||||
|
const db = new MetonaSqlark({ name: uniqueName('insel'), mode: 'memory' });
|
||||||
|
await db.init();
|
||||||
|
await db.defineTable('users', { id: { type: 'string', primaryKey: true }, name: { type: 'string', required: true }, email: { type: 'string' }, age: { type: 'number', default: 0 } });
|
||||||
|
await db.table('users').insertMany([{ id: '1', name: 'Alice', email: 'a@x.com', age: 30 }]);
|
||||||
|
// alter 预设:ADD 列 → 显式列名插入(无 email)→ DROP 列,产生行键缺 email 的行
|
||||||
|
await db.query('ALTER TABLE users ADD COLUMN phone STRING');
|
||||||
|
await db.query("INSERT INTO users (id, name, age, phone) VALUES ('6', 'Frank', 33, '123')");
|
||||||
|
await db.query('ALTER TABLE users DROP COLUMN phone');
|
||||||
|
|
||||||
|
await db.query('CREATE TABLE users_backup (id STRING PRIMARY KEY, name STRING, email STRING, age NUMBER)');
|
||||||
|
await db.query('INSERT INTO users_backup SELECT * FROM users');
|
||||||
|
const rows = await db.query('SELECT * FROM users_backup ORDER BY id') as Record<string, unknown>[];
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(rows[0]).toEqual({ id: '1', name: 'Alice', email: 'a@x.com', age: 30 });
|
||||||
|
expect(rows[1].id).toBe('6');
|
||||||
|
expect(rows[1].name).toBe('Frank');
|
||||||
|
expect(rows[1].age).toBe(33);
|
||||||
|
|
||||||
|
// SELECT 指定列映射
|
||||||
|
await db.query('CREATE TABLE names_only (id STRING PRIMARY KEY, name STRING)');
|
||||||
|
await db.query('INSERT INTO names_only SELECT id, name FROM users');
|
||||||
|
const names = await db.query('SELECT * FROM names_only') as Record<string, unknown>[];
|
||||||
|
expect(names.map((r) => r.name).sort()).toEqual(['Alice', 'Frank']);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('关联 EXISTS / NOT EXISTS(SELECT 列仅含 name 时绑定主键仍生效)', async () => {
|
||||||
|
const db = new MetonaSqlark({ name: uniqueName('exists'), mode: 'memory' });
|
||||||
|
await db.init();
|
||||||
|
await db.defineTable('users', { id: { type: 'string', primaryKey: true }, name: { type: 'string' }, age: { type: 'number' } });
|
||||||
|
await db.defineTable('orders', { id: { type: 'string', primaryKey: true }, user_id: { type: 'string' }, amount: { type: 'number' } });
|
||||||
|
await db.query("INSERT INTO users VALUES ('1', 'Alice', 30), ('2', 'Bob', 25), ('3', 'Eve', 22)");
|
||||||
|
await db.query("INSERT INTO orders VALUES ('o1', '1', 100), ('o2', '1', 200), ('o3', '2', 50)");
|
||||||
|
|
||||||
|
// SELECT 只投影 name(不含 id),EXISTS 绑定 u.id 必须仍工作
|
||||||
|
const withOrders = await db.query(
|
||||||
|
'SELECT u.name FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)',
|
||||||
|
) as Record<string, unknown>[];
|
||||||
|
expect(withOrders.map((r) => r.name).sort()).toEqual(['Alice', 'Bob']);
|
||||||
|
|
||||||
|
const without = await db.query(
|
||||||
|
'SELECT u.name FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)',
|
||||||
|
) as Record<string, unknown>[];
|
||||||
|
expect(without.map((r) => r.name)).toEqual(['Eve']);
|
||||||
|
|
||||||
|
// SELECT 列带表前缀 + EXISTS 组合
|
||||||
|
const prefixed = await db.query(
|
||||||
|
'SELECT u.name FROM users u WHERE u.age > 21 AND EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)',
|
||||||
|
) as Record<string, unknown>[];
|
||||||
|
expect(prefixed.map((r) => r.name).sort()).toEqual(['Alice', 'Bob']);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HAVING 引用聚合表达式 + 标量子查询', async () => {
|
||||||
|
const db = new MetonaSqlark({ name: uniqueName('having'), mode: 'memory' });
|
||||||
|
await db.init();
|
||||||
|
await db.defineTable('users', { id: { type: 'string', primaryKey: true }, name: { 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 ('1', 'Alice'), ('2', 'Bob'), ('3', 'Charlie')");
|
||||||
|
await db.query("INSERT INTO orders VALUES ('o1', '1', 1200), ('o2', '1', 50), ('o3', '2', 150), ('o4', '3', 400), ('o5', '3', 20)");
|
||||||
|
|
||||||
|
// HAVING SUM(...) > (SELECT AVG(...)):均值 364,Alice 1250 / Charlie 420 达标
|
||||||
|
const rows = await db.query(
|
||||||
|
'SELECT u.name, SUM(o.amount) AS spent FROM users u INNER JOIN orders o ON u.id = o.user_id GROUP BY u.name HAVING SUM(o.amount) > (SELECT AVG(amount) FROM orders)',
|
||||||
|
) as Record<string, unknown>[];
|
||||||
|
expect(rows.map((r) => [r['u.name'], r.spent])).toEqual([['Alice', 1250], ['Charlie', 420]]);
|
||||||
|
|
||||||
|
// HAVING 引用别名也生效
|
||||||
|
const byAlias = await db.query(
|
||||||
|
'SELECT u.name, SUM(o.amount) AS spent FROM users u INNER JOIN orders o ON u.id = o.user_id GROUP BY u.name HAVING spent > 400',
|
||||||
|
) as Record<string, unknown>[];
|
||||||
|
expect(byAlias.map((r) => r['u.name']).sort()).toEqual(['Alice', 'Charlie']);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('JOIN 主表 WHERE 条件下推走索引 + DROP INDEX 报错', async () => {
|
||||||
|
const db = new MetonaSqlark({ name: uniqueName('idx'), mode: 'memory' });
|
||||||
|
await db.init();
|
||||||
|
await db.defineTable('users', { id: { type: 'string', primaryKey: true }, name: { type: 'string' } });
|
||||||
|
await db.defineTable('orders', { id: { type: 'string', primaryKey: true }, user_id: { type: 'string' }, product: { type: 'string' } });
|
||||||
|
await db.query("INSERT INTO users VALUES ('1', 'Alice'), ('2', 'Bob')");
|
||||||
|
await db.query("INSERT INTO orders VALUES ('o1', '1', 'Laptop'), ('o2', '1', 'Mouse'), ('o3', '2', 'Keyboard')");
|
||||||
|
|
||||||
|
await db.query('CREATE INDEX idx_orders_user ON orders (user_id)');
|
||||||
|
|
||||||
|
// WHERE 主表前缀条件下推:引擎收到的 orders 查询 where 为 { user_id: { $eq: '1' } }
|
||||||
|
const eng = db.getEngine() as { find: (...a: unknown[]) => Promise<unknown> };
|
||||||
|
const origFind = eng.find.bind(eng);
|
||||||
|
let pushed: unknown = null;
|
||||||
|
eng.find = async (t: string, q: { where?: unknown }) => {
|
||||||
|
if (t === 'orders' && q?.where) pushed = q.where;
|
||||||
|
return origFind(t, q);
|
||||||
|
};
|
||||||
|
const rows = await db.query(
|
||||||
|
"SELECT u.name, o.product FROM orders o JOIN users u ON u.id = o.user_id WHERE o.user_id = '1'",
|
||||||
|
) as Record<string, unknown>[];
|
||||||
|
expect(rows.map((r) => [r['u.name'], r['o.product']])).toEqual([['Alice', 'Laptop'], ['Alice', 'Mouse']]);
|
||||||
|
expect(pushed).toEqual({ user_id: { $eq: '1' } });
|
||||||
|
|
||||||
|
// DROP 存在的索引成功;再次 DROP(列已无索引)报 INDEX_NOT_FOUND
|
||||||
|
await db.query('DROP INDEX idx_orders_user ON orders (user_id)');
|
||||||
|
await expect(db.query('DROP INDEX idx_nonexist ON orders (user_id)')).rejects.toThrow('Index on column');
|
||||||
|
|
||||||
|
// DROP 后回退全表扫描,结果不变
|
||||||
|
const after = await db.query("SELECT product FROM orders WHERE user_id = '2'") as Record<string, unknown>[];
|
||||||
|
expect(after.map((r) => r.product)).toEqual(['Keyboard']);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('裸布尔列条件:WHERE done / CASE WHEN done(真值判断)', async () => {
|
||||||
|
const db = new MetonaSqlark({ name: uniqueName('bool'), mode: 'memory' });
|
||||||
|
await db.init();
|
||||||
|
await db.defineTable('tasks', { id: { type: 'string', primaryKey: true }, title: { type: 'string' }, done: { type: 'boolean', default: false } });
|
||||||
|
await db.query("INSERT INTO tasks VALUES ('t1', 'A', false), ('t2', 'B', true), ('t3', 'C', false)");
|
||||||
|
|
||||||
|
// WHERE 裸列
|
||||||
|
const done = await db.query('SELECT id FROM tasks WHERE done') as Record<string, unknown>[];
|
||||||
|
expect(done.map((r) => r.id)).toEqual(['t2']);
|
||||||
|
|
||||||
|
// CASE WHEN 裸列(演示页 aria 预设)
|
||||||
|
const labeled = await db.query(
|
||||||
|
"SELECT title, CASE WHEN done THEN 'done' ELSE 'pending' END AS status FROM tasks",
|
||||||
|
) as Record<string, unknown>[];
|
||||||
|
expect(labeled.find((r) => r.title === 'B')?.status).toBe('done');
|
||||||
|
expect(labeled.find((r) => r.title === 'A')?.status).toBe('pending');
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Aria $in 查询去重(IN 子查询含重复值不返回重复行)', async () => {
|
||||||
|
const db = new MetonaSqlark({ name: uniqueName('indup'), mode: 'aria', diskEngine: 'indexeddb' });
|
||||||
|
await db.init();
|
||||||
|
await db.getEngine().clearAll();
|
||||||
|
await db.defineTable('users', { id: { type: 'string', primaryKey: true }, name: { type: 'string' } });
|
||||||
|
await db.defineTable('orders', { id: { type: 'string', primaryKey: true }, user_id: { type: 'string' } });
|
||||||
|
await db.query("INSERT INTO users VALUES ('1', 'Alice'), ('2', 'Bob')");
|
||||||
|
await db.query("INSERT INTO orders VALUES ('o1', '1'), ('o2', '1')");
|
||||||
|
|
||||||
|
const rows = await db.query('SELECT name FROM users WHERE id IN (SELECT user_id FROM orders)') as Record<string, unknown>[];
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].name).toBe('Alice');
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user