release: v0.4.1 — Aria 级联/ALTER/clearAll + 流式查询/派生表 + 正确性加固
CI / test (20.x) (push) Successful in 10m4s
CI / test (22.x) (push) Successful in 10m8s
CI / test (24.x) (push) Successful in 9m55s
CI / test (18.x) (push) Successful in 10m9s

新增:
- 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:
thzxx
2026-08-08 13:36:34 +08:00
parent 82ad8e93b9
commit a1e4f5071c
38 changed files with 13493 additions and 8756 deletions
+60
View File
@@ -2,6 +2,66 @@
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
### Added
+22 -8
View File
@@ -1,10 +1,10 @@
# MetonaSqlark
<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/coverage-81.1%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/coverage-81.5%25-brightgreen?style=flat-square" alt="coverage">
<img src="https://img.shields.io/badge/tests-894%20passed-success?style=flat-square" alt="tests">
</p>
> 基于 TypeScript 的**前端关系型数据库**,支持完整 SQL 查询、Query Builder 链式 API、与 **AriaEngine 自研页面式存储引擎**。
@@ -18,13 +18,15 @@
- 🔒 **生产级数据安全** — WAL CRC 完整性校验、`RESTRICT` 外键约束、Hybrid 提交原子性、SQL 注入防护
- 🛡 **输入校验全覆盖**`maxLength`/`min`/`max` 约束、类型检查、必填验证
- 💾 **多引擎架构** — Memory / IndexedDB / OPFS / Hybrid(write-through) / Aria 五种模式
- 📝 **完整 SQL 支持** — SELECT/JOIN/子查询/GROUP BY/HAVING/ORDER BY/LIMIT/BETWEEN/IF NOT EXISTS/ALTER TABLE/TRUNCATE TABLE/UNION/INSERT...SELECT/事务语句/CREATE INDEX/EXISTSv0.3.0
- 📝 **完整 SQL 支持** — SELECT/JOIN/子查询/GROUP BY/HAVING/ORDER BY/LIMIT/BETWEEN/IF NOT EXISTS/ALTER TABLE/TRUNCATE TABLE/UNION/INSERT...SELECT/事务语句/CREATE INDEX/EXISTSv0.3.0+ CASE WHEN/哈希连接/组提交(v0.3.1+ 多标签页同步(v0.3.2
- 🚰 **流式查询**`queryStream`/`stream()` 逐行回调,Aria LSM 惰性扫描不物化结果集(v0.4.1)
- 🧩 **派生表**`FROM (SELECT ...)` 子查询作为行源,多列 ON 哈希连接,COUNT(DISTINCT)NULLS FIRST/LASTv0.4.1
- 🔗 **Query Builder API** — 链式 `.select().where().orderBy().limit().execute()`
- 🔄 **事务回滚** — Memory/IndexedDB/Hybrid/Aria 四引擎事务原子性,自动回滚,MVCC 版本链接入读写路径
- 🌲 **RB-Tree 完整实现** — 标准红黑树插入+删除修复,O(log n) 保证
-**性能优化** — SSTableReader 二分查找统一、IndexedDB 索引利用、crypto 实例化避免全局状态
- 🌐 **浏览器兼容** — Chrome 80+ / Firefox 80+ / Safari 14+ / Edge 80+ / Node.js 16+
- 🧪 **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 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
await db.transaction(async (trx) => {
await trx.table('users').insert({ id: '3', name: 'Charlie' });
@@ -222,6 +235,7 @@ await db2.disconnect(); // 引用计数 -1
| 方法 | 说明 |
|------|------|
| `db.query(sql)` | 执行 SQL 字符串 |
| `db.queryStream(sql, onRow)` | 流式查询(逐行回调,不物化)🆕 |
| `db.table(name)` | 获取表操作对象 |
| `db.defineTable(name, cols)` | 定义表结构 |
| `db.transaction(fn)` | 执行事务(自动回滚)🆕 |
@@ -373,9 +387,9 @@ npm run typecheck # 类型检查
| 指标 | 数值 |
|------|------|
| 测试用例 | 837 |
| 测试套件 | 44 |
| 行覆盖率 | 81.1% |
| 测试用例 | 894 |
| 测试套件 | 47 |
| 行覆盖率 | 81.5% |
| SQL 关键字 | 36 |
| 存储引擎 | 5Memory / IndexedDB / OPFS / Hybrid / **Aria** |
+1010 -141
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
File diff suppressed because one or more lines are too long
+101 -16
View File
@@ -83,6 +83,8 @@ interface OrderBy {
column: string;
/** 排序方向 */
direction: SortDirection;
/** v0.4.0: NULL 值排序位置(first 排最前 / last 排最后,默认同引擎行为) */
nulls?: 'first' | 'last';
}
/** 查询计划 — 由 Executor 编译 AST 后生成 */
interface QueryPlan {
@@ -116,7 +118,7 @@ interface MetonaPlugin {
/** 销毁 */
destroy(): void;
}
declare const VERSION = "0.3.2";
declare const VERSION = "0.4.1";
/**
* metona-sqlark Plugin — 插件系统
@@ -145,11 +147,6 @@ declare class PluginManager {
destroy(): void;
}
/**
* metona-sqlark Engine Interface — 存储引擎抽象接口
* @module engine/interface
*/
interface IStorageEngine {
/** 引擎名称 */
readonly name: string;
@@ -173,6 +170,8 @@ interface IStorageEngine {
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
/** 查询行 */
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>;
/** 删除行,返回影响行数 */
@@ -181,6 +180,10 @@ interface IStorageEngine {
count(tableName: string, query?: QueryPlan): Promise<number>;
/** 清空表数据(保留结构) */
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 */
createIndex?(tableName: string, column: string, unique?: boolean): Promise<void>;
/** 删除二级索引(DROP INDEX */
@@ -281,6 +284,8 @@ interface SelectStatement {
columns: ColumnRef[];
distinct?: boolean;
from: string;
/** v0.4.0: FROM (SELECT ...) 派生表(存在时 from 为占位,行源取此子查询结果) */
fromSubquery?: SelectStatement | SelectUnionStatement;
/** 主表别名 */
alias?: string;
/** JOIN 子句列表 */
@@ -364,7 +369,13 @@ declare class QueryExecutor {
private executeJoinSelect;
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 查询右表 → 哈希映射匹配。
* 替代嵌套循环,大表 INNER/LEFT JOIN 复杂度 O(N + M)。
* 不适用时返回 null(回退嵌套循环)。
@@ -389,11 +400,17 @@ declare class QueryExecutor {
private executeRollback;
/** 列列表是否包含 CASE WHEN 表达式 */
private hasCaseColumn;
/**
* v0.3.3: ORDER BY 是否引用 SELECT 别名(如 `SELECT name AS n ... ORDER BY n`)。
* 别名列在引擎层投影前不存在,需投影后重新排序。
*/
private orderByUsesSelectAlias;
/** WHERE 是否包含 CASE WHEN 表达式键 */
private whereHasCase;
getEngine(): IStorageEngine;
/**
* 列投影(v0.3.1):普通列走 projectColumnsCASE WHEN 表达式逐行求值
* 列投影(v0.3.1):普通列走 projectColumnsCASE WHEN 表达式逐行求值
* v0.3.3: 支持 `col AS alias` 列别名
*/
private projectRow;
/** 检查 SELECT 列列表中是否包含聚合函数 */
@@ -516,6 +533,13 @@ declare class Table<T = Record<string, unknown>> {
insert(row: T & Record<string, unknown>): Promise<string>;
insertMany(rows: (T & Record<string, unknown>)[]): Promise<string[]>;
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;
delete(): DeleteQueryBuilder;
count(where?: Record<string, unknown>): Promise<number>;
@@ -580,6 +604,23 @@ declare class MetonaSqlark {
getTableNames(): Promise<string[]>;
/** 执行 SQL 字符串查询 */
query(sql: string): Promise<unknown>;
/**
* 流式查询:逐行回调,不一次性物化全部结果(大表友好)。
* 支持简单 SELECTWHERE/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>;
/** 导出表数据为 JSON */
@@ -648,6 +689,8 @@ declare class MemoryEngine implements IStorageEngine {
getTableSchema(tableName: string): Promise<TableSchema | null>;
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
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>;
delete(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 updateIndexes;
/** v0.3.3: 从所有索引中移除一行的条目(update/delete 前调用,修复索引过期/残留) */
private removeIndexEntries;
/**
* 级联删除:查找引用 tableName.pkValue 的所有表的行并删除。
* @returns 级联删除的行数
@@ -706,6 +751,8 @@ declare class IndexedDBEngine implements IStorageEngine {
getTableSchema(tableName: string): Promise<TableSchema | null>;
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
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>;
delete(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>;
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
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>;
delete(tableName: string, query: QueryPlan): Promise<number>;
count(tableName: string, query?: QueryPlan): Promise<number>;
@@ -798,13 +847,6 @@ interface AriaEngineConfig {
maxMemoryMB?: number;
}
/**
* AriaEngine — 自研页面式存储引擎主类
* @module engine/aria/index
*
* v0.2.5: WAL 同步修复 + MVCC 接入 + 版本统一 + 生产加固
*/
declare class AriaEngine implements IStorageEngine {
readonly name = "aria";
private config;
@@ -826,6 +868,11 @@ declare class AriaEngine implements IStorageEngine {
constructor(config?: AriaEngineConfig);
open(dbName: string, _version: number): Promise<void>;
close(): Promise<void>;
/**
* v0.4.1: 重置数据库 — 清空全部数据与表结构(演示页刷新/重新初始化用)。
* 清空存储后端、LSM、WAL、MVCC 与二级索引,后续可继续使用本实例。
*/
clearAll(): Promise<void>;
isOpen(): boolean;
createTable(schema: TableSchema): 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>[]>;
update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): 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>;
clear(tableName: string): Promise<void>;
/**
* v0.4.1: ALTER TABLE — 结构变更真正生效于存储:
* - ADD: 持久化 schemapersistSchemas),行无需修改
* - 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>;
dropIndex(tableName: string, column: string, _indexName?: string): Promise<void>;
beginTransaction(): Promise<void>;
@@ -849,6 +920,11 @@ declare class AriaEngine implements IStorageEngine {
releaseSavepoint(name: string): Promise<void>;
backup(): Promise<Record<string, Record<string, unknown>[]>>;
private getAllRows;
/**
* v0.3.3: 将事务未提交快照的变更合并到行列表(新增/更新/删除标记)。
* 幂等操作:行已是最新时不重复修改。
*/
private mergeTxnSnapshot;
private getPK;
private validateRow;
private checkType;
@@ -864,6 +940,13 @@ declare class AriaEngine implements IStorageEngine {
*/
private createSSTableStore;
private applyWALRecord;
/**
* v0.3.3: DROP_TABLE 恢复 — 删除 schema 并清除主 LSM 中该表的所有残留数据。
*
* 此前 DROP_TABLE 在恢复时被忽略,而 CREATE_TABLE 回放会重建 schema
* 导致崩溃后"已删除的表和数据复活"(实证 P0 bug)。
*/
private applyDropTableRecovery;
/** 更新行的二级索引条目 */
private updateSecondaryIndexes;
/** 通过二级索引快速查找 */
@@ -942,6 +1025,8 @@ declare class HybridEngine implements IStorageEngine {
getTableSchema(tableName: string): Promise<TableSchema | null>;
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
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>;
delete(tableName: string, query: QueryPlan): Promise<number>;
count(tableName: string, query?: QueryPlan): Promise<number>;
@@ -1131,7 +1216,7 @@ declare class OPFSBackend implements IStorageBackend {
/**
* metona-sqlark — 入口文件
* @module metona-sqlark
* @version 0.2.5
* @version 0.4.1
*
* 前端关系型数据库,内存与磁盘双模式。
* 支持 Query Builder 链式 API 和 SQL 字符串查询。
+1010 -141
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
File diff suppressed because one or more lines are too long
+1010 -141
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+8111 -8111
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"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",
"type": "module",
"main": "dist/metona-sqlark.cjs",
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>⚡ 性能基准 — MetonaSqlark v0.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>">
<style>
:root {
+121 -54
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🧪 在线演示 — MetonaSqlark v0.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>">
<style>
:root {
@@ -84,13 +84,15 @@
<a href="demo.html" class="nav-active">演示</a>
<a href="benchmark.html">基准</a>
</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>
<div class="main">
<div class="editor-panel">
<div class="editor-area">
<textarea id="sql-input" placeholder="输入 SQL 语句...&#10;&#10;SELECT * FROM users;&#10;INSERT INTO users VALUES ('4', 'Diana', 'diana@test.com', 28);&#10;SELECT u.name, o.amount FROM users u INNER JOIN orders o ON u.id = o.user_id;">-- 🚀 MetonaSqlark v0.3.2 在线演示
<textarea id="sql-input" placeholder="输入 SQL 语句...&#10;&#10;SELECT * FROM users;&#10;INSERT INTO users VALUES ('4', 'Diana', 'diana@test.com', 28);&#10;SELECT u.name, o.amount FROM users u INNER JOIN orders o ON u.id = o.user_id;">-- 🚀 MetonaSqlark v0.4.1 在线演示
-- 已预置 users / orders / products 表数据
-- 新特性: 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('index')">🗂 索引</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>
</div>
</div>
@@ -161,57 +164,84 @@ const rowCount = document.getElementById('row-count');
const sqlInput = document.getElementById('sql-input');
// Init database
let currentEngine = 'memory';
async function initDB() {
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();
// Create tables
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' },
});
// v0.4.1: Aria 引擎持久化 — 每次加载清空上次演示数据,保证演示确定性
if (engine === 'aria' && typeof db.getEngine().clearAll === 'function') {
await db.getEngine().clearAll();
}
// 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' },
]);
await seedDemoData(db);
renderInfo(`${engine === 'aria' ? '🌲 AriaEngineLSM-Tree + WAL + MVCC' : '⚡ Memory'} 引擎已就绪`);
} catch (e) {
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
async function runQuery() {
const sql = sqlInput.value.trim();
@@ -316,6 +346,10 @@ function renderSuccess(msg) {
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() {
resultArea.innerHTML = `<div class="empty-state"><div class="icon">⚡</div><div>结果已清空</div></div>`;
updateRowCount(0);
@@ -518,11 +552,13 @@ SELECT COUNT(*) as total FROM temp_logs;
-- 清理
DROP TABLE temp_logs;`,
aria: `-- 🌲 AriaEngine 演示 (v0.2.5 → v0.3.2)
-- AriaEngine: LSM-Tree 自研存储引擎
-- 支持 LSM-Tree · WAL CRC同步 · MVCC版本链 · BloomFilter · 二级索引 · AES-GCM · 836测试
aria: `-- 🌲 AriaEngine 演示 (v0.4.1)
-- 点击右上角「🌲 Aria」按钮切换数据库引擎到 AriaEngine
-- 当前数据库即运行在 Aria 引擎上(LSM-Tree · WAL 崩溃恢复 · MVCC · BloomFilter
-- 基础 CRUD 与 Memory 引擎完全兼容
-- 基础 CRUD 完全兼容
-- 当前引擎确认
-- (内存中 Aria 引擎实例,数据经 WAL + SSTable 持久化到 IndexedDB)
CREATE TABLE IF NOT EXISTS tasks (
id STRING PRIMARY KEY,
title STRING NOT NULL,
@@ -556,8 +592,9 @@ DROP TABLE temp_logs;`,
-- • Buffer Pool: SSTable LRU 缓存 (256页 ~ 1MB) ✅ 已生效
-- • Bloom Filter: FNV-1a + Murmur 双哈希
-- • 二级索引: 每列独立 LSM + 动态 CREATE INDEX
-- • 外键级联: CASCADE / SET NULL / RESTRICT (v0.4.1)
-- 生产环境: mode: 'aria' 激活自研引擎
-- 生产环境 API(与演示页右上角切换等价)
-- const db = await MetonaSqlark.create({
-- 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);`,
index: `-- 🗂 动态索引 CREATE / DROP INDEX (v0.3.0)
-- 为 orders.user_id 创建索引
-- 为 orders.user_id 创建索引(已有数据自动回填)
CREATE INDEX idx_orders_user ON orders (user_id);
-- 索引查找(走二级索引)
-- 索引查找(v0.4.1: JOIN 主表 WHERE 条件下推到引擎,真正走二级索引)
SELECT u.name, o.product, o.amount
FROM orders o JOIN users u ON u.id = o.user_id
WHERE o.user_id = '1';
@@ -646,7 +683,10 @@ WHERE o.user_id = '1';
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)
-- 分号分隔的多语句一次执行
@@ -667,11 +707,35 @@ BEGIN;
INSERT INTO audit VALUES ('a4', 'will be committed');
COMMIT;
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 / LASTNULL 排序位置控制
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]) {
sqlInput.value = presets[name];
await initDB();
runQuery();
}
}
@@ -685,8 +749,11 @@ document.addEventListener('keydown', e => {
});
// Boot
document.getElementById('btn-memory').style.opacity = '1';
document.getElementById('btn-aria').style.opacity = '0.6';
document.getElementById('engine-status').textContent = '⚡ Memory';
initDB().then(() => {
console.log('✅ MetonaSqlark v0.3.2 demo ready');
console.log('✅ MetonaSqlark v0.4.1 demo ready');
setTimeout(runQuery, 300);
}).catch(err => {
renderError('初始化失败: ' + err.message);
+19 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>📖 API 文档 — MetonaSqlark v0.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>">
<style>
:root {
@@ -203,6 +203,21 @@ db.<span class="f">isReady</span>(); <span class="c">// true</span>
<h2 id="sql-query">🔍 SQL 查询</h2>
<p><code>db.query(sql)</code> — 执行标准 SQL 字符串,返回查询结果。</p>
<p><code>db.queryStream(sql, onRow)</code> — 流式查询(v0.4.0):逐行回调不物化结果集,大表友好。支持简单 SELECTWHERE/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) =&gt; {
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>
<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>
<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.3.2 表达式与并发</strong> — WAL full模式真正同步 · MVCC接入读写路径 · SSTableReader二分查找统一 · crypto实例化 · IndexedDB索引利用 · compactLevel public接口 · WAL大小阈值自动checkpoint · SQL注入防护 · ALTER TABLE · TRUNCATE TABLE · 837测试 44套件。</p>
<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 · 多标签页同步 · IDB schema持久化。<br>
<strong>v0.4.1 Aria 级联与演示页引擎切换</strong> — AriaEngine 外键级联(CASCADE/SET NULL/RESTRICT)· `clearAll()` 重置 API · 演示页 ⚡Memory/🌲Aria 引擎切换器 · 894测试 47套件。</p>
<h3>存储模式对比</h3>
<table>
+11 -6
View File
@@ -153,7 +153,7 @@
<!-- Hero -->
<section class="hero">
<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>
<p>TypeScript 原生构建,5 种存储引擎,支持完整 SQL 查询。<br>零运行时依赖,开箱即用。AriaEngine 自研引擎:LSM-Tree + WAL 同步 + MVCC。</p>
<div class="actions">
@@ -243,8 +243,8 @@ npm install @metona-team/metona-sqlark
</div>
<div class="feature-card">
<div class="icon">🌲</div>
<h3>AriaEngine <span style="font-size:0.65rem;color:var(--accent);vertical-align:super;">v0.3.2</span></h3>
<p>自研 LSM-Tree 页面式存储引擎。MemTable 红黑树 + 多级 SSTable、Bloom Filter 快速判存、WAL full模式真正同步、MVCC 版本链接入读写路径。</p>
<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 版本链接入读写路径、崩溃恢复 DROP_TABLE 回放修复</p>
</div>
<div class="feature-card">
<div class="icon">🔒</div>
@@ -296,6 +296,11 @@ npm install @metona-team/metona-sqlark
<h3>Query Builder</h3>
<p>链式 API + TypeScript 泛型。.select().where().innerJoin().orderBy().limit() — 类型安全,IDE 友好。</p>
</div>
<div class="feature-card">
<div class="icon">🚰</div>
<h3>流式查询 + 派生表</h3>
<p>queryStream 逐行回调不物化结果集(大表友好)。FROM (SELECT ...) 派生表、多列哈希连接、COUNT(DISTINCT)、NULLS FIRST/LAST。</p>
</div>
</div>
</div>
</section>
@@ -394,12 +399,12 @@ npm install @metona-team/metona-sqlark
<p>MetonaSqlark 的核心指标</p>
</div>
<div class="stats">
<div class="stat-card"><div class="num">837</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">894</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">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">44</div><div class="label">测试套件</div></div>
<div class="stat-card"><div class="num">46</div><div class="label">测试套件</div></div>
</div>
</div>
</section>
+3 -1
View File
@@ -140,6 +140,8 @@ export interface OrderBy {
column: string;
/** 排序方向 */
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
View File
@@ -202,6 +202,91 @@ export class MetonaSqlark {
return result;
}
// ---- 流式查询(v0.4.0 ----
/**
* 流式查询:逐行回调,不一次性物化全部结果(大表友好)。
* 支持简单 SELECTWHERE/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
View File
@@ -2,7 +2,7 @@
* AriaEngine — 自研页面式存储引擎主类
* @module engine/aria/index
*
* v0.2.5: WAL 同步修复 + MVCC 接入 + 版本统一 + 生产加固
* v0.4.1: 外键级联 + ALTER TABLE 重写 + clearAll 重置 + 崩溃恢复加固
*/
import type { IStorageEngine } from '../interface';
@@ -161,10 +161,22 @@ export class AriaEngine implements IStorageEngine {
// 第二遍:仅应用 txnId==0(非事务)或已提交事务的数据
for (const r of allRecords) {
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 大小阈值)
this.checkpointManager = new CheckpointManager(
this.lsm,
@@ -187,6 +199,29 @@ export class AriaEngine implements IStorageEngine {
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; }
// =======================================================================
@@ -203,8 +238,9 @@ export class AriaEngine implements IStorageEngine {
this.tablePKs.set(schema.name, this.getPK(schema));
// 为索引列创建二级索引 LSM(每个索引使用独立命名空间的 SSTableStore,避免 id/meta 冲突)
// v0.3.3: 主键列不建冗余二级索引(主 LSM 本身就是 PK 索引,范围查询走前缀扫描)
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}`;
if (!this.secondaryIndexes.has(idxKey)) {
const idxLsm = new LSM({
@@ -344,24 +380,8 @@ export class AriaEngine implements IStorageEngine {
rows = await this.getAllRows(tableName);
}
// Merge transaction snapshot writes (uncommitted data visible within txn)
if (this.currentTxnId && this.txnSnapshot) {
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);
}
}
}
// v0.3.3: 事务内合并未提交快照(统一在 mergeTxnSnapshot 处理)
rows = this.mergeTxnSnapshot(tableName, rows);
// WHERE filter
if (query.where && Object.keys(query.where).length > 0) {
@@ -447,12 +467,16 @@ export class AriaEngine implements IStorageEngine {
let count = 0;
// v0.3.1: 批量 WAL 写入(组提交)
const walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[] = [];
// v0.4.1: 外键级联(环路保护)
const visited = new Set<string>();
for (const row of rows) {
const pkCol = this.tablePKs.get(tableName)!;
const key = `${tableName}:${row[pkCol]}`;
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
// v0.4.1: 外键规则(RESTRICT 抛错 / CASCADE 递归删 / SET NULL 置空)
count += await this.applyForeignKeyRules(tableName, String(row[pkCol]), walRecords, visited);
if (this.currentTxnId && this.txnSnapshot) {
// Buffer delete in snapshot + MVCC tombstone
this.txnSnapshot.set(key, { __txn_deleted: true } as unknown as Record<string, unknown>);
@@ -482,6 +506,154 @@ export class AriaEngine implements IStorageEngine {
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> {
this.ensureOpen();
this.ensureTable(tableName);
@@ -495,13 +667,93 @@ export class AriaEngine implements IStorageEngine {
this.ensureOpen();
this.ensureTable(tableName);
const rows = await this.getAllRows(tableName);
// v0.3.3: 事务内清空走快照(删除标记),提交时生效;并写入 WAL
const walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[] = [];
for (const row of rows) {
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 TABLEv0.4.1 ----
/**
* v0.4.1: ALTER TABLE — 结构变更真正生效于存储:
* - ADD: 持久化 schemapersistSchemas),行无需修改
* - 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> {
this.ensureOpen();
@@ -551,6 +803,10 @@ export class AriaEngine implements IStorageEngine {
if (colDef.primaryKey) {
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.unique = false;
@@ -610,6 +866,15 @@ export class AriaEngine implements IStorageEngine {
async rollbackTransaction(): Promise<void> {
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.txnSnapshot = null;
@@ -621,6 +886,13 @@ export class AriaEngine implements IStorageEngine {
});
this.currentTxnId = null;
// v0.3.3: 事务内直接写入了二级索引 LSM,回滚后全量重建受影响表的索引
for (const tableName of affectedTables) {
if (this.schemas.has(tableName)) {
await this.reindexTable(tableName);
}
}
}
// ---- Savepoint 嵌套事务 ----
@@ -642,6 +914,9 @@ export class AriaEngine implements IStorageEngine {
if (!sp) throw new DatabaseError(`Savepoint "${name}" not found`, 'SAVEPOINT_NOT_FOUND');
// 恢复到 savepoint 时的快照
this.txnSnapshot = sp.snapshot ? new Map(sp.snapshot) : null;
// v0.3.3: 清理该事务在 MVCC 版本链中的全部记录(快照已含正确数据,
// 版本链仅作 undo 记录,清空后 commit 时 LSM 写入与快照保持一致)
this.mvcc.discardVersions(this.currentTxnId!);
// 清除此 savepoint 之后的所有 savepoint
let found = false;
for (const [k] of this.savepoints) {
@@ -676,11 +951,37 @@ export class AriaEngine implements IStorageEngine {
// 预加载范围内涉及的 SSTable,避免 rangeScan 时缓存未命中静默丢数据
await this.lsm.prefetchRange(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 };
row[pkCol] = key.slice(prefix.length);
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 {
@@ -872,11 +1173,31 @@ export class AriaEngine implements IStorageEngine {
case WALRecordType.COMMIT:
case WALRecordType.ROLLBACK:
case WALRecordType.BEGIN:
case WALRecordType.DROP_TABLE:
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;
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 idxLsm = this.secondaryIndexes.get(idxKey);
if (!idxLsm) continue;
@@ -947,6 +1269,32 @@ export class AriaEngine implements IStorageEngine {
const value = this.lsm.get(key);
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 → 多次精确查找
if ('$in' in c && Array.isArray(c.$in)) {
const results: Record<string, unknown>[] = [];
const seenPks = new Set<string>(); // v0.4.1: IN 值可能重复,按 pk 去重
for (const val of c.$in) {
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;
}
@@ -1091,7 +1446,8 @@ export class AriaEngine implements IStorageEngine {
let rebuiltCount = 0;
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 idxLsm = this.secondaryIndexes.get(idxKey);
if (!idxLsm) continue;
+15
View File
@@ -183,6 +183,21 @@ export class MVCCManager {
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
* key N
+7 -2
View File
@@ -48,9 +48,14 @@ export class CheckpointManager {
}
}
/** 估算 WAL 大小 */
/** 估算 WAL 大小(优先真实字节数,回退到缓冲计数估算) */
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;
}
+12
View File
@@ -47,6 +47,8 @@ export class WAL {
private enabled: boolean;
private buffer: Uint8Array[] = [];
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') {
this.store = store;
@@ -74,12 +76,14 @@ export class WAL {
if (this.syncMode === 'full') {
try {
await this.store.append(bytes);
this.bufferedBytes += bytes.byteLength;
} catch {
// eslint-disable-next-line no-console
console.warn('[AriaEngine WAL] Failed to append record');
}
} else if (this.syncMode === 'batch') {
this.buffer.push(bytes);
this.bufferedBytes += bytes.byteLength;
}
// 'none' mode: 不写 WAL
}
@@ -98,12 +102,14 @@ export class WAL {
if (this.syncMode === 'full') {
try {
await this.store.append(combined);
this.bufferedBytes += combined.byteLength;
} catch {
// eslint-disable-next-line no-console
console.warn('[AriaEngine WAL] Failed to append batch record');
}
} else if (this.syncMode === 'batch') {
this.buffer.push(combined);
this.bufferedBytes += combined.byteLength;
}
// 'none' mode: 不写 WAL
}
@@ -165,6 +171,7 @@ export class WAL {
await this.flush();
await this.store.truncate();
this.lsn = 0;
this.bufferedBytes = 0;
}
// =======================================================================
@@ -183,6 +190,11 @@ export class WAL {
return this.buffer.length;
}
/** v0.3.3: 未 checkpoint 的 WAL 累计字节数(full/batch/none 通用) */
getBufferedBytes(): number {
return this.bufferedBytes;
}
// -----------------------------------------------------------------------
// 编解码
// -----------------------------------------------------------------------
+14
View File
@@ -163,6 +163,20 @@ export class IndexedDBEngine implements IStorageEngine {
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> {
const count = await this.memoryCache.update(tableName, query, updates);
if (this.txActive) return count;
+6
View File
@@ -43,6 +43,9 @@ export interface IStorageEngine {
/** 查询行 */
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>;
@@ -55,6 +58,9 @@ export interface IStorageEngine {
/** 清空表数据(保留结构) */
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 ----
/** 创建二级索引(CREATE INDEX */
+74 -9
View File
@@ -97,6 +97,29 @@ export class MemoryEngine implements IStorageEngine {
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> {
this.ensureTable(tableName);
const schema = this.schemas.get(tableName)!;
@@ -104,9 +127,13 @@ export class MemoryEngine implements IStorageEngine {
let count = 0;
for (const [pk, row] of table) {
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 };
this.validateRow(schema, updated);
this.checkUniqueness(schema, updated);
table.set(pk, updated);
this.updateIndexes(tableName, updated, pk);
count++;
}
}
@@ -119,6 +146,8 @@ export class MemoryEngine implements IStorageEngine {
const toDelete: string[] = [];
for (const [pk, row] of table) {
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
// v0.3.3: 删除行前清理其索引条目(修复删除后索引残留)
this.removeIndexEntries(tableName, row, pk);
toDelete.push(pk);
}
}
@@ -177,6 +206,10 @@ export class MemoryEngine implements IStorageEngine {
const schema = this.schemas.get(tableName)!;
const colDef = schema.columns[column];
if (!colDef) throw new DatabaseError(`Column "${column}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND');
// 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.unique = false;
const tableIndexes = this.indexes.get(tableName);
@@ -288,17 +321,24 @@ export class MemoryEngine implements IStorageEngine {
const tableIndexes = this.indexes.get(tableName);
if (!tableIndexes || !query.where) return Array.from(table.values());
for (const [col, condition] of Object.entries(query.where)) {
// v0.4.1: 支持 { $eq: value } 形式(SQL 解析器生成的等值条件)走索引
let targetValue: unknown;
if (typeof condition !== 'object' || condition === null) {
const colIndex = tableIndexes.get(col);
if (colIndex) {
const pks = colIndex.get(condition);
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 [];
targetValue = condition;
} else if ('$eq' in (condition as Record<string, unknown>) && Object.keys(condition as Record<string, unknown>).length === 1) {
targetValue = (condition as Record<string, unknown>).$eq;
} else {
continue;
}
const colIndex = tableIndexes.get(col);
if (colIndex) {
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());
@@ -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) {
const refRow = refTableData.get(refPk);
if (refRow) {
// v0.3.3: 级联删除前清理索引条目
this.removeIndexEntries(refTableName, refRow, refPk);
totalCascade += await this.cascadeDelete(refTableName, refPk, refRow);
}
refTableData.delete(refPk);
@@ -368,6 +426,13 @@ export class MemoryEngine implements IStorageEngine {
for (const refPk of toDelete) {
const refRow = refTableData.get(refPk);
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;
}
}
+5
View File
@@ -109,6 +109,11 @@ export class OPFSEngine implements IStorageEngine {
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> {
const count = await this.memoryCache.update(tableName, query, updates);
const allRows = await this.memoryCache.find(tableName, { table: tableName });
+5
View File
@@ -125,6 +125,11 @@ export class HybridEngine implements IStorageEngine {
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> {
const count = await this.memoryEngine.update(tableName, query, updates);
// write-through: 同步更新磁盘
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* metona-sqlark
* @module metona-sqlark
* @version 0.2.5
* @version 0.4.1
*
*
* Query Builder API SQL
+2
View File
@@ -168,6 +168,8 @@ export interface SelectStatement {
columns: ColumnRef[];
distinct?: boolean;
from: string;
/** v0.4.0: FROM (SELECT ...) 派生表(存在时 from 为占位,行源取此子查询结果) */
fromSubquery?: SelectStatement | SelectUnionStatement;
/** 主表别名 */
alias?: string;
/** JOIN 子句列表 */
+243 -60
View File
@@ -218,8 +218,26 @@ export class QueryExecutor {
// 引擎层取全行,投影统一在 executor 端完成
const needsRawRows = this.hasCaseColumn(stmt.columns) ||
(!!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) {
// 非 JOINWHERE 在 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 保持原名不剥离
rows = await this.executeJoinSelect(stmt);
} else {
@@ -227,11 +245,31 @@ export class QueryExecutor {
if (stmt.where && Object.keys(stmt.where).length > 0) {
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 引用外层行)→ 逐行绑定上下文求值
if (stmt.where && this.hasCorrelatedRefs(stmt.where)) {
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.filterCorrelated(rows, stmt.where);
} else {
@@ -240,7 +278,8 @@ export class QueryExecutor {
stmt.where = await this.resolveSubqueries(stmt.where);
}
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);
}
}
@@ -253,15 +292,30 @@ export class QueryExecutor {
if (hasGroupBy) rows = this.executeGroupBy(rows, stmt);
if (stmt.distinct) rows = this.executeDistinct(rows);
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!));
}
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] !== '*') {
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) {
@@ -273,10 +327,20 @@ export class QueryExecutor {
// ---- 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 mainRows = (await this.engine.find(stmt.from, { table: stmt.from }))
.map((row) => this.prefixRow(row, mainAlias));
// v0.4.0: 派生表行源已预加载(行带别名前缀)
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;
for (const join of stmt.joins!) {
@@ -312,7 +376,28 @@ export class QueryExecutor {
}
/**
* v0.3.2ON /
* 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
* INNER/LEFT JOIN O(N + M)
* null退
@@ -325,52 +410,64 @@ export class QueryExecutor {
): Promise<Record<string, unknown>[] | null> {
if (join.type === 'CROSS' || join.type === 'RIGHT') return null;
// 提取单一等值条件:{ colA: { $eq: { $col: colB } } } 或 { colA: { $col: colB } }
const keys = Object.keys(join.on);
if (keys.length !== 1) return null;
const keyCol = keys[0];
const cond = join.on[keyCol] as Record<string, unknown> | null | undefined;
// 解析 ON 为 (leftCol, rightCol) 等值对列表(v0.4.0 支持多列,含顶层 $and 展开)
const pairs: { leftCol: string; rightCol: string }[] = [];
const collectPairs = (on: WhereCondition): boolean => {
for (const [keyCol, cond] of Object.entries(on)) {
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;
if (typeof cond === 'object' && cond !== null) {
if ('$eq' in cond && typeof cond.$eq === 'object' && cond.$eq !== null && '$col' in (cond.$eq as Record<string, unknown>)) {
refCol = String((cond.$eq as Record<string, unknown>).$col);
} else if ('$col' in cond && Object.keys(cond).length === 1) {
refCol = String(cond.$col);
const keyIsLeft = mainAlias ? keyCol.startsWith(`${mainAlias}.`) : false;
pairs.push({
leftCol: keyIsLeft ? keyCol : refCol,
rightCol: keyIsLeft ? refCol : keyCol,
});
}
}
if (!refCol) return null;
return true;
};
if (!collectPairs(join.on)) return null;
if (pairs.length === 0) return null;
// 方向判定:键/值哪个属于左表(mainAlias 前缀)?
// ON 键形如 'o.user_id'(右表)→ 值 $col 'u.id'(左表);或反向
const keyIsLeft = mainAlias ? keyCol.startsWith(`${mainAlias}.`) : false;
const leftCol = keyIsLeft ? keyCol : refCol;
const rightCol = keyIsLeft ? refCol : keyCol;
// 右表列必须是主键/索引列(确保 $in 走索引)
// 右表列必须是主键/索引列(确保 $in 走索引)——任一列即可
const schema = await this.engine.getTableSchema(join.table);
if (!schema) return null;
const bareRightCol = rightCol.split('.').pop()!;
const colDef = schema.columns[bareRightCol];
if (!colDef || (!colDef.primaryKey && !colDef.index && !colDef.unique)) return null;
const probePair = pairs.find((p) => {
const bare = p.rightCol.split('.').pop()!;
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;
// 一次 $in 查询右表
// 一次 $in 查询右表(缩小候选集)
const rightRows = await this.engine.find(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) {
const v = rr[bareRightCol];
if (v === undefined || v === null) continue;
if (!hash.has(v)) hash.set(v, []);
hash.get(v)!.push(rr);
const key = pairs.map((p) => String(rr[p.rightCol.split('.').pop()!] ?? '\0')).join('\x1f');
if (!hash.has(key)) hash.set(key, []);
hash.get(key)!.push(rr);
}
const nullRight: Record<string, unknown> = {};
@@ -378,8 +475,8 @@ export class QueryExecutor {
const result: Record<string, unknown>[] = [];
for (const l of leftRows) {
const lv = l[leftCol];
const matches = hash.get(lv);
const key = pairs.map((p) => String(l[p.leftCol] ?? '\0')).join('\x1f');
const matches = hash.get(key);
if (matches && matches.length > 0) {
for (const r of matches) {
result.push({ ...l, ...this.prefixRow(r, joinAlias) });
@@ -449,6 +546,8 @@ export class QueryExecutor {
groups.get(key)!.push(row);
}
const result: Record<string, unknown>[] = [];
// v0.4.0: 聚合表达式键 → 输出键 映射(HAVING SUM(...) 引用表达式时归一为别名键)
const aliasMap = new Map<string, string>();
for (const groupRows of groups.values()) {
const aggregated: Record<string, unknown> = {};
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);
if (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)) {
// v0.3.2: 非聚合的 CASE WHEN 列取组内第一行求值
const expr = parseCaseExpression(colExpr);
@@ -468,22 +571,34 @@ export class QueryExecutor {
}
result.push(aggregated);
}
(stmt as unknown as { _aggAliasMap?: Map<string, string> })._aggAliasMap = aliasMap;
return result;
}
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)
const caseExpr = /^\s*CASE\b/i.test(col) ? parseCaseExpression(col) : null;
const nums = rows
.map((r) => (caseExpr ? evaluateCase(caseExpr, r) : r[col]))
.filter((v) => v !== null && v !== undefined)
.map(Number);
// v0.4.0: COUNT(DISTINCT col) 等去重聚合
const distinctArg = !caseExpr && /^\s*DISTINCT\s+/i.test(col);
const argCol = distinctArg ? col.replace(/^\s*DISTINCT\s+/i, '').trim() : col;
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) {
case 'COUNT': return col === '*' ? rows.length : nums.length;
case 'SUM': return nums.reduce((a: number, b) => a + b, 0);
case 'AVG': return nums.length === 0 ? 0 : nums.reduce((a: number, b) => a + b, 0) / nums.length;
case 'MIN': return nums.length === 0 ? 0 : Math.min(...nums);
case 'MAX': return nums.length === 0 ? 0 : Math.max(...nums);
case 'SUM': return distinctNums.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 'MIN': return distinctNums.length === 0 ? 0 : Math.min(...distinctNums);
case 'MAX': return distinctNums.length === 0 ? 0 : Math.max(...distinctNums);
default: return 0;
}
}
@@ -512,11 +627,26 @@ export class QueryExecutor {
// INSERT INTO ... SELECT ...v0.3.0
if (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 mapped: Record<string, unknown> = {};
const values = Object.values(row);
for (let i = 0; i < colNames.length; i++) {
if (i < values.length) mapped[colNames[i]] = values[i];
const src = i < srcCols.length ? srcCols[i] : null;
if (src && src in row) mapped[colNames[i]] = row[src];
}
return mapped;
});
@@ -566,6 +696,11 @@ export class QueryExecutor {
const schema = await this.engine.getTableSchema(stmt.name);
if (!schema) return;
// v0.4.1: 引擎级 alterTableAria 需重写存储行 + 持久化 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 (schema.columns[stmt.column.name]) {
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));
}
/**
* 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 表达式键 */
private whereHasCase(where: WhereCondition): boolean {
for (const [key, value] of Object.entries(where)) {
@@ -665,18 +819,47 @@ export class QueryExecutor {
getEngine(): IStorageEngine { return this.engine; }
/**
* v0.3.1 projectColumnsCASE WHEN
* v0.3.1 projectColumnsCASE WHEN
* v0.3.3: 支持 `col AS alias`
*/
private projectRow(row: Record<string, unknown>, columns: string[]): Record<string, unknown> {
const plain: string[] = [];
const aliasCols: { alias: string; source: string }[] = [];
const caseCols: { alias: string; expr: CaseExpression }[] = [];
const constCols: { key: string; value: unknown }[] = [];
for (const col of columns) {
if (col === '*') continue;
const expr = parseCaseExpression(col);
if (expr) caseCols.push({ alias: expr.alias ?? col, expr });
else plain.push(col);
if (expr) {
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) : {};
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) {
projected[alias] = evaluateCase(expr, row);
}
+9 -1
View File
@@ -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>[] {
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]);
if (cmp !== 0) return direction === 'desc' ? -cmp : cmp;
}
+15 -5
View File
@@ -195,18 +195,28 @@ export class Lexer {
this.readChar(); // 跳过开始引号
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) {
this.readChar();
value += quote;
} else {
value += this.ch;
this.readChar();
continue;
}
value += this.ch;
this.readChar();
}
// 跳过结束引号(在 readChar 之后才会调用,所以这里不需要处理)
return {
type: TokenType.STRING,
value,
+106 -20
View File
@@ -212,18 +212,37 @@ export class Parser {
columns.push(...this.parseColumnList());
}
// FROM
this.expect(TokenType.FROM);
const tableName = this.expectIdentifier('table name');
// 表别名(可选)
// FROMv0.4.0 可选:SELECT 1 / SELECT 'lit' 无表查询)
let fromSubquery: SelectStatement | SelectUnionStatement | undefined;
let tableName = '';
let alias: string | undefined;
if (this.curTokenIs(TokenType.AS)) {
this.nextToken();
alias = this.expectIdentifier('alias');
} else if (this.curToken.type === TokenType.IDENTIFIER && !this._isReservedAfterFrom()) {
alias = this.curToken.value;
if (this.curTokenIs(TokenType.FROM)) {
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 = {
@@ -234,6 +253,9 @@ export class Parser {
alias,
where: {},
};
if (fromSubquery) {
stmt.fromSubquery = fromSubquery;
}
// JOIN 子句(可选,支持多个)
const joins = this.parseJoinClauses();
@@ -824,6 +846,17 @@ export class Parser {
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();
@@ -897,15 +930,30 @@ export class Parser {
private parseColumnList(): string[] {
const cols: string[] = [];
cols.push(this.parseColumnRef());
cols.push(this.parseColumnWithAlias());
while (this.curTokenIs(TokenType.COMMA)) {
this.nextToken();
cols.push(this.parseColumnRef());
cols.push(this.parseColumnWithAlias());
}
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 {
// CASE WHEN 表达式(v0.3.1
if (this.curTokenIs(TokenType.CASE)) {
@@ -919,6 +967,13 @@ export class Parser {
return value;
}
// v0.4.0: 字符串常量列:SELECT 'value' FROM t
if (this.curTokenIs(TokenType.STRING)) {
const value = this.curToken.value;
this.nextToken();
return `'${value}'`;
}
// 聚合函数?
if (
this.curTokenIs(TokenType.COUNT) ||
@@ -973,11 +1028,19 @@ export class Parser {
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();
this.nextToken();
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;
if (this.curTokenIs(TokenType.STAR)) {
arg = '*';
@@ -998,10 +1061,11 @@ export class Parser {
this.nextToken();
}
const inner = distinct ? `DISTINCT ${arg}` : arg;
if (alias) {
return `${func}(${arg}) AS ${alias}`;
return `${func}(${inner}) AS ${alias}`;
}
return `${func}(${arg})`;
return `${func}(${inner})`;
}
private _isAggregateAlias(): boolean {
@@ -1010,10 +1074,10 @@ export class Parser {
private parseIdentifierList(): string[] {
const ids: string[] = [];
ids.push(this.expectIdentifier('identifier'));
ids.push(this.parseIdentifierWithDot());
while (this.curTokenIs(TokenType.COMMA)) {
this.nextToken();
ids.push(this.expectIdentifier('identifier'));
ids.push(this.parseIdentifierWithDot());
}
return ids;
}
@@ -1039,7 +1103,7 @@ export class Parser {
}
private parseOrderBy(): OrderBy {
const column = this.expectIdentifier('column name');
const column = this.parseIdentifierWithDot();
let direction: SortDirection = 'asc';
if (this.curTokenIs(TokenType.ASC)) {
this.nextToken();
@@ -1047,7 +1111,29 @@ export class Parser {
direction = 'desc';
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;
}
/** 解析字面量值 */
+25
View File
@@ -59,6 +59,31 @@ export class Table<T = Record<string, unknown>> {
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 {
+199
View File
@@ -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();
});
});
+2 -2
View File
@@ -23,8 +23,8 @@ import { createSchema } from '../src/table/schema';
// ---------------------------------------------------------------------------
describe('[v0.2.5] P0-1: 版本号统一', () => {
test('VERSION 常量为当前版本(0.3.2', () => {
expect(VERSION).toBe('0.3.2');
test('VERSION 常量为当前版本(0.4.1', () => {
expect(VERSION).toBe('0.4.1');
});
});
+418
View File
@@ -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();
});
});
+382
View File
@@ -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 匹配 b3t1/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 EXISTSSELECT 列仅含 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(...)):均值 364Alice 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();
});
});