使用React js完成web前端

This commit is contained in:
LIm1tZz7 committed 2026-09-27 22:08:23 +08:00
1 parent 9e8c5f1ef0
commit 7600f0f9ed
130 files changed
+12694 -774

No files matched your search

+16
View File
@@ -38,6 +38,15 @@ target/
# 运行时数据(Rust 服务默认数据目录 CHUSQL_DATA_DIR=data/),不入库
chusql-storage/data/
# 一键启动脚本(script/chusql.ps1)的唯一默认数据目录与日志,不入库
localdata/
# 启动器保存的设置(script/chusql.ps1 config set ... 写这里),本机专用,不入库
script/chusql.settings.json
# 前端 IDE 设置(字体等,由 /api/ui-settings 读写),同样本机专用
script/chusql.ui.settings.json
# Cargo.lock 要入库(本项目是要产出可执行程序的 crate,不是纯库)
# ------------------------------------------------------------
@@ -49,10 +58,14 @@ build/
.next/
.nuxt/
.vite/
.npm-cache/
.angular/
coverage/
*.tsbuildinfo
# 前端构建产物:vite outDir 指向 ../static,由 npm run build 生成,不入库
chusql-web/static/
# 环境变量:只提交 .env.example,本地实际配置不入库
.env
.env.local
@@ -86,3 +99,6 @@ coverage/
Thumbs.db
ehthumbs.db
desktop.ini
# Isolated UI verification build (the live server may lock the default executable)
.stack-work-ui/
+124
View File
@@ -0,0 +1,124 @@
# AGENTS.md — ChuSQL 开发契约
给 AI agent 的项目规则。**约束优先于便利**:任何"暂时放宽"都必须先在此文件里改数字,并说明理由,否则视为违约。
进度与待办的唯一事实来源是 `projectplan.txt`;本文件只管"什么算做完"。
---
## 1. 硬约束(CONSTRAINTS)
违反任一条 = 改动不算完成,不许提交、不许进 `projectplan.txt` 的 ✅。
| # | 约束 | 当前值 | 校验方式 |
|---|---|---|---|
| C1 | Haskell 测试全绿 | **381 条**(engine 200 + web 181) | `cd chusql-engine && stack test`、`cd chusql-web && stack test` |
| C2 | Rust 测试全绿 | **78 条** | `cd chusql-storage && cargo test` |
| C3 | Haskell 编译零警告 | `-Wall` 下 **0 warning** | `stack build` 输出无 `warning:`(engine 与 web 两个包都要看) |
| C4 | 不引入抑制手段 | 禁止 `{-# OPTIONS_GHC -Wno-... #-}`、`#[allow(...)]`、`@ts-ignore`、`--no-verify`、跳过/删除测试、注释掉断言 | 看 diff |
| C5 | 不吞错误 | Rust 拿不到 `unwrap()/expect()` 当控制流;Haskell 不用 `error`/`head` 兜底。能返回错误的走 `Result` / `Either` | 看 diff + clippy 提示 |
| C6 | 索引语义不得回退 | "索引列必须唯一"是**决策 14**,不是实现细节;放宽它必须同时改 `projectplan.txt` 的已知问题 6/9 | 看 diff |
| C7 | 性能结论必须同轮交叉测 | 机器漂移 15~25%(已知问题 4);**小于 2 倍的差异不当结论**,改前改后必须同轮前后跑 | 基准记录 |
| C8 | 已实测的功能不许只留"应该能跑" | 新能力要么有测试断言,要么在 `projectplan.txt` 标 ⬜ 未做 | 看 `projectplan.txt` |
| C9 | 测试数下降 = 阻塞 | 新增测试可以增数;数字变小必须解释哪条被移除及原因 | 前后对比 |
**已知的历史违约模式**(别重复):
- P1 遗留:`Spec.hs` 的 "INSERT ... via IPC" 用例只断言了结果,**没断言"没走 scan"** —— 需求被"测试绿了"掩盖。补法是检查服务端 debug 日志里不出现 `scan`。
- 若哪天要"为了跑通先注释掉断言" —— 停手,改成把这条写进 `projectplan.txt` 的 ⬜。
---
## 2. 改动规则
1. **改 `chusql-engine` 源码后**,跑全链路基准前必须先 `stack build --force-dirty`,否则基准可能链到旧引擎(plan 第 146 行)。
2. **表存在性判据**以 `catalog` 为准(不是文件长度),空表也是合法表;改这条要先搜它**所有**调用点(决策 7、8)。
3. **索引元数据只放一处**(数据字典)。优化器只把"单列等值条件"标成"可点查",有没有索引由存储层回答(`no_index` → 上层退回全表扫描)。不许在优化器里缓存一份索引表(决策 13)。
4. **等价改造不许改结果**。典型:等值连接换哈希表,输出顺序必须和嵌套循环一致(左表序为主、右表序为辅,同键下右行保序);认不出等值键或某侧空表时退回嵌套循环(决策 9)。
5. **存储接口分 `schema` 与 `snapshot`**:语义检查与优化只走 `schema`,不许再拉全库行(决策 10)。
6. **测试的位置约定**:Haskell 引擎测试全部在 `chusql-engine/test/Spec.hs`;Web 在 `chusql-web/test/Spec.hs`(web 是独立包,不能反向依赖 engine 的测试);Rust 在 `chusql-storage/tests/*_test.rs`,另有 `src/log.rs` 里 3 条单元测试。新增测试放同一处,不另起目录。
7. **只留根目录一个 README**:性能基准的跑法/结果/踩坑都写进 `README.md`;设计长文写 `docs/ENGINE_INTRO.md`、`docs/STORAGE_INTRO.md`,P3 的规格写 `docs/WEB_SPEC.md`。
8. 改任何**决策**或**已知问题**,同一轮内更新 `projectplan.txt`。
---
## 3. 已知正确性缺口(最高优先级)
- **重放不幂等**(已知问题 1):断电卡在"数据已落盘"与"日志清空"之间时,重启会把同一操作再作用一遍,`INSERT` 多一行。**这是当前唯一的正确性缺口**,不是功能缺失。
做 P7 完整版 WAL 时,验收标准 = 给每次操作记 LSN、重放前比对是否已作用过,并有一条"中途断电重放两次、行数不变"的测试。
---
## 4. Skill 路由表
已安装 7 个相关 skill(源:`addyosmani/agent-skills`,全局位于 `~/.agents/skills`)。按阶段加载,**不要全部加载**。
| 阶段 | 先加载 | 用途 |
|---|---|---|
| 任何阶段开工 | `constraint-driven-development` | 对照本文件 C1–C9,检查本轮有没有偷偷降标准 |
| 写代码 / 修 bug(全程) | `test-driven-development` | 先红后绿;C1/C2 的 200/181/78 是底线 |
| 编译失败、测试失败、行为不符预期、IPC/FFI 异常 | `debugging-and-error-recovery` | 根因定位,禁止"试一下看看"式修改 |
| P3 Web(✅ 已完成,改动 Web 时仍照此走) | `spec-driven-development` → `api-and-interface-design` → `security-and-hardening` | 先出 spec 再定接口,最后过输入校验与 Session 安全 |
| P3 启动前(无 spec 时) | `spec-driven-development` | Web 涉及 5 块能力,必须先拆能力图 |
| P4 CLI | `api-and-interface-design` | 与 Web 共用 Haskell 后端,接口一次定稳 |
| P5 FFI(C ABI + 跨语言所有权) | `api-and-interface-design` → `debugging-and-error-recovery` | ABI 契约 + 内存所有权问题定位 |
| P2 遗留 A/B(字节键、重复键、范围扫描) | `test-driven-development` → `performance-optimization` | 重写比较器与分裂节点,必须有前后同轮数字 |
| P6 SQL 功能补齐 | `test-driven-development` | 每条语法/语义能力都要有用例 |
| P7 代价优化 / 统计信息被"用"上 | `performance-optimization` | 遵 C7;已知问题 3(池淘汰 O(池容量))绝对值 2.8 µs/次,先证明值得改 |
| P7 完整版 WAL(含重放幂等) | `debugging-and-error-recovery` + 对抗式复核 | 见上面第 3 节,正确性问题 |
**明确不用**(本项目场景不匹配,别加载):
`browser-testing-with-devtools`(需 chrome-devtools MCP,本机没配;Web 的端到端用
`stack test` 里那条真 HTTP 用例顶着)、
`deprecation-and-migration`、`observability-and-instrumentation`、`shipping-and-launch`(无生产部署)、
`planning-and-task-breakdown`(路线图已在 `projectplan.txt`)、
`git-workflow-and-versioning` / `code-review-and-quality` / `refactor` / `code-simplification`(与既有习惯重叠,只增流程噪音)。
---
## 5. 每轮收尾清单
- [ ] `cd chusql-engine && stack test` → 200 条全绿
- [ ] `cd chusql-web && stack test` → 181 条全绿(含一条真起 Rust 存储进程的端到端;`static/` 未构建时 14 条构建产物契约测试记 pending)
- [ ] `cd chusql-storage && cargo test` → 78 条全绿
- [ ] `stack build` 输出 0 warning(`-Wall`,engine 与 web 两个包)
- [ ] diff 里没有 C4 列的抑制手段
- [ ] 动过性能路径 → 同轮交叉测,数字进 `README.md` 表格
- [ ] 动过决策/已知问题 → `projectplan.txt` 同一轮更新
- [ ] 新能力的测试数只增不减(C9)
---
## 6. 常用命令
```bash
# Haskell 引擎测试
cd chusql-engine && stack test
# Web 测试(端到端那条会自己 cargo build --bin chusql-storage 并起真进程)
cd chusql-web && stack test
# Rust 测试
cd chusql-storage && cargo test
# 一键把三个模块都起来(Rust 存储 + Haskell Web + 浏览器)
# .\script\chusql.ps1 web -Port 9000 临时端口(关掉即失效)
# .\script\chusql.ps1 config set port 9000 存成设置(script\chusql.settings.json)
# .\script\chusql.ps1 config / cli / help 看设置 / CLI 占位(会起 web)/ 帮助
# (每个开关都能换成 CHUSQL_* 环境变量;清单见 README 的配置表)
# 手动分开起(一条命令连 Rust 存储进程一起拉)
cd chusql-storage && cargo build --release
cd chusql-web && stack run chusql-web -- --storage ../chusql-storage/target/release/chusql-storage.exe
# 浏览器打开 http://127.0.0.1:7777/ ;
# 端口被占或被系统保留时换一个:--port 7778
# (Windows 查保留段:netsh interface ipv4 show excludedportrange protocol=tcp)
# 全链路基准(Haskell 引擎 + Rust 存储进程一起起)
cd chusql-storage && cargo build --release
cd benchmark/fullchain && stack run chusql-fullchain -- 1000 1000
# ⚠️ 改过 chusql-engine 源码 → 先 stack build --force-dirty
# 分层基准
cd benchmark/chusql-engine && stack run # 查询层(内存数据,隔离优化器)
cd benchmark/chusql-storage && cargo bench --offline # 存储层微基准
```
> 上一轮评估:2026-09-25(P3 ⑰ 全部完成:按 DataGrip 截图把界面整个重做——主工具栏 + "Database Explorer" 工具窗(标题栏窗口动作/小工具栏/连接→库→schema→Database Objects→表→列的树)+ 标签页 + 数据编辑器工具栏 + 带行号槽与类型字形的网格 + 状态栏;图标换成 IDEA 风格线稿,配色换 IDEA 深浅两套;顺手做实了标签页、行号槽、单元格游标与方向键、工具栏加减行、DDL 只读查看、树过滤、折叠全部、工具窗三个窗口动作与下拉菜单)。下一项:P2 遗留 A/B(复合键 + 字节键 → 非唯一索引)。
+20 -182
View File
@@ -5,10 +5,12 @@
## 仓库布局
| 目录 | 语言 | 组件 |
| ----------------- | ------- | ----------------------------------------------------------------- |
| ----------------- | -------------------------- | ----------------------------------------------------------------- |
| `chusql-engine/` | Haskell | 执行引擎:词法分析与AST、表达式求值、关系代数、内存表、服务端接口 |
| `chusql-storage/` | Rust | 存储引擎:磁盘IO、B+树、缓冲池、WAL、崩溃恢复、并发控制 |
| `chusql-web/` | 前端 | 浏览器端管理客户端 |
| `chusql-web/` | Haskell + React/TypeScript | Web 管理端:Scotty REST API + VS Code Dark+ 风格数据库 Web IDE |
| `script/` | PowerShell | `chusql.ps1` / `chusql.cmd`:一键启动 / 子命令 / 设置管理 |
| `docs/` | Markdown | 设计长文:ENGINE_INTRO / STORAGE_INTRO / WEB_SPEC |
# 架构分层
@@ -22,6 +24,7 @@
- 查询优化、内存表管理
- 关系代数运算
- 服务端接口与请求相应
- Web 管理端:Scotty REST API、登录会话、静态页面交付
## Rust部分
@@ -34,45 +37,27 @@
- 事务日志和崩溃恢复
- 并发控制
## 前端部分
# 配置
- 前端部分目前还没想好选型架构,暂且使用简单前端三件套做一个简单的仿CLI WebUI即可。后期可能会加上Rust编写的CLI前端
服务端与前端各有一份配置文件,互不影响:
> Rust和Haskell部分的通信通过管道实现,后期跨平台到Linux后使用Unix Domain Socket
| 文件 | 归属 | 内容 | 读写方式 |
| ------------------------------- | ------------------ | ------------------------------------------------------------------------ | ---------------------------------------- |
| `script/chusql.settings.json` | chusql-web 服务端 | 端口、监听地址、会话、限流、分页上限、账号、存储进程参数 | `chusql.ps1 config set`、`/api/settings` |
| `script/chusql.ui.settings.json`| 前端 IDE(浏览器) | 字体链、字号、行高、Tab 宽度、分页大小、NULL 文本、自动补全、Minimap 等 | `GET` / `PUT /api/ui-settings` |
> 如果找到了较好的Rust+Haskell集成方法也会及时替换
服务端配置的优先级是 `命令行 > 设置文件 > 环境变量 > 内置默认`;前端配置只有设置文件与内置默认两层。
前端配置由 chusql-web 负责落盘,**不经过 Rust 存储进程**,也不写进 `chusql-storage.toml`。前端在本地
`localStorage` 留一份缓存,后端不可用时(例如开发用的 MockAdapter)自动回退到本地,行为与没有这份配置时一致。
两份文件都是本机专用、不入库(见 `.gitignore`)。`chusql-web/static/` 是 vite 构建产物,同样不入库,
需要时在 `chusql-web/frontend` 下跑 `npm run build` 生成;缺失时依赖它的构建产物契约测试会记为 pending。
# 性能基准
三个基准,各管一件事,**主基准是全链路**(Haskell 引擎和 Rust 存储进程一起启动,跑真实情景):
| 基准 | 位置 | 测什么 |
| ---- | ---- | ------ |
| **全链路** | `benchmark/fullchain/` | SQL 文本 → 引擎 → 命名管道 → Rust → WAL/堆表/索引 → 落盘,整条路 |
| 查询层 | `benchmark/chusql-engine/` | 只用内存数据,隔离出优化器与连接算法的效果 |
| 存储层 | `benchmark/chusql-storage/` | Rust 侧页 IO / B+ 树 / 堆表 / WAL 的微基准 |
## 全链路:Haskell + Rust 一起跑
```bash
cd chusql-storage && cargo build --release # 先编译存储进程(找不到 release 会用 debug 并提醒你)
cd benchmark/fullchain
stack run chusql-fullchain # 默认 users = orders = 200
stack run chusql-fullchain -- 1000 1000 # 换规模
# 改过 chusql-engine 源码之后,先 stack build --force-dirty 再跑(原因见"计时怎么做"的坑四)
```
它做的事:造一个临时数据目录和独立管道名 → 起 Rust 存储进程 → 用引擎的 IPC 存储连上去 →
按真实情景跑一遍(建表、逐条 INSERT、全表 SELECT、过滤、JOIN、按主键点查)→ 校验行数 → 收摊。
**每条语句都是整条链路**:SQL 文本真的要过词法/语法分析、语义检查、关系代数,再走管道到 Rust,
经 WAL(fsync)、堆表、B+ 树、页缓冲池落到磁盘,结果再原路返回。
计时用**墙上时钟(单调时钟)**,不是 CPU 时间——这条链路大部分工夫花在等 IO 和跨进程往返上。
### 实测(每条语句平均耗时)
| 指标 | 200 × 200 | 500 × 500 | 1000 × 1000 |
| ---- | --------: | --------: | ----------: |
| ------------------------------------------------ | --------: | --------: | ------------------: |
| 单条 `INSERT`(整条语句) | 2.66 ms | 2.86 ms | 2.88 ms |
| 同一条 `INSERT`(原始 IPC,不经引擎) | 2.50 ms | 2.85 ms | 2.77 ms |
| **多行 `INSERT`(100 行/条)** | — | — | **0.059 ms/行** |
@@ -84,151 +69,4 @@ stack run chusql-fullchain -- 1000 1000 # 换规模
| `DELETE FROM users`(删全部 1000 行) | — | — | 20.2 ms |
| `SELECT ... WHERE code = k`(无索引 → 建索引后) | — | — | 1.8 ms → **0.2 ms** |
四条结论:
1. **写入是 fsync 说了算,和数据量无关。** 单条 INSERT 约 2.7 ms。能单独量出来的落盘成本约 1.0 ms
(写日志 0.43 + 清日志 0.42 + 表数据 flush 0.15),其余是这几次同步落盘之间的等待和跨进程往返。
表从 200 行长到 1000 行,单条耗时基本不动——**插一行和插一千行的落盘代价几乎一样**。
所以想快就得多行一起提交:`VALUES (...), (...), ...` 一条语句 100 行,
每行 2.88 ms → **0.059 ms(约 49 倍)**。
2. **点查是 O(1)。** 按主键查一行 0.2 ms(其中原始 IPC 索引查找 0.07 ms),1000 行时仍是 0.2 ms。
建在别的列上的索引也一样:`WHERE code = k` 从 1.8 ms(没有索引,退回扫描)降到 0.2 ms。
3. **删除的代价和"删几行"无关了。** 删 1 行 4.8 ms、删全部 1000 行 20.2 ms:
前者就贵在一次全表扫描 + 一次落盘,后者是同一笔扫描加上 1000 次 O(1) 的行级删除。
改之前,删 1 行也要把整张表重写一遍并重建整棵索引。
4. **单表查询与那张表的行数成正比**(1000 行 1.9 ms,约 2 µs/行)。查询层现在的设计是把表拉进内存算,
这个数字就是"拉 1000 行过管道 + 在内存里过一遍"的价钱。
## P2 这一版改了什么
四件事,都对应"把整表操作换成行级/批量操作":
- **① 行级 DELETE。** B+ 树加 `delete`(惰性删除:只摘叶子里的键,不合并节点);
堆表把槽位长度置 0(读侧本来就把"长度 0"当作没有这一行);查询层的 `DELETE` 改成
"扫一遍算出要删哪些 id → `delete_keys` 一次删掉",不再 `replaceAll` 重写整表。
- **② 多索引 + `CREATE INDEX` + 规划器选路。** 优化器把"单列等值条件"标成点查节点
`Lookup 别名 表 列 值`;**到底有没有索引由存储层回答**——没有就回 `no_index`,查询层退回全表扫描。
这样索引元数据只存在数据字典一处,加索引不用改优化器。
边界写在下面:**只支持单列整数索引,而且索引列必须唯一**。
- **③ 批量插入。** 语法支持 `INSERT INTO t (...) VALUES (...), (...), ...`:一条语句走一次 WAL、一次 fsync。
- **④ 列统计。** 数据字典里每列记"不同值个数"(内存里按值哈希精确维护,上限 4096 之后转下界),
随 `catalog.json` 落盘,**启动时扫一遍表重建**,所以重启后数字仍然准;插入/删除实时更新。
### 一个必须讲清楚的取舍:索引列必须唯一
原来的索引只服务 `id`,"唯一"是默认成立的前提。要做通用索引就必须回答一个问题:
**一个列上有重复值时,"走索引取一行"和"全表扫一遍再筛"还能得到同一个答案吗?**
现在不能——索引的每个键只放得下一个行位置。所以这一版的规则是:
- 建索引时查重,有重复值就**报错、不建**(不留半成品);
- 索引列上插入重复值会被**拒绝**(老代码是"覆盖索引条目",那会让老行再也查不到)。
真正的解法是让 B+ 树支持重复键(键后面带上行号,再做叶子链上的范围扫描),
那要把 B+ 树的 `i64` 定长键换成内存可比较的变长字节键——和"复合键"是同一块地基,
留到单独一轮做。这条限制记在根 README 和 `projectplan.txt` 里,不是忘了。
> 顺带修掉一个旧 bug:点查节点原来会把**别名前缀丢掉**,
> 于是 `SELECT u.name FROM users u WHERE u.id = 1` 取不到列(投影按 `u.name` 找,
> 回来的行却是 `name`)。现在 `Lookup` 也带着别名,回来的一行和 `Scan 别名 表` 长得一样,
> 并且补了对照测试。
## 全链路这一版改了什么:别再为语义检查搬整个库
原来的 `runStatementM` 每条语句都先取一次**整库快照**(把所有表的所有行都拉过管道),
只为了给语义检查看列名。可是语义检查(`Semantic.check`)和查询优化(`relOpCols`)**只看列,一行数据都不碰**,
行数据是白搬的——而且每条语句都搬一次,于是单条语句的开销随库变大而线性增长。
改法:给 `MonadStorage` 加一个 `schema`(只要表结构,不要行),默认实现退化成"快照 + 抹掉行",
`IPCStorage` 覆盖成"只问数据字典要结构"(一次 `list_catalog` 往返,约 0.07 ms),`Engine.runStatementM` 改用它。
1000 × 1000 下的效果:
| 指标 | 改前 | 改后 | 倍率 |
| ---- | ---: | ---: | ---: |
| `INSERT INTO users` | 3.65 ms | **2.67 ms** | 1.4x |
| `INSERT INTO orders` | 5.61 ms | **2.91 ms** | 1.9x |
| `SELECT * FROM users` | 6.7 ms | **2.0 ms** | 3.4x |
| `SELECT ... WHERE age > 90` | 6.6 ms | **1.7 ms** | 3.9x |
| `SELECT ... JOIN ... WHERE` | 11.2 ms | **4.0 ms** | 2.8x |
| `SELECT ... WHERE id = k` | 3.3 ms | **0.2 ms** | 16x |
改完之后单条 INSERT 的耗时和"原始 IPC 插入"持平(2.67 vs 2.68 ms)——
**引擎侧的每条语句开销已经小到量不出来**,剩下全是存储层的落盘成本。
## 查询层:等值连接改成哈希表
`Join` 原来是"两边两两配对再筛":25 万对,每对拼一次行、按列名查两次值,单对约 0.66 µs,
乘起来就是 165 ms 上下——这正是下表"未优化"那一列的全部耗时。
现在条件是 `a = b` 这种两列等值时,先拿右表建哈希表、再拿左表逐行探测,配对次数降到 1000 次哈希查找;
认不出等值键时自动退回嵌套循环,输出顺序与原来完全一致。
数据规模 users / orders 各 500 行(配对 25 万对),中位数,单位 ms:
| 查询 | 结果行数 | 改前 未优化 | 改后 未优化 | 改前 优化后 | 改后 优化后 |
| ------------------------------------------ | -------: | ----------: | ----------: | ----------: | ----------: |
| `JOIN` + `WHERE u.age > 90` | 45 | 110.4 ms | **0.267 ms** | 8.9 ms | **0.30 ms** |
| `JOIN`(无 WHERE,只有投影) | 500 | 135.7 ms | **0.397 ms** | 111.3 ms | **0.549 ms** |
| `JOIN` + `WHERE` + `ORDER BY` + `LIMIT 10` | 10 | 130.9 ms | **0.336 ms** | 9.2 ms | **0.320 ms** |
| 单表 `WHERE age > 90`(对照组) | 45 | 0.095 ms | 0.084 ms | 0.149 ms | 0.13 ms |
**连接类查询约 300 倍;换成 2000 行 / 400 万对时是 500~900 倍**(嵌套循环是 O(左 × 右),哈希连接是线性,规模越大省得越多)。
需要注意:表里"优化器开/关"的比值从原来的 12~17 倍降到了 1.0 倍左右——
**不是优化器变差了,是分母变了**:原来那十几倍几乎全来自"条件把配对次数从 25 万筛到 2.25 万",
而连接本身只要 0.4 ms 之后,下推省下的时间已不占主导。**看性能要看绝对耗时,不是比值。**
## 存储层:写入路径
单条 `INSERT` 的完整流程(清 WAL → 写 WAL → 改数据 → 落盘 → 清 WAL)原来约 **3.8 ms**,现在约 **2.5 ms**:
- **WAL 文件句柄常驻**:原来每条操作要 open/close 三次,而 append 模式下开完再 fsync 比看起来的 open 更贵;
实测单次追加 990 µs → 507 µs。
- **写前那次清空只截断、不单独 fsync**:紧跟其后的 `append` 会 fsync,那一次会把截断一起落下去。
这一项从 522~600 µs 降到 11.6 µs。
写入路径的瓶颈**是 fsync 次数,不是数据量**。剩下的时间基本都是"真落盘",而这是 WAL 语义要求的:
再往下压就要动"每条操作都截断 WAL"这个设计本身(真正的数据库是顺序追加 + 检查点,而不是写一条清一次)。
其余微基准(n = 2000):页顺序写 15~17 µs/页(含 flush)、页顺序读 4.1~4.5 µs/页、
B+ 树插入 17.9 µs/次、点查 14.8~16.1 µs/次、堆表扫描 0.5 µs/行。
## 计时怎么做(四个坑都踩过了)
**坑一:Haskell 是惰性的,不强制求值就等于没测。**
只写 `let r = evalRelOp db plan`,`r` 根本没被算出来,测到的是"定义一个变量"的时间。
所以每次必须把结果算到底(项目里是逐行把列数累加起来当校验和,或者把行数 `evaluate` 出来)。
**坑二:Windows 上 CPU 时钟的精度只有约 15.6 毫秒。**
毫秒级的用例直接测会得到 `0.0 ms` 这种没意义的数字。
查询层基准用**自适应批量**(同一用例连跑 `k` 次,`k` 翻倍到一批超过 0.3 秒再折算成单次),
把时钟误差摊薄;全链路基准因为是 IO 密集、单次就有毫秒级,直接用墙上单调时钟。
**坑三:机器会漂移。** 同一份代码、同一天、前后隔半小时,指标能整体差 15~25%(温度、后台进程都算)。
所以:改前改后必须**同一时段交叉测**;小于 2 倍的差异,没交叉验证之前不要当结论。
**坑四(本项目的专属坑):小心链到旧引擎。** `benchmark/fullchain` 和 `chusql-engine` 在同一个 stack 项目里,
改完引擎源码后 stack 有时不会把依赖库重新拷进基准项目的安装目录——于是你测的是旧引擎,
而且数字看起来"合理"。**改过引擎就先 `stack build --force-dirty`。**
(这一条是实测踩出来的:一次"改动没效果"的排查,最后发现是引擎库的时间戳比源码还旧。)
## 试过但没留下的(别重复踩)
| 想法 | 结果 | 结论 | 为什么 |
| ---- | ---- | ---- | ------ |
| 给 Haskell 库加 `-O2` | 加完 8 项全部慢 15~25%;**去掉后重建再测,一样慢** | **已回退** | 这次"变慢"是机器漂移,不是 `-O2` 造成的——但回退后也没测出 `-O2` 的好处。**结论:没有证据**,要么同轮交叉测再下判断,要么别加 |
| 换掉缓冲池的 `min_by_key` 淘汰 | 池 1024 / 工作集超池:随机读 7.9 µs vs 池 16:5.1 µs | 未做 | 差距是真的(缺页时多扫 1024 个候选页),但只影响"工作集大于池"的随机读,绝对量 ~2.8 µs/次;而写入路径是 2500 µs/次,不在一个量级 |
| 把投影下推进 `Scan`(Scan 自带列清单) | 未做 | 待定 | 能消掉"无 WHERE 的 JOIN"多出来的那趟投影(约 25%,0.1 ms 级),但要改 `RelOp` 类型、`relOpCols`、Eval 和一堆测试 |
| 非唯一索引(一个键多条记录) | 未做 | 待定 | 要 B+ 树允许重复键(键后面带行号)+ 叶子链上的范围扫描;和"复合键"共用同一块地基:**键从 `i64` 定长槽位改成内存可比较的变长字节键** |
| 只给索引加"唯一"检查、不改插入语义 | 未做 | 已否 | 那样建索引时唯一、之后插重复值仍会把老行"挤"出索引,查询结果就和全表扫不一致了——宁可插入时报错 |
## 怎么加用例
- **全链路**:在 `benchmark/fullchain/src/Main.hs` 的 `runScenarios` 里加 `benchSql label sql` 就行;
要验证结果就照着 `[校验]` 那一段写断言(现在的两个校验是 `age > 90` 的行数和 JOIN 的行数)。
- **查询层**:在 `benchmark/chusql-engine/src/Main.hs` 的 `queries` 列表里加一条 SQL 字符串,其余全自动
(自动翻译、优化、跑两遍、比结果、算加速比)。
- **存储层**:在 `benchmark/chusql-storage/benches/storage.rs` 里加一段 `bench(...)` / `bench_once(...)`。
想在文档里引用数字,记得把**数据规模、机器、时间**一起写上——脱离这些谈"快了几倍"没有意义。
> 最近更新:2026-09-25
> 最近更新:2026-09-26
+8 -10
View File
@@ -43,7 +43,7 @@ checksum :: Either String [Row] -> Int
checksum (Left _) = -1
checksum (Right rows) = go 0 rows
where
-- \| 累加行长
-- | 累加行长
go acc [] = acc
go acc (r : rs) = go (acc + length r) rs
@@ -80,11 +80,9 @@ timed act = do
t1 <- getCPUTime
return (fromIntegral (t1 - t0) / 1e12)
-- | 每批至少跑几次(减少 CPU 时钟量化误差)
minBatch :: Int
minBatch = 8
-- | 取几批中位数
rounds :: Int
rounds = 3
@@ -103,7 +101,7 @@ benchOp target act = do
ts <- mapM (const (timeBatch k act)) [1 .. rounds]
return (median ts / fromIntegral k, k)
where
-- \| 批次翻倍直到累计超过目标
-- | 批次翻倍直到累计超过目标
pickBatch k = do
t <- timeBatch k act
if t < target || k < minBatch then pickBatch (k * 2) else return k
@@ -116,17 +114,17 @@ report saltRef db target sql = do
(tUn, kUn) <- benchOp target (runOnce saltRef db sql False)
(tOp, kOp) <- benchOp target (runOnce saltRef db sql True)
let speedup = if tOp <= 0 then 0 else tUn / tOp
mismatch = if nUn == nOp then "" else " <<< 结果不一致!"
mismatch = if nUn == nOp then "" else " <<< result mismatch!"
printf "%s\n" sql
printf
" 结果行数 %-6d | 未优化 %9.3f ms | 优化后 %9.3f ms | 加速 %6.1fx%s\n"
" rows %-6d | baseline %9.3f ms | optimized %9.3f ms | speedup %6.1fx%s\n"
nOp
(tUn * 1000)
(tOp * 1000)
speedup
mismatch
printf
" (%d 次/批 × %d 批取中位数:未优化 %d 次/批,优化后 %d 次/批)\n\n"
" (%d iters/batch x %d batches, median: baseline %d iters/batch, optimized %d iters/batch)\n\n"
minBatch
rounds
kUn
@@ -152,9 +150,9 @@ main = do
m = pick 1 500
target = pick 2 (0.3 :: Double)
db = [("users", mkUsers n), ("orders", mkOrders n m)]
printf "数据规模:users = %d 行,orders = %d 行\n" n m
printf "计时方式:CPU 时间;每批至少 %d 次迭代、累计超过 %.2f 秒后固定批次,取 %d 批中位数\n" minBatch target rounds
printf "(先跑一遍预热,避免把首次构造数据的开销算进去)\n\n"
printf "dataset: users = %d rows, orders = %d rows\n" n m
printf "timing: CPU time; at least %d iterations per batch, batch fixed after %.2f s, median of %d batches\n" minBatch target rounds
printf "(one warmup run first, so building the data is not counted)\n\n"
saltRef <- newIORef ""
_ <- runOnce saltRef db warmupSql False
_ <- runOnce saltRef db warmupSql True
+39 -55
View File
@@ -1,11 +1,3 @@
//! 存储层基准:页 IO、B+ 树、堆表、WAL 与服务端写入路径。
//!
//! 用 `std::time::Instant` 计时,不引第三方基准框架,离线也能跑。
//! 每项跑 `rounds` 轮取最快一轮:存储层的抖动主要来自 fsync 与调度,
//! 取最快一轮最接近"这套代码本身要花多久"。
//!
//! 跑法:`cargo bench`(可选环境变量 `CHUSQL_BENCH_N` 改规模,默认 2000)
use std::time::{Duration, Instant};
use chusql_storage::btree::DiskBTree;
@@ -16,8 +8,11 @@ use chusql_storage::protocol::Row;
use chusql_storage::wal::{Wal, WalOp};
use serde_json::json;
// 工具
/// 跑一个操作 `ops` 次,量总耗时并折算每次耗时
// 存储层基准:页 IO、B+ 树、堆表、WAL 与服务端写入路径;
// 用 std::time::Instant 计时,不引第三方基准框架,
// 每项跑若干轮取最快一轮,离线也能跑。
/// 跑一个操作 ops 次,量总耗时并折算每次耗时
fn bench<F: FnMut()>(label: &str, ops: usize, rounds: usize, mut f: F) {
let mut best = Duration::MAX;
for _ in 0..rounds {
@@ -30,7 +25,7 @@ fn bench<F: FnMut()>(label: &str, ops: usize, rounds: usize, mut f: F) {
}
let per = best.as_secs_f64() * 1e6 / ops as f64;
println!(
"{:<38} {:>9.2} ms {:>10.3} us/次",
"{:<38} {:>9.2} ms {:>10.3} us/op",
label,
best.as_secs_f64() * 1e3,
per
@@ -44,7 +39,7 @@ fn bench_once<F: FnOnce()>(label: &str, ops: usize, f: F) {
let d = t0.elapsed();
let per = d.as_secs_f64() * 1e6 / ops as f64;
println!(
"{:<38} {:>9.2} ms {:>10.3} us/次",
"{:<38} {:>9.2} ms {:>10.3} us/op",
label,
d.as_secs_f64() * 1e3,
per
@@ -65,13 +60,12 @@ fn next_rand(state: &mut u64) -> u64 {
*state >> 33
}
// 页 IO
/// 顺序写 / 顺序读 / 随机读
fn bench_page(dir: &std::path::Path, n: usize) {
let path = dir.join("page.db");
let mut pf = PageFile::with_options(&path, DEFAULT_PAGE_SIZE, 1024).unwrap();
bench("page 顺序写(含 flush)", n, 3, || {
bench("page sequential write (with flush)", n, 3, || {
pf.truncate().unwrap();
for i in 0..n {
pf.write_page(&Page::new(i as u64, DEFAULT_PAGE_SIZE)).unwrap();
@@ -80,9 +74,8 @@ fn bench_page(dir: &std::path::Path, n: usize) {
});
drop(pf);
// 池子故意开小,让顺序读真的去读盘
let mut pf = PageFile::with_options(&path, DEFAULT_PAGE_SIZE, 16).unwrap();
bench("page 顺序读(池 16,基本都缺页)", n, 3, || {
bench("page sequential read (pool 16, mostly misses)", n, 3, || {
for i in 0..n {
pf.read_page(i as u64).unwrap();
}
@@ -90,37 +83,35 @@ fn bench_page(dir: &std::path::Path, n: usize) {
let mut pf = PageFile::with_options(&path, DEFAULT_PAGE_SIZE, 16).unwrap();
let mut state = 12345u64;
bench("page 随机读(池 16)", n, 3, || {
bench("page random read (pool 16)", n, 3, || {
for _ in 0..n {
let id = next_rand(&mut state) % n as u64;
pf.read_page(id).unwrap();
}
});
// 池子开大、工作集比池子还大:每次缺页都要挑一个牺牲页
let mut pf = PageFile::with_options(&path, DEFAULT_PAGE_SIZE, 1024).unwrap();
let mut state = 999u64;
bench("page 随机读(池 1024,工作集 > 池)", n, 3, || {
bench("page random read (pool 1024, working set > pool)", n, 3, || {
for _ in 0..n {
let id = next_rand(&mut state) % n as u64;
pf.read_page(id).unwrap();
}
});
println!(
" (池 1024:命中 {}/{})",
" (pool 1024: hits {}/{})",
pf.hits(),
pf.hits() + pf.misses()
);
}
// B+ 树
/// 插入、点查、全量遍历
fn bench_btree(dir: &std::path::Path, n: usize) {
let path = dir.join("bt.idx");
let _ = std::fs::remove_file(&path);
let mut bt = DiskBTree::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, 1024).unwrap();
bench("btree 插入(含 flush)", n, 2, || {
bench("btree insert (with flush)", n, 2, || {
bt.clear().unwrap();
for i in 0..n {
bt.insert(i as i64, i as u64).unwrap();
@@ -128,19 +119,18 @@ fn bench_btree(dir: &std::path::Path, n: usize) {
bt.flush().unwrap();
});
bench("btree 点查(全部命中)", n, 3, || {
bench("btree point lookup (all hits)", n, 3, || {
for i in 0..n {
bt.get(i as i64).unwrap().expect("key must exist");
}
});
bench("btree 全量遍历", n, 3, || {
bench("btree full scan", n, 3, || {
let v = bt.iter_all().unwrap();
assert_eq!(v.len(), n);
});
}
// 堆表
/// 插行、全表扫描、按索引取行
fn bench_heap(dir: &std::path::Path, n: usize) {
let path = dir.join("heap.db");
@@ -149,14 +139,14 @@ fn bench_heap(dir: &std::path::Path, n: usize) {
let _ = std::fs::remove_file(&idx);
let mut t = HeapTable::open(&path, DEFAULT_PAGE_SIZE, 1024).unwrap();
bench("heap 插入(只在缓冲池,不 flush)", n, 2, || {
bench("heap insert (buffer pool only, no flush)", n, 2, || {
t.replace_all(&[]).unwrap();
for i in 0..n {
t.insert_row(&row(i as i64)).unwrap();
}
});
bench("heap 插入 + flush", n, 2, || {
bench("heap insert + flush", n, 2, || {
t.replace_all(&[]).unwrap();
for i in 0..n {
t.insert_row(&row(i as i64)).unwrap();
@@ -164,20 +154,19 @@ fn bench_heap(dir: &std::path::Path, n: usize) {
t.flush().unwrap();
});
bench("heap 批量插入 + flush(一次落盘)", n, 2, || {
bench("heap batch insert + flush (single commit)", n, 2, || {
t.replace_all(&[]).unwrap();
let rows: Vec<Row> = (0..n as i64).map(row).collect();
t.insert_rows(&rows).unwrap();
t.flush().unwrap();
});
bench("heap 全表扫描", n, 3, || {
bench("heap full scan", n, 3, || {
let rows = t.scan().unwrap();
assert_eq!(rows.len(), n);
});
}
// WAL
/// 追加写与清空
fn bench_wal(dir: &std::path::Path, n: usize) {
let path = dir.join("wal.log");
@@ -188,32 +177,30 @@ fn bench_wal(dir: &std::path::Path, n: usize) {
row: row(1),
};
bench_once("wal 追加(每条都 fsync)", n, || {
bench_once("wal append (fsync per record)", n, || {
for _ in 0..n {
wal.append(&op).unwrap();
}
});
wal.clear().unwrap();
bench_once("wal 清空(每条都 fsync)", n, || {
bench_once("wal clear (fsync per record)", n, || {
for _ in 0..n {
wal.clear().unwrap();
}
});
wal.clear().unwrap();
bench_once("wal 截断(不落盘)", n, || {
bench_once("wal truncate (no fsync)", n, || {
for _ in 0..n {
wal.truncate().unwrap();
}
});
// 拆分:一次 fsync 本身多贵,一次 open 多贵
use std::fs::OpenOptions;
use std::io::Write;
// 大小和一条真实的 WAL 记录同量级(~200 字节)
let bytes = vec![0u8; 200];
bench_once(" └ open + write + fsync", n, || {
bench_once(" - open + write + fsync", n, || {
for _ in 0..n {
let mut f = OpenOptions::new()
.create(true)
@@ -229,18 +216,18 @@ fn bench_wal(dir: &std::path::Path, n: usize) {
.append(true)
.open(&path)
.unwrap();
bench_once(" └ 复用句柄:write + fsync", n, || {
bench_once(" - reuse handle: write + fsync", n, || {
for _ in 0..n {
open_file.write_all(&bytes).unwrap();
open_file.sync_data().unwrap();
}
});
bench_once(" └ only fsync(不开不写)", n, || {
bench_once(" - only fsync (no open, no write)", n, || {
for _ in 0..n {
open_file.sync_data().unwrap();
}
});
bench_once(" └ only open/close", n, || {
bench_once(" - only open/close", n, || {
for _ in 0..n {
let _ = OpenOptions::new()
.create(true)
@@ -252,8 +239,7 @@ fn bench_wal(dir: &std::path::Path, n: usize) {
wal.clear().unwrap();
}
// 服务端写入路径
/// 复刻 server.rs 里 write_via_wal 的顺序:清 WAL -> 写 WAL -> 改数据 -> flush -> 清 WAL
/// 复刻 write_via_wal 的端到端写入顺序
fn bench_write_path(dir: &std::path::Path, n: usize) {
let path = dir.join("wp.db");
let idx = dir.join("wp.idx");
@@ -272,7 +258,7 @@ fn bench_write_path(dir: &std::path::Path, n: usize) {
.unwrap();
let wal = Wal::new(&wal_path);
bench_once("写入路径 单条 INSERT(端到端)", n, || {
bench_once("write path single INSERT (end to end)", n, || {
for i in 0..n as i64 {
wal.truncate().unwrap();
let op = WalOp::Insert {
@@ -286,8 +272,7 @@ fn bench_write_path(dir: &std::path::Path, n: usize) {
}
});
// 拆开量,好定位时间花在哪一步
bench_once(" └ wal.truncate()(不落盘)", n, || {
bench_once(" - wal.truncate() (no fsync)", n, || {
for _ in 0..n {
wal.truncate().unwrap();
}
@@ -297,27 +282,26 @@ fn bench_write_path(dir: &std::path::Path, n: usize) {
table: "t".to_string(),
row: row(1),
};
bench_once(" └ wal.append()(含 fsync)", n, || {
bench_once(" - wal.append() (with fsync)", n, || {
for _ in 0..n {
wal.append(&op).unwrap();
}
});
wal.clear().unwrap();
bench_once(" └ wal.clear()(含 fsync)", n, || {
bench_once(" - wal.clear() (with fsync)", n, || {
for _ in 0..n {
wal.clear().unwrap();
}
});
bench_once(" └ heap.insert_row()(含写索引)", n, || {
// 主循环已经插过 0..n 了,这里换个号段,免得撞上"索引列唯一"
bench_once(" - heap.insert_row() (incl. index write)", n, || {
for i in n as i64..2 * n as i64 {
t.insert_row(&row(i)).unwrap();
}
});
bench_once(" └ heap.flush()", n, || {
bench_once(" - heap.flush()", n, || {
for _ in 0..n {
t.flush().unwrap();
}
@@ -330,24 +314,24 @@ fn main() {
.and_then(|v| v.parse().ok())
.unwrap_or(2000);
println!("chusql-storage 基准:n = {},页大小 = {} 字节,btree order = {}",
println!("chusql-storage benchmark: n = {}, page size = {} bytes, btree order = {}",
n, DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER);
println!("每项取最快一轮;带 fsync 的项只跑一次(跑多了太慢)\n");
println!("best of each round set; fsync entries run once only (repeats are too slow)\n");
let dir = tempfile::tempdir().unwrap();
println!("[页 IO]");
println!("[page IO]");
bench_page(dir.path(), n);
println!("\n[B+ 树]");
println!("\n[B+ tree]");
bench_btree(dir.path(), n);
println!("\n[堆表]");
println!("\n[heap]");
bench_heap(dir.path(), n);
println!("\n[WAL]");
bench_wal(dir.path(), n.min(200));
println!("\n[服务端写入路径]");
println!("\n[server write path]");
bench_write_path(dir.path(), n.min(200));
}
+67
View File
@@ -0,0 +1,67 @@
全链路基准:Haskell 引擎 ←命名管道→ Rust 存储进程
存储进程:../../chusql-storage/target\release\server.exe
管道:chusql-fullchain-145262202226
数据目录:C:\Users\32402\AppData\Local\Temp\chusql-fullchain-145262202226
数据规模:users = 1000 行,orders = 1000 行
计时:墙上时钟(单调);每条语句都走完整链路
[启动] 存储进程就绪,用时 0.05 s
[建表]
CREATE TABLE users 2.3 ms 0 行
CREATE TABLE orders 1.6 ms 0 行
[写入:每条 INSERT 都是一次完整落盘]
1000 条 INSERT INTO users 2949.8 ms 2.950 ms/条
1000 条 INSERT INTO orders 3244.4 ms 3.244 ms/条
[分解:同一条 INSERT 拆成两半,都用原始 IPC,不经过引擎]
CREATE TABLE raw_users 1.9 ms 0 行
list_catalog(取结构) 23.3 ms 0.116 ms/次
原始 IPC insert × 1000 2992.4 ms 2.992 ms/条
[读取与查询:整条语句,含引擎侧的解析与语义检查]
SELECT * FROM users 1.9 ms 1000 行
SELECT name FROM users WHERE age > 90 2.5 ms 90 行
[读取与查询:连接]
JOIN + WHERE(哈希连接) 5.4 ms 90 行
[点查]
lookup_by_index(原始 IPC,按 id) 7.7 ms 0.077 ms/次(命中 100/100)
SELECT ... WHERE id = k(走 Lookup) 0.4 ms 1 行
[批量插入:一条语句带多行,N 行只落一次盘]
CREATE TABLE batch_t 3.0 ms 0 行
多行 INSERT 58.8 ms 0.059 ms/行(100 行/条 × 10 条语句)
[行级删除:代价和删几行无关,只和扫描 + 一次落盘有关]
DELETE FROM users WHERE id = k(1 行) 4.8 ms 0 行
DELETE FROM users(全部) 20.2 ms 0 行
[二级索引:没有索引回退扫描,建完索引走索引]
CREATE TABLE items 2.1 ms 0 行
(先把 1000 行灌进 items:53.2 ms)
SELECT ... WHERE code = k(无索引) 1.8 ms 1 行
CREATE INDEX ON items (code) 9.8 ms 0 行
SELECT ... WHERE code = k(走索引) 0.2 ms 1 行
[数据字典统计]
batch_t 1000 行 索引=id
统计=age:100 id:1000 name:1000
items 1000 行 索引=id code
统计=code:1000 id:1000
orders 1000 行 索引=id
统计=id:1000 product:50 user_id:1000
raw_users 1000 行 索引=id
统计=age:100 id:1000 name:1000
users 0 行 索引=id
统计=age:0 id:0 name:0
[校验]
age > 90 命中 90(按数据分布应为 90 行)
JOIN 命中 90(每个用户恰好 1 张订单,应与上一行相同 = 90)
多行 INSERT 灌进去 1000 行(应为 1000)
二级索引那一行:code = 3501 应该查到 id = 500
[收尾] 停存储进程、清理数据目录
+69 -88
View File
@@ -40,25 +40,11 @@ import System.Process (
)
import Text.Printf (printf)
-- 全链路基准:Haskell 引擎和 Rust 存储进程**一起启动**,跑真实情景。
--
-- 一条语句真正走过的路:
-- SQL 文本 → 词法/语法分析 → 语义检查 → 关系代数 → 命名管道(JSON 行)
-- → Rust:WAL(fsync)→ 堆表/B+ 树 → 页缓冲池 → 落盘
-- → 结果原路返回
--
-- 计时用**墙上时钟(单调时钟)**,不是 CPU 时间:这条链路大部分工夫花在等 IO
-- 和跨进程往返上,用 CPU 时间会把这些等待整个漏掉。
--
-- 跑法(先把 Rust 那边编译成 release):
-- cd chusql-storage && cargo build --release
-- cd benchmark/fullchain && stack run # 默认 users=orders=200
-- stack run -- 500 500 # 换规模
-- stack run -- 200 200 ../../chusql-storage/target/release/server.exe # 指定存储进程
-- 全链路基准:Haskell 引擎与 Rust 存储进程一起跑,
-- 语句走完整链路(解析、优化、命名管道、WAL、落盘),
-- 用墙上时钟计时,结果打印到标准输出。
-- * 命令行与存储进程
-- | 找 Rust 存储进程:优先 release,其次 debug
-- | 找 Rust 存储进程:优先 release
findServer :: FilePath -> IO FilePath
findServer "" = do
let dirs =
@@ -68,16 +54,16 @@ findServer "" = do
, "target"
]
modes = ["release", "debug"]
exes = ["server.exe", "server"]
exes = ["chusql-storage.exe", "chusql-storage"]
candidates = [d </> m </> e | d <- dirs, m <- modes, e <- exes]
found <- filterM doesFileExist candidates
case found of
(p : _) -> do
unless ("release" `isInfixOf` p) $
putStrLn "!! 找到的是 debug 版存储进程,数字会偏慢;建议 cargo build --release"
putStrLn "!! using the debug storage binary; numbers will be slower, run: cargo build --release"
pure p
[] -> do
putStrLn "找不到 Rust 存储进程。先编译:"
putStrLn "Rust storage binary not found. Build it first:"
putStrLn " cd chusql-storage && cargo build --release"
exitFailure
findServer p = do
@@ -85,10 +71,10 @@ findServer p = do
if ok
then pure p
else do
putStrLn ("指定的存储进程不存在:" ++ p)
putStrLn ("storage binary not found: " ++ p)
exitFailure
-- | 子进程环境:继承当前环境,但把 CHUSQL_* 换成本次专用的
-- | 子进程环境:继承并覆盖 CHUSQL_*
childEnv :: [(String, String)] -> IO [(String, String)]
childEnv extra = do
base <- getEnvironment
@@ -111,7 +97,7 @@ startServer serverPath pipe dataDir = do
(proc serverPath []) {env = Just envs, std_out = NoStream, std_err = Inherit}
ready <- waitReady (200 :: Int)
unless ready $ do
putStrLn "存储进程 5 秒内没就绪"
putStrLn "storage process was not ready within 5 s"
exitFailure
pure ph
@@ -133,9 +119,7 @@ stopServer ph dataDir = do
_ <- try (removeDirectoryRecursive dataDir) :: IO (Either SomeException ())
pure ()
-- * 计时
-- | 量一次墙上耗时,并把结果行数强制出来(Haskell 是惰性的,不强制就等于没测)
-- | 量一次墙上耗时并强制求出行数
timedRows :: IO (Either String [Row]) -> IO (Double, Either String Int)
timedRows act = do
t0 <- getMonotonicTime
@@ -155,26 +139,24 @@ runSql sql = case parseStatement sql of
-- | 打印一行结果
report :: String -> Either String Int -> Double -> IO ()
report label result dt = case result of
Left e -> printf "%-38s %10s !! %s\n" label "失败" e
Right n -> printf "%-38s %8.1f ms %8d 行\n" label (dt * 1000) n
Left e -> printf "%-38s %10s !! %s\n" label "FAILED" e
Right n -> printf "%-38s %8.1f ms %8d rows\n" label (dt * 1000) n
-- | 单独量一条 SQL(先预热一遍):只适合可以重复跑的语句(SELECT)
-- | 单独量一条可重复的 SQL(先预热)
benchSql :: String -> String -> IO (Either String Int)
benchSql label sql = do
_ <- runSql sql -- 预热:连接、页缓存、优化器都先热一次
_ <- runSql sql
(dt, r) <- timedRows (runSql sql)
report label r dt
pure r
-- | 只跑一次的语句(CREATE TABLE 这类重跑会报错的)
-- | 只跑一次的语句(重跑会报错)
benchSqlOnce :: String -> String -> IO (Either String Int)
benchSqlOnce label sql = do
(dt, r) <- timedRows (runSql sql)
report label r dt
pure r
-- * 场景
-- | 造一条 INSERT 的 SQL 文本
userSql :: Int -> String
userSql i =
@@ -200,13 +182,13 @@ batchInsert label n mkSql = do
case errs of
[] -> do
printf
"%-38s %8.1f ms %7.3f ms/条\n"
"%-38s %8.1f ms %7.3f ms/row\n"
label
(total * 1000)
(total * 1000 / fromIntegral n)
pure (Right ())
(e : _) -> do
printf "%-38s %10s !! %s\n" label "失败" e
printf "%-38s %10s !! %s\n" label "FAILED" e
pure (Left e)
-- | 一次点查(原始 IPC,不经过引擎)
@@ -219,14 +201,14 @@ benchLookup table column key times = do
let total = t1 - t0
hit = length [() | Right (IndexRow (Just _)) <- results]
printf
"%-38s %8.1f ms %7.3f ms/次(命中 %d/%d)\n"
"lookup_by_index(原始 IPC,按 id)"
"%-38s %8.1f ms %7.3f ms/op (hits %d/%d)\n"
"lookup_by_index (raw IPC, by id)"
(total * 1000)
(total * 1000 / fromIntegral times)
hit
times
-- | 量 list_catalog:现在每条语句都要先来一发(只取结构,不取行)
-- | 量 list_catalog(只取结构,不取行)
benchListCatalog :: Int -> IO ()
benchListCatalog n = do
t0 <- getMonotonicTime
@@ -234,12 +216,12 @@ benchListCatalog n = do
t1 <- getMonotonicTime
let total = t1 - t0
printf
"%-38s %8.1f ms %7.3f ms/次\n"
"list_catalog(取结构)"
"%-38s %8.1f ms %7.3f ms/op\n"
"list_catalog (schema only)"
(total * 1000)
(total * 1000 / fromIntegral n)
-- | 量原始 IPC 插入:不经过引擎,把"协议 + 存储"和"引擎"分开
-- | 量原始 IPC 插入(不经过引擎)
benchRawInsert :: String -> [Row] -> IO ()
benchRawInsert table rows = do
t0 <- getMonotonicTime
@@ -251,11 +233,11 @@ benchRawInsert table rows = do
case errs of
[] ->
printf
"%-38s %8.1f ms %7.3f ms/条\n"
("原始 IPC insert × " ++ show n)
"%-38s %8.1f ms %7.3f ms/row\n"
("raw IPC insert x " ++ show n)
(total * 1000)
(total * 1000 / fromIntegral n)
(e : _) -> printf "%-38s %10s !! %s\n" "原始 IPC insert" "失败" e
(e : _) -> printf "%-38s %10s !! %s\n" "raw IPC insert" "FAILED" e
-- | 造一行 users 数据(给原始 IPC 用)
userRow :: Int -> Row
@@ -273,7 +255,7 @@ runBatch sqls = do
chunked :: Int -> Int -> [[Int]]
chunked size n = go [1 .. n]
where
-- \| 每次切下 size 个
-- | 每次切下 size 个
go [] = []
go xs = let (a, b) = splitAt size xs in a : go b
@@ -287,12 +269,12 @@ multiInsertSql ids =
printStats :: TableInfo -> IO ()
printStats i =
printf
" %-12s %6d 行 索引=%s\n 统计=%s\n"
" %-12s %6d rows indexes=%s\n stats=%s\n"
(tiTable i)
(tiRows i)
(if null (tiIndexes i) then "无" else unwords (tiIndexes i))
(if null (tiIndexes i) then "none" else unwords (tiIndexes i))
( unwords
[ n ++ ":" ++ show d ++ (if c then "+(下界)" else "")
[ n ++ ":" ++ show d ++ (if c then "+ (lower bound)" else "")
| (n, d, c) <- tiStats i
]
)
@@ -300,58 +282,58 @@ printStats i =
-- | 主场景
runScenarios :: Int -> Int -> IO ()
runScenarios u o = do
putStrLn "\n[建表]"
putStrLn "\n[create tables]"
r1 <- benchSqlOnce "CREATE TABLE users" "CREATE TABLE users (id int, name str, age int)"
r2 <- benchSqlOnce "CREATE TABLE orders" "CREATE TABLE orders (id int, user_id int, product str)"
case (r1, r2) of
(Right _, Right _) -> pure ()
_ -> do
putStrLn "建表失败,后面不用跑了"
putStrLn "table creation failed; stopping here"
exitFailure
putStrLn "\n[写入:每条 INSERT 都是一次完整落盘]"
_ <- batchInsert (printf "%d 条 INSERT INTO users" u) u userSql
_ <- batchInsert (printf "%d 条 INSERT INTO orders" o) o (orderSql u)
putStrLn "\n[write: every INSERT is a full durable commit]"
_ <- batchInsert (printf "%d INSERT INTO users" u) u userSql
_ <- batchInsert (printf "%d INSERT INTO orders" o) o (orderSql u)
putStrLn "\n[分解:同一条 INSERT 拆成两半,都用原始 IPC,不经过引擎]"
putStrLn "\n[breakdown: same INSERT split in two, both via raw IPC, no engine]"
_ <- benchSqlOnce "CREATE TABLE raw_users" "CREATE TABLE raw_users (id int, name str, age int)"
benchListCatalog 200
benchRawInsert "raw_users" [userRow i | i <- [1 .. u]]
putStrLn "\n[读取与查询:整条语句,含引擎侧的解析与语义检查]"
putStrLn "\n[read/query: full statement, including parsing and semantic checks]"
_ <- benchSql "SELECT * FROM users" "SELECT * FROM users"
ageR <- benchSql "SELECT name FROM users WHERE age > 90" "SELECT name FROM users WHERE age > 90"
putStrLn "\n[读取与查询:连接]"
putStrLn "\n[read/query: join]"
joinR <-
benchSql
"JOIN + WHERE(哈希连接)"
"JOIN + WHERE (hash join)"
"SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id WHERE u.age > 90"
putStrLn "\n[点查]"
putStrLn "\n[point lookup]"
benchLookup "users" "id" (u `div` 2) 100
_ <- benchSql "SELECT ... WHERE id = k(走 Lookup)" (printf "SELECT name FROM users WHERE id = %d" (u `div` 2))
_ <- benchSql "SELECT ... WHERE id = k (via Lookup)" (printf "SELECT name FROM users WHERE id = %d" (u `div` 2))
putStrLn "\n[批量插入:一条语句带多行,N 行只落一次盘]"
putStrLn "\n[batch insert: one statement with N rows, one durable commit]"
_ <- benchSqlOnce "CREATE TABLE batch_t" "CREATE TABLE batch_t (id int, name str, age int)"
let groups = chunked 100 u
(bt, batchErrs) <- runBatch (map multiInsertSql groups)
case batchErrs of
[] ->
printf
"%-38s %8.1f ms %7.3f ms/行(%d 行/条 × %d 条语句)\n"
"多行 INSERT"
"%-38s %8.1f ms %7.3f ms/row (%d rows/stmt x %d statements)\n"
"multi-row INSERT"
(bt * 1000)
(bt * 1000 / fromIntegral u)
(100 :: Int)
(length groups)
(e : _) -> printf "%-38s %10s !! %s\n" "多行 INSERT" "失败" e
(e : _) -> printf "%-38s %10s !! %s\n" "multi-row INSERT" "FAILED" e
putStrLn "\n[行级删除:代价和删几行无关,只和扫描 + 一次落盘有关]"
_ <- benchSqlOnce "DELETE FROM users WHERE id = k(1 行)" (printf "DELETE FROM users WHERE id = %d" (u `div` 2))
_ <- benchSqlOnce "DELETE FROM users(全部)" "DELETE FROM users"
putStrLn "\n[row delete: cost tracks the scan plus one commit, not the row count]"
_ <- benchSqlOnce "DELETE FROM users WHERE id = k (1 row)" (printf "DELETE FROM users WHERE id = %d" (u `div` 2))
_ <- benchSqlOnce "DELETE FROM users (all)" "DELETE FROM users"
putStrLn "\n[二级索引:没有索引回退扫描,建完索引走索引]"
putStrLn "\n[secondary index: falls back to scan, then uses the index once built]"
_ <- benchSqlOnce "CREATE TABLE items" "CREATE TABLE items (id int, code int)"
let itemGroups = chunked 100 o
itemSql ids =
@@ -361,39 +343,39 @@ runScenarios u o = do
case itemErrs of
[] -> pure ()
(e : _) -> printf " !! %s\n" e
printf " (先把 %d 行灌进 items:%.1f ms)\n" o (it * 1000)
printf " (loaded %d rows into items: %.1f ms)\n" o (it * 1000)
let targetCode = (o `div` 2) * 7 + 1
_ <-
benchSql
"SELECT ... WHERE code = k(无索引)"
"SELECT ... WHERE code = k (no index)"
(printf "SELECT id FROM items WHERE code = %d" targetCode)
_ <- benchSqlOnce "CREATE INDEX ON items (code)" "CREATE INDEX ON items (code)"
_ <-
benchSql
"SELECT ... WHERE code = k(走索引)"
"SELECT ... WHERE code = k (via index)"
(printf "SELECT id FROM items WHERE code = %d" targetCode)
putStrLn "\n[数据字典统计]"
putStrLn "\n[catalog stats]"
cat <- doListCatalog
case cat of
Left e -> printf " 取统计失败:%s\n" e
Left e -> printf " failed to read stats: %s\n" e
Right infos -> mapM_ printStats infos
putStrLn "\n[校验]"
putStrLn "\n[checks]"
let expectAge = 9 * (u `div` 100) + max 0 (u `mod` 100 - 90)
printf
"age > 90 命中 %s(按数据分布应为 %d 行)\n"
"age > 90 matched %s (expected %d rows from the data distribution)\n"
(either show show ageR)
expectAge
when (u == o) $
printf
"JOIN 命中 %s(每个用户恰好 1 张订单,应与上一行相同 = %d)\n"
"JOIN matched %s (one order per user, should equal the line above = %d)\n"
(either show show joinR)
expectAge
unless (u == o) $
putStrLn "(users 与 orders 行数不同,JOIN 行数没有简单公式,只打印不校验)"
printf "多行 INSERT 灌进去 %d 行(应为 %d)\n" u u
printf "二级索引那一行:code = %d 应该查到 id = %d\n" targetCode (o `div` 2)
putStrLn "(users and orders differ in size; no simple formula, printing only)"
printf "multi-row INSERT loaded %d rows (expected %d)\n" u u
printf "secondary index: code = %d should find id = %d\n" targetCode (o `div` 2)
-- | 主入口
main :: IO ()
@@ -405,7 +387,6 @@ main = do
serverArg = pick 2 ""
serverPath <- findServer serverArg
-- 每次跑用独立的管道名与数据目录,避免和别的进程抢
tmp <- getTemporaryDirectory
stamp <- getMonotonicTime
let pipe = "chusql-fullchain-" ++ show (round (stamp * 1e6) :: Int)
@@ -413,23 +394,23 @@ main = do
createDirectoryIfMissing True dataDir
setEnv "CHUSQL_PIPE" pipe
printf "全链路基准:Haskell 引擎 ←命名管道→ Rust 存储进程\n"
printf "存储进程:%s\n" serverPath
printf "管道:%s\n" pipe
printf "数据目录:%s\n" dataDir
printf "数据规模:users = %d 行,orders = %d 行\n" u o
printf "计时:墙上时钟(单调);每条语句都走完整链路\n"
printf "full-chain benchmark: Haskell engine <--named pipe--> Rust storage\n"
printf "storage : %s\n" serverPath
printf "pipe : %s\n" pipe
printf "data dir : %s\n" dataDir
printf "dataset : users = %d rows, orders = %d rows\n" u o
printf "timing : wall clock (monotonic); every statement runs the full chain\n"
bracket
(do
t1 <- getMonotonicTime
ph <- startServer serverPath pipe dataDir
t2 <- getMonotonicTime
printf "\n[启动] 存储进程就绪,用时 %.2f s\n" (t2 - t1)
printf "\n[startup] storage ready in %.2f s\n" (t2 - t1)
pure ph
)
(\ph -> do
putStrLn "\n[收尾] 停存储进程、清理数据目录"
putStrLn "\n[teardown] stopping storage and removing the data dir"
stopServer ph dataDir
)
(const (runScenarios u o))
+3 -14
View File
@@ -11,8 +11,6 @@ import qualified Data.HashMap.Strict as HM
-- 执行:按算子树算出结果行(Scan 阶段会加别名前缀)。
-- * 工具
-- | 只保留清单里的列
project :: [String] -> Row -> Row
project ["*"] row = row
@@ -28,13 +26,10 @@ evalScan db mAlias tbl = do
table <- lookupTable db tbl
Right (map (addPrefix mAlias) (tableRows table))
-- | 点查退化成"扫描 + 按条件筛"时用的条件:`列 = 值`(列名带上别名前缀)
-- | 点查退化成扫描时用的条件
lookupCondition :: Maybe String -> String -> Int -> Expr
lookupCondition mAlias col k = Eq (Col (qualify mAlias col)) (LitInt k)
-- * 等值连接
-- | 等值连接的两侧键:左行取哪列、右行取哪列
data EquiKeys = EquiKeys
{ ekLeft :: String
, ekRight :: String
@@ -55,12 +50,10 @@ equiKeys lrows rrows (Eq (Col a) (Col b))
rcols = rowsCols rrows
equiKeys _ _ _ = Nothing
-- | 哈希连接:右表建哈希表,左表逐行探测。
-- 输出顺序与嵌套循环一致(左表顺序为主,右表顺序为辅)。
-- | 哈希连接,输出顺序同嵌套循环
hashJoin :: EquiKeys -> [Row] -> [Row] -> [Row]
hashJoin keys lrows rrows = concatMap probe lrows
where
-- 同一键的右行按原顺序排在桶里
buckets =
HM.fromListWith
(flip (++))
@@ -88,12 +81,9 @@ joinRows cond lrows rrows = case equiKeys lrows rrows cond of
Just keys -> Right (hashJoin keys lrows rrows)
Nothing -> filterPairs (evalCondForRow cond) lrows rrows
-- * 求值
-- | 纯求值(不需要存储)
evalRelOp :: Database -> RelOp -> Either String [Row]
evalRelOp db (Scan mAlias tbl) = evalScan db mAlias tbl
-- 纯求值没有索引可问:退回"扫描 + 按条件筛",和未优化时的 Filter 完全等价
evalRelOp db (Lookup mAlias tbl col k) = do
rows <- evalScan db mAlias tbl
filterM (evalCondForRow (lookupCondition mAlias col k)) rows
@@ -114,7 +104,7 @@ evalRelOp db (Join left right cond) = do
rrows <- evalRelOp db right
joinRows cond lrows rrows
-- | 单子求值:Lookup 先问存储有没有索引,没有就退回扫描
-- | 单子求值:点查先问存储有无索引
evalRelOpM :: (MonadStorage m) => RelOp -> m (Either String [Row])
evalRelOpM (Scan mAlias t) = do
result <- scan t
@@ -127,7 +117,6 @@ evalRelOpM (Lookup mAlias t col k) = do
result <- lookupByColumn t col k
case result of
Left e -> pure (Left e)
-- 这个列上没有索引:老老实实扫一遍(结果和未优化时一样)
Right NoIndex -> do
rows <- evalRelOpM (Scan mAlias t)
pure $ do
-3
View File
@@ -6,7 +6,6 @@ import Control.Monad (join)
-- 表达式求值:拿一行数据算出一个值。
-- * 求值
-- | 在一行上求出表达式的值
evalExpr :: Expr -> Row -> Either String Value
evalExpr (Col name) row =
@@ -20,7 +19,6 @@ evalExpr (Eq a b) row = liftBinOp eqOp a b row
evalExpr (And a b) row = liftBinOp (boolOp (&&)) a b row
evalExpr (Or a b) row = liftBinOp (boolOp (||)) a b row
-- * 列引用
-- | 表达式用到了哪些列
colsInExpr :: Expr -> [String]
colsInExpr (Col c) = [c]
@@ -39,7 +37,6 @@ evalCondForRow e row = do
VBool b -> Right b
_ -> Left "type error: WHERE condition must be a boolean"
-- * 内部
-- | 把二元运算提升到 Either
liftBinOp ::
(Value -> Value -> Either String Value) ->
+1 -6
View File
@@ -4,14 +4,9 @@ import ChuSQL.Syntax.AST (Expr, SortDir)
-- 关系代数算子,以及把算子树渲染成文本。
-- | 算子树的一个节点
data RelOp
= Scan (Maybe String) String
| -- \| 按某一列的值点查一行:别名、表、列、值。
-- 只是"这里可以走点查"的意思,到底有没有索引由存储层回答
-- (那个列上没有索引就退回全表扫描,见 Eval)。
-- 别名要留着,回来的一行才能和 `Scan 别名 表` 长得一样。
Lookup (Maybe String) String String Int
| Lookup (Maybe String) String String Int
| Filter Expr RelOp
| Project [String] RelOp
| Sort [(String, SortDir)] RelOp
@@ -7,7 +7,6 @@ import ChuSQL.Syntax.AST (Expr (..))
-- 查询优化:谓词下推 + 投影下推 + 常量折叠,跑到不动点。
-- * 谓词下推
-- | 算子会产出哪些列
relOpCols :: Database -> RelOp -> [String]
relOpCols db (Scan mAlias tbl) =
@@ -73,8 +72,6 @@ pushOne _ (Filter p (Filter q x)) = Filter (And p q) x
pushOne _ (Filter p (Sort spec x)) = Sort spec (Filter p x)
pushOne _ op = op
-- * 投影下推
-- | 是否要全部列
needsAll :: [String] -> Bool
needsAll = elem allColumns
@@ -106,8 +103,6 @@ pushProject db need (Join l r c)
pushProject db need leaf@(Scan _ _) = pushProjectLeaf db need leaf
pushProject db need leaf@(Lookup _ _ _ _) = pushProjectLeaf db need leaf
-- * 常量折叠
-- | 折一个表达式节点
foldNode :: Expr -> Expr
foldNode e
@@ -127,14 +122,8 @@ foldConstants (And a b) = foldNode (And (foldConstants a) (foldConstants b))
foldConstants (Or a b) = foldNode (Or (foldConstants a) (foldConstants b))
foldConstants e = e
-- * 执行优化
-- | 单节点重写
rewriteNode :: Database -> RelOp -> RelOp
-- 单列等值条件改成点查:这里是"可以走索引"的意思,
-- 到底走不走得成由存储层回答(这个列上没有索引就退回全表扫描,见 Eval),
-- 所以优化器不必先知道表上到底有哪些索引。
-- 别名要留在节点里:回来的一行必须和 `Scan 别名 表` 长得一样,不然后面取列就对不上了。
rewriteNode _ (Filter (Eq (Col k) (LitInt v)) (Scan mAlias t)) =
Lookup mAlias t (unqualify mAlias k) v
rewriteNode db (Filter p (Join l r c)) = pushJoin db p l r c
+5 -13
View File
@@ -12,8 +12,6 @@ import ChuSQL.Syntax.AST
-- 引擎入口:语义检查 + 分发执行,以及内存实现与泛型版本。
-- * 对外入口
-- | 跑一条语句(内存实现)
runStatement :: Database -> Statement -> Either String (Database, [Row])
runStatement db q = do
@@ -28,15 +26,11 @@ rowsOf = fmap snd
-- | 泛型入口:先检查再执行
runStatementM :: (MonadStorage m) => Statement -> m (Either String [Row])
runStatementM q = do
-- 只要结构:语义检查和查询优化都只看列,一行数据都不碰,
-- 所以这里不用取整库快照(那会把所有行拉一遍)
db <- schema
case check db q of
Left err -> pure (Left err)
Right _ -> runStatementUncheckedM db q
-- * 分发执行
-- | 按语句类型分发(已检查过)
runStatementUncheckedM :: (MonadStorage m) => Database -> Statement -> m (Either String [Row])
runStatementUncheckedM db q@Select{} =
@@ -44,7 +38,6 @@ runStatementUncheckedM db q@Select{} =
Left e -> pure (Left e)
Right relOp -> evalRelOpM (optimize db relOp)
runStatementUncheckedM _ (Insert tbl cols rows) =
-- 多行一次交给存储:N 行只落一次盘(键怎么算、索引怎么写是存储层的事)
case mapM toRow rows of
Left err -> pure (Left err)
Right rs -> do
@@ -65,8 +58,6 @@ runStatementUncheckedM _ (Delete tbl mWhere) = do
Right rows -> case splitByCondition mWhere rows of
Left err -> pure (Left err)
Right (doomed, kept) ->
-- 要删的行都有 id 就走行级删(存储层原地删,代价只和删几行有关);
-- 没有 id 的表定位不到行,只能整表写回。
case mapM rowId doomed of
Right ids -> do
result <- deleteKeys tbl ids
@@ -111,14 +102,15 @@ runStatementUncheckedM _ (CreateIndex tbl col) = do
runStatementUncheckedM _ (DropIndex tbl col) = do
result <- dropIndex tbl col
pure (result >> Right [])
runStatementUncheckedM _ (DropColumn tbl col) = do
result <- dropColumn tbl col
pure (result >> Right [])
-- * 更新辅助
-- | 按条件把行分成"要删的"和"留着的"两拨(没有条件就全都要删)
-- | 按条件把行分成待删和保留两拨
splitByCondition :: Maybe Expr -> [Row] -> Either String ([Row], [Row])
splitByCondition cond = go [] []
where
-- \| 一行一行过,保持原顺序;条件求值出错就立刻停(和 filterM 一样)
-- \| 逐行过,保持原顺序
go doomed kept [] = Right (reverse doomed, reverse kept)
go doomed kept (r : rs) = do
hit <- case cond of
+2 -11
View File
@@ -6,31 +6,25 @@ import Data.Maybe (fromMaybe)
-- 数据模型:列类型、值、行、表、数据库,外加几个查表小工具。
-- * 类型
-- | 列的类型
data Column
= TInt
| TStr
| TBool
deriving (Show, Eq)
-- | 列的值
data Value
= VInt Int
| VStr String
| VBool Bool
deriving (Show, Eq)
-- | 值要能当哈希键用(等值连接建哈希表)
instance Hashable Value where
hashWithSalt s (VInt n) = hashWithSalt (hashWithSalt s (0 :: Int)) n
hashWithSalt s (VStr t) = hashWithSalt (hashWithSalt s (1 :: Int)) t
hashWithSalt s (VBool b) = hashWithSalt (hashWithSalt s (2 :: Int)) b
-- | 一行:列名 -> 值
type Row = [(String, Value)]
-- | 一张表
data Table = Table
{ tableName :: String
, tableCols :: [(String, Column)]
@@ -38,10 +32,8 @@ data Table = Table
}
deriving (Show)
-- | 数据库:多张表
type Database = [(String, Table)]
-- * 工具
-- | 取表的列名
colNames :: Table -> [String]
colNames = map fst . tableCols
@@ -54,16 +46,15 @@ colType t c = lookup c (tableCols t)
lookupTable :: Database -> String -> Either String Table
lookupTable db t = maybe (Left ("unknown table: " ++ t)) Right (lookup t db)
-- | 投影哨兵 "*"
allColumns :: String
allColumns = "*"
-- | 给列名加别名前缀:`Just "u"` + `name` -> `u.name`(没有别名就原样)
-- | 给列名加别名前缀
qualify :: Maybe String -> String -> String
qualify Nothing c = c
qualify (Just a) c = a ++ "." ++ c
-- | 去掉别名前缀:`Just "u"` + `u.name` -> `name`(没有这个前缀就原样返回)
-- | 去掉别名前缀
unqualify :: Maybe String -> String -> String
unqualify Nothing c = c
unqualify (Just a) c = fromMaybe c (stripPrefix (a ++ ".") c)
+11 -8
View File
@@ -7,7 +7,6 @@ import Data.List (intercalate, nub)
-- 语义检查:表和列在不在、类型对不对,全部在执行前查。
-- * 环境
-- | 给列名加上别名前缀
prefixColumns :: Maybe String -> [(String, Column)] -> [(String, Column)]
prefixColumns mAlias cols = [(prefix ++ c, ty) | (c, ty) <- cols]
@@ -26,8 +25,6 @@ checkFrom db (FromJoin left mAlias tbl cond) = do
checkBool "ON" env cond
Right env
-- * 检查
-- | 拼“列不存在”的报错
missingColumn :: String -> String -> [(String, Column)] -> String
missingColumn place c env =
@@ -44,7 +41,6 @@ checkColumns place env requested =
[] -> Right ()
(c : _) -> Left (missingColumn place c env)
-- | 推导表达式的类型
inferExpr :: String -> [(String, Column)] -> Expr -> Either String Column
inferExpr place env (Col c) = maybe (Left (missingColumn place c env)) Right (lookup c env)
@@ -80,8 +76,6 @@ checkBool place env e = do
then Right ()
else Left (place ++ ": condition must be a boolean, got " ++ show t)
-- * 写入语句
-- | 检查赋值列和类型
checkTyped :: String -> [(String, Column)] -> Table -> (String, Expr) -> Either String ()
checkTyped place env t (c, e) = do
@@ -99,8 +93,6 @@ checkValue = checkTyped "INSERT" []
checkAssign :: Table -> (String, Expr) -> Either String ()
checkAssign t = checkTyped "UPDATE" (tableCols t) t
-- * 入口
-- | 按语句类型分派检查
check :: Database -> Statement -> Either String ()
check db q = case q of
@@ -150,6 +142,17 @@ check db q = case q of
if col `elem` tableCols' t
then Right ()
else Left ("DROP INDEX: unknown column: " ++ col)
DropColumn tbl col -> do
t <- lookupTable db tbl
if col `notElem` tableCols' t
then Left ("DROP COLUMN: unknown column: " ++ col)
else
if col == idColumn
then Left "DROP COLUMN: the built-in id column cannot be dropped"
else Right ()
where
-- \| 表的列名清单
tableCols' = map fst . tableCols
idColumn :: String
idColumn = "id"
+10 -22
View File
@@ -7,27 +7,19 @@ import ChuSQL.Model (Column, Database, Row, Table (..), Value (..))
-- 存储层抽象:上层只认 domain 类型,不关心底层实现。
-- | 一次索引点查的结果
data IndexResult
= -- \| 有索引,查过了:Just 命中一行,Nothing 没这行
IndexRow (Maybe Row)
| -- \| 没有这个索引 —— 调用方应该退回全表扫描
NoIndex
= IndexRow (Maybe Row)
| NoIndex
deriving (Show, Eq)
-- | 存储接口:读写表、按索引取行、取快照
class (Monad m) => MonadStorage m where
-- \| 全表扫描
scan :: String -> m (Either String [Row])
-- \| 追加一行。
-- 要写哪些索引由**存储层按自己的元数据**决定(表上有哪些索引它自己清楚),
-- 调用方不需要指定 key。
-- \| 追加一行
insert :: String -> Row -> m (Either String ())
-- \| 一批行一起追加。
-- 默认逐行调 `insert`;能批量的实现(比如走 WAL 的 IPC 存储)会覆盖它,
-- 好处是 N 行只落一次盘。
-- \| 一批行一起追加
insertMany :: String -> [Row] -> m (Either String ())
insertMany t = go
where
@@ -39,9 +31,7 @@ class (Monad m) => MonadStorage m where
Left e -> pure (Left e)
Right _ -> go rs
-- \| 按 `id` 批量删行。
-- 默认实现是"全读出来、筛掉、整表写回"(对内存实现足够);
-- 真正能在原地删的实现会覆盖它。
-- \| 按 id 批量删行
deleteKeys :: String -> [Int] -> m (Either String ())
deleteKeys t ks = do
rows <- scan t
@@ -57,8 +47,7 @@ class (Monad m) => MonadStorage m where
-- \| 整表替换
replaceAll :: String -> [Row] -> m (Either String ())
-- \| 按某一列的索引取一行。
-- 默认回答"没有索引"(内存实现不需要索引),于是上层会自己扫一遍。
-- \| 按某一列的索引取一行
lookupByColumn :: String -> String -> Int -> m (Either String IndexResult)
lookupByColumn _ _ _ = pure (Right NoIndex)
@@ -76,14 +65,13 @@ class (Monad m) => MonadStorage m where
dropIndex :: String -> String -> m (Either String ())
dropIndex _ _ = pure (Right ())
-- \| 删一列,连它的索引与数据一起去掉
dropColumn :: String -> String -> m (Either String ())
-- \| 取全库快照(表名 + 列 + 全部行)
snapshot :: m Database
-- \| 取全库**结构**:只要表名和列,不要行。
--
-- 语义检查和查询优化都只看列名/列类型,一行数据都不碰;
-- 而"取全库快照"要把所有行拉一遍,在单语句路径上是白花的钱。
-- 默认实现退化成整库快照再抹掉行,有更便宜做法的实现(比如走数据字典)可以覆盖。
-- \| 取全库结构:只要表名和列,不要行
schema :: m Database
schema = fmap (map withoutRows) snapshot
where
+21 -41
View File
@@ -38,31 +38,22 @@ import System.IO.Unsafe (unsafePerformIO)
-- IPC 存储实现:每个存储方法翻成管道上的一条 JSON 请求。
-- * 存储实现
-- | 命名管道上的存储
newtype IPCStorage a = IPCStorage
{ runIPCStorage :: IO a
}
-- | Functor:转发给 IO
instance Functor IPCStorage where
fmap f (IPCStorage m) = IPCStorage (fmap f m)
-- | Applicative:转发给 IO
instance Applicative IPCStorage where
pure = IPCStorage . pure
IPCStorage mf <*> IPCStorage ma = IPCStorage (mf <*> ma)
-- | Monad:顺序执行
instance Monad IPCStorage where
IPCStorage m >>= k = IPCStorage $ do
a <- m
runIPCStorage (k a)
-- * 全局连接缓存
-- | 全局句柄缓存:按管道路径缓存,路径变了就换连接。
{-# NOINLINE connectionRef #-}
connectionRef :: MVar (Maybe (FilePath, Handle))
connectionRef = unsafePerformIO (newMVar Nothing)
@@ -100,15 +91,13 @@ dropConnection = modifyMVar_ connectionRef $ \cached -> do
closeConnection :: IO ()
closeConnection = dropConnection
-- * 管道
-- | 管道路径
pipePath :: IO String
pipePath = do
name <- maybe "chusql-storage" id <$> lookupEnv "CHUSQL_PIPE"
pure ("\\\\.\\pipe\\" ++ name)
-- | 发一条请求读一条响应;管道不通就回 RespError,并丢掉缓存等下次重连。
-- | 发一条请求,管道不通就重连
sendRequest :: Request -> IO Response
sendRequest req = do
path <- pipePath
@@ -132,29 +121,21 @@ roundTrip req path = do
Left err -> pure (RespError ("decode: " ++ err))
Right r -> pure r
-- * 编解码
-- | 一列的 schema
data SchemaColumn = SchemaColumn
{ scName :: String
, scType :: String
}
deriving (Show, Eq)
-- | SchemaColumn 编码
instance ToJSON SchemaColumn where
toJSON (SchemaColumn n t) = object ["name" .= n, "ty" .= t]
-- | SchemaColumn 解码
instance FromJSON SchemaColumn where
parseJSON = withObject "SchemaColumn" $ \o -> do
n <- o .: "name"
t <- o .: "ty"
pure (SchemaColumn n t)
-- * 请求
-- | 请求类型
data Request
= ReqPing
| ReqScan String
@@ -169,9 +150,9 @@ data Request
| ReqDropTable String
| ReqCreateIndex String String
| ReqDropIndex String String
| ReqDropColumn String String
| ReqListCatalog
-- | 请求编码
instance ToJSON Request where
toJSON ReqPing = object ["method" .= ("ping" :: T.Text)]
toJSON (ReqScan t) = object ["method" .= ("scan" :: T.Text), "table" .= t]
@@ -232,9 +213,14 @@ instance ToJSON Request where
, "table" .= t
, "column" .= c
]
toJSON (ReqDropColumn t c) =
object
[ "method" .= ("drop_column" :: T.Text)
, "table" .= t
, "column" .= c
]
toJSON ReqListCatalog = object ["method" .= ("list_catalog" :: T.Text)]
-- | 一张表的线上信息:列 + 行数 + 索引 + 列统计
data TableInfo = TableInfo
{ tiTable :: String
, tiColumns :: [SchemaColumn]
@@ -244,7 +230,6 @@ data TableInfo = TableInfo
}
deriving (Show, Eq)
-- | 响应类型
data Response
= RespPong
| RespRows [Row]
@@ -255,7 +240,6 @@ data Response
| RespError String
| RespCatalog [TableInfo]
-- | 响应解码
instance FromJSON Response where
parseJSON = withObject "Response" $ \o -> do
status <- o .: "status"
@@ -272,7 +256,7 @@ instance FromJSON Response where
RespCatalog <$> mapM parseTable xs
other -> fail ("unknown status: " ++ T.unpack other)
where
-- \| 解一张表的 schema(新字段都给了默认值,老响应也能解)
-- \| 解一张表的 schema
parseTable :: A.Value -> Parser TableInfo
parseTable = withObject "TableInfo" $ \o -> do
t <- o .: "table"
@@ -323,12 +307,7 @@ rowFromJSON (A.Object o) = mapM toPair (KM.toList o)
pure (K.toString k, val)
rowFromJSON _ = fail "row must be a JSON object"
-- * 内部封装
-- | 发一条请求,把响应翻成 Either。
--
-- 每个操作的区别只有两处:发什么请求、认哪种响应。所以固定套路收在这里:
-- 对面报错 → `Left`;认得这种响应 → `Right`;其余一律算协议错(比如对面版本对不上)。
-- | 发请求并把响应翻成 Either
ask :: String -> Request -> (Response -> Maybe a) -> IO (Either String a)
ask what req recognize = do
resp <- sendRequest req
@@ -349,7 +328,7 @@ doScan t = ask "scan" (ReqScan t) $ \resp -> case resp of
RespRows rows -> Just rows
_ -> Nothing
-- | 发 Insert(索引由存储层自己维护,这里不传 key)
-- | 发 Insert(索引由存储层维护)
doInsert :: String -> Row -> IO (Either String ())
doInsert t r = ask "insert" (ReqInsert t r) okOnly
@@ -371,14 +350,13 @@ doListTables = ask "list_tables" ReqListTables $ \resp -> case resp of
RespTables ts -> Just ts
_ -> Nothing
-- | 一次拿全库 schema(表名 + 列 + 行数 + 索引 + 统计)。
-- | 一次拿全库 schema
doListCatalog :: IO (Either String [TableInfo])
doListCatalog = ask "list_catalog" ReqListCatalog $ \resp -> case resp of
RespCatalog xs -> Just xs
_ -> Nothing
-- | 按某一列的索引取一行。
-- 返回 `NoIndex` 表示"这个列上没有索引",上层据此退回全表扫描。
-- | 按某一列的索引取一行
doLookupByColumn :: String -> String -> Int -> IO (Either String IndexResult)
doLookupByColumn t c k = ask "lookup_by_index" (ReqLookupByColumn t c k) $ \resp -> case resp of
RespRows [] -> Just (IndexRow Nothing)
@@ -400,6 +378,10 @@ doCreateIndex t c = ask "create_index" (ReqCreateIndex t c) okOnly
doDropIndex :: String -> String -> IO (Either String ())
doDropIndex t c = ask "drop_index" (ReqDropIndex t c) okOnly
-- | 删一列(列定义、这一列上的索引、每行里的那一格一起没)
doDropColumn :: String -> String -> IO (Either String ())
doDropColumn t c = ask "drop_column" (ReqDropColumn t c) okOnly
-- | 发 CreateTable
doCreateTable :: String -> [SchemaColumn] -> IO (Either String ())
doCreateTable t cols = ask "create_table" (ReqCreateTable t cols) okOnly
@@ -408,8 +390,6 @@ doCreateTable t cols = ask "create_table" (ReqCreateTable t cols) okOnly
doDropTable :: String -> IO (Either String ())
doDropTable t = ask "drop_table" (ReqDropTable t) okOnly
-- * 表结构
-- | 线上 schema 转本地列
schemaToColumns :: [SchemaColumn] -> [(String, Column)]
schemaToColumns = map go
@@ -419,9 +399,6 @@ schemaToColumns = map go
go (SchemaColumn n "bool") = (n, TBool)
go (SchemaColumn n _) = (n, TStr)
-- * MonadStorage 实例
-- | 存储方法全走管道
instance MonadStorage IPCStorage where
-- \| 发 Scan
scan t = IPCStorage (doScan t)
@@ -458,6 +435,9 @@ instance MonadStorage IPCStorage where
-- \| 删索引
dropIndex t c = IPCStorage (doDropIndex t c)
-- \| 删一列
dropColumn t c = IPCStorage (doDropColumn t c)
-- \| 列表 + 逐表扫描拼库
snapshot = IPCStorage $ do
result <- doListCatalog
@@ -465,7 +445,7 @@ instance MonadStorage IPCStorage where
Left _ -> pure []
Right xs -> mapM loadEntry xs
where
-- \| 加载一张表(schema 来自 catalog,行来自 scan)
-- \| 加载一张表,行来自 scan
loadEntry info = do
rows <- doScan (tiTable info)
pure
+20 -13
View File
@@ -3,23 +3,15 @@ module ChuSQL.Storage.Memory (MemoryStorage (runMemoryStorage)) where
import ChuSQL.Model
import ChuSQL.Storage
-- 内存实现:状态是 Database,错误通道是 Either String。
--
-- 它只实现"必须有"的那几个:批量插入、行级删除、索引、统计全走类的默认实现
-- (内存里数据本来就在手上,扫一遍就是最快的做法)。
-- 内存实现:Database 上的状态,错误通道是 Either String。
-- * 实例
-- | 内存存储:Database 上的状态
newtype MemoryStorage a = MemoryStorage {runMemoryStorage :: Database -> Either String (a, Database)}
-- | Functor:转发给底层状态
instance Functor MemoryStorage where
fmap f (MemoryStorage m) = MemoryStorage $ \db -> do
(a, db') <- m db
Right (f a, db')
-- | Applicative:转发 pure 和 <*>
instance Applicative MemoryStorage where
pure x = MemoryStorage $ \db -> Right (x, db)
MemoryStorage mf <*> MemoryStorage ma = MemoryStorage $ \db -> do
@@ -27,13 +19,11 @@ instance Applicative MemoryStorage where
(a, db2) <- ma db1
Right (f a, db2)
-- | Monad:顺序执行
instance Monad MemoryStorage where
MemoryStorage m >>= k = MemoryStorage $ \db -> do
(a, db') <- m db
runMemoryStorage (k a) db'
-- | MonadStorage:直接改内存库
instance MonadStorage MemoryStorage where
-- \| 全表扫描
scan t = MemoryStorage $ \db -> do
@@ -62,11 +52,28 @@ instance MonadStorage MemoryStorage where
then Right (Right (), filter ((/= name) . fst) db)
else Right (Left ("unknown table: " ++ name), db)
-- \| 删一列:表定义里去掉这一列,每一行里的那一格也一起去掉
dropColumn name col = MemoryStorage $ \db ->
case lookup name db of
Nothing -> Right (Left ("unknown table: " ++ name), db)
Just tbl ->
if col `notElem` map fst (tableCols tbl)
then Right (Left ("unknown column: " ++ col), db)
else
Right
( Right ()
, replaceTable
name
tbl
{ tableCols = filter ((/= col) . fst) (tableCols tbl)
, tableRows = map (filter ((/= col) . fst)) (tableRows tbl)
}
db
)
-- \| 原样返回当前库
snapshot = MemoryStorage $ \db -> Right (db, db)
-- * 工具
-- | 用给定表替换同名表
replaceTable :: String -> Table -> Database -> Database
replaceTable name table =
+3 -16
View File
@@ -4,9 +4,6 @@ import ChuSQL.Model (Column (..))
-- 语法树:语句、数据来源、条件表达式、排序方向。
-- * 语句
-- | 一条语句:查 / 加 / 删 / 改 / 建表 / 删表 / 建索引 / 删索引
data Statement
= Select
{ selectCols :: [String]
@@ -15,26 +12,21 @@ data Statement
, selectOrderBy :: [(String, SortDir)]
, selectLimit :: Maybe Int
}
| -- \| 表、列、若干行(`VALUES (...), (...)` 可以一次给多行)
Insert String [String] [[Expr]]
| Insert String [String] [[Expr]]
| Delete String (Maybe Expr)
| Update String [(String, Expr)] (Maybe Expr)
| CreateTable String [(String, Column)]
| DropTable String
| -- \| 给某一列建索引(列名即索引名)
CreateIndex String String
| CreateIndex String String
| DropIndex String String
| DropColumn String String
deriving (Show, Eq)
-- | 数据来源:单表,或两表拼接
data FromClause
= FromTable (Maybe String) String
| FromJoin FromClause (Maybe String) String Expr
deriving (Show, Eq)
-- * 表达式
-- | 条件表达式
data Expr
= Col String
| LitInt Int
@@ -47,16 +39,11 @@ data Expr
| Or Expr Expr
deriving (Show, Eq)
-- * 排序
-- | 排序方向
data SortDir
= Asc
| Desc
deriving (Show, Eq)
-- * 构造
-- | 造一个最简单的 SELECT
makeSelect :: [String] -> String -> Maybe Expr -> Statement
makeSelect cols tbl w =
+22 -30
View File
@@ -15,11 +15,8 @@ import Text.Megaparsec.Char.Lexer qualified as Lxr
-- SQL 解析:词法 + 语法,把文本变成语句树。
-- * 词法
-- | 解析器类型
type Parser = Parsec Void String
-- | 跳过空白
sc :: Parser ()
sc = Lxr.space space1 empty empty
@@ -32,9 +29,6 @@ lexeme = Lxr.lexeme sc
symbol :: String -> Parser String
symbol = Lxr.symbol sc
{- | 大小写不敏感的关键字;其后不能紧跟标识符字符,避免把 @selection@ 读成 @select@。
整体用 'try' 包住:否则 @ORDER@ 会被 @keyword "or"@ 吃掉前缀 @OR@ 再报错,导致无法回溯。
-}
-- | 读关键字(不分大小写)
keyword :: String -> Parser ()
keyword k = lexeme $ try $ do
@@ -64,9 +58,6 @@ stringLit = lexeme $ do
_ <- char '\''
return s
{- | 字符串内的一个字符:@''@ 折叠成一个单引号,其余字符(含反斜杠)原样保留。
两个分支都不会在失败时消耗输入,因此可以安全地放进 'many'。
-}
-- | 字符串里的一个字符
stringChar :: Parser Char
stringChar =
@@ -75,7 +66,6 @@ stringChar =
, satisfy (/= '\'')
]
-- * 子句
-- | 升序或降序
sortDir :: Parser SortDir
sortDir = (Desc <$ keyword "desc") <|> (Asc <$ keyword "asc") <|> pure Asc
@@ -94,31 +84,29 @@ qualifiedName = do
return (intercalate "." (first : rest))
-- * 表达式
-- | 读一个表达式
expr :: Parser Expr
expr = makeExprParser atom operatorTable
{- | 运算符表。注意 'makeExprParser' 要求各层按优先级从高到低排列:
比较运算符绑定最紧,'AND' 次之,'OR' 最松。
-}
-- | 运算符优先级(从紧到松)
operatorTable :: [[Operator Parser Expr]]
operatorTable =
[
[ InfixN (Gt <$ symbol ">") -- 最高优先级
[ InfixN (Gt <$ symbol ">")
, InfixN (Lt <$ symbol "<")
, InfixN (Eq <$ symbol "=")
]
, [InfixL (And <$ keyword "AND")]
, [InfixL (Or <$ keyword "OR")] -- 最低优先级
, [InfixL (Or <$ keyword "OR")]
]
-- | 最小的表达式单位
atom :: Parser Expr
atom =
choice
[ LitInt <$> integer
[ LitBool True <$ keyword "TRUE"
, LitBool False <$ keyword "FALSE"
, LitInt <$> integer
, LitStr <$> stringLit
, Col <$> qualifiedName
, between (symbol "(") (symbol ")") expr
@@ -148,7 +136,6 @@ assignment = do
return (col, e)
-- | 保留字清单
reservedWords :: [String]
reservedWords =
[ "select"
@@ -173,12 +160,10 @@ reservedWords =
, "table"
, "index"
, "drop"
, "alter"
, "column"
]
{- | 表别名。别名不能是保留字:否则 @FROM users WHERE age > 18@ 会把 @WHERE@
当成 @users@ 的别名吃掉,后面真正的 WHERE 子句就再也解析不到。
用 'try' 包住,一旦命中保留字就整体回溯,让 'optional' 正常返回 'Nothing'。
-}
-- | 别名(不许用保留字)
aliasName :: Parser String
aliasName = try $ do
@@ -234,7 +219,7 @@ dropTableStatement = do
name <- identifier
return (DropTable name)
-- | 读 CREATE INDEX(列名即索引名:一列最多一个索引)
-- | 读 CREATE INDEX(列名即索引名)
createIndexStatement :: Parser Statement
createIndexStatement = do
keyword "create"
@@ -254,6 +239,17 @@ dropIndexStatement = do
col <- between (symbol "(") (symbol ")") identifier
return (DropIndex tbl col)
-- | 读 ALTER TABLE ... DROP COLUMN
alterDropColumnStatement :: Parser Statement
alterDropColumnStatement = do
keyword "alter"
keyword "table"
tbl <- identifier
keyword "drop"
keyword "column"
col <- identifier
return (DropColumn tbl col)
-- | 建表时的一列
columnDef :: Parser (String, Column)
columnDef = do
@@ -272,7 +268,6 @@ columnType =
<|> (keyword "boolean" >> pure TBool)
<|> (keyword "bool" >> pure TBool)
-- * 语句
-- | 读 SELECT
selectStatement :: Parser Statement
selectStatement = do
@@ -292,7 +287,7 @@ selectStatement = do
, selectLimit = mLimit
}
-- | 读 INSERT:`VALUES (...)` 后面可以跟多个括号,一次插多行
-- | 读 INSERT,可一次插多行
insertStatement :: Parser Statement
insertStatement = do
keyword "INSERT"
@@ -330,7 +325,6 @@ updateStatement = do
mWhere <- optional (keyword "where" *> expr)
return (Update tbl assigns mWhere)
-- * 入口
-- | 解析总入口
parseStatement :: String -> Either String Statement
parseStatement input =
@@ -338,10 +332,7 @@ parseStatement input =
Left err -> Left (errorBundlePretty err)
Right q -> Right q
where
-- \| 依次尝试各种语句。
-- `CREATE` / `DROP` 后面跟的词决定是哪条语句,而 `keyword` 的 `try` 只能回溯到
-- "这个词之前"——`CREATE` 一旦被吃掉就没得退了。所以带 `INDEX` 的那两个各自
-- 用 `try` 包住并排在前面:`CREATE INDEX ...` 先试,退回来再试 `CREATE TABLE ...`。
-- \| 依次尝试各种语句
statementP =
selectStatement
<|> insertStatement
@@ -350,4 +341,5 @@ parseStatement input =
<|> try createIndexStatement
<|> try createTableStatement
<|> try dropIndexStatement
<|> try alterDropColumnStatement
<|> dropTableStatement
+102
View File
@@ -0,0 +1,102 @@
-- ============================================================================
-- ChuSQL DROP TABLE + 统计信息 手测脚本
-- 用法:
-- 1. 另开终端起 Rust server:cd chusql-storage && cargo run --bin chusql-storage
-- 2. GHCi 里执行::script ghci-scripts/drop-table-test.ghci
-- ============================================================================
:set -XOverloadedStrings
import ChuSQL.Storage
import ChuSQL.Storage.IPC
import ChuSQL.Model
import ChuSQL.Engine
import ChuSQL.Syntax.AST
import ChuSQL.Syntax.Parser
-- 辅助:把一条 SQL 字符串跑到底
let runSql sql = case parseQuery sql of { Left e -> putStrLn ("parse error: " ++ e); Right q -> runIPCStorage (runQueryM q) >>= print }
-- ============================================================================
-- 2. 清场:如果 demo3 存在就删掉
-- ============================================================================
putStrLn ">>> [2] cleanup"
runSql "DROP TABLE demo3"
-- ============================================================================
-- 3. 建表
-- ============================================================================
putStrLn ">>> [3] CREATE TABLE"
runSql "CREATE TABLE demo3 (id INT, name TEXT)"
-- ============================================================================
-- 4. describe:列应为 id/name,行数应为 0
-- ============================================================================
putStrLn ">>> [4] describe (row_count should be 0)"
doDescribeTable "demo3"
-- ============================================================================
-- 5. 空表能扫
-- ============================================================================
putStrLn ">>> [5] scan empty table"
runIPCStorage (scan "demo3")
-- ============================================================================
-- 6. 插 3 行
-- ============================================================================
putStrLn ">>> [6] INSERT 3 rows"
runSql "INSERT INTO demo3 (id, name) VALUES (1, 'Alice')"
runSql "INSERT INTO demo3 (id, name) VALUES (2, 'Bob')"
runSql "INSERT INTO demo3 (id, name) VALUES (3, 'Carol')"
-- ============================================================================
-- 7. describe:行数应为 3
-- ============================================================================
putStrLn ">>> [7] describe (row_count should be 3)"
doDescribeTable "demo3"
-- ============================================================================
-- 8. 扫回三行
-- ============================================================================
putStrLn ">>> [8] scan 3 rows"
runIPCStorage (scan "demo3")
-- ============================================================================
-- 9. SQL SELECT,走索引
-- ============================================================================
putStrLn ">>> [9] SELECT WHERE id = 2"
runSql "SELECT name FROM demo3 WHERE id = 2"
-- ============================================================================
-- 10. 重复建表应报错
-- ============================================================================
putStrLn ">>> [10] CREATE TABLE again (should fail)"
runSql "CREATE TABLE demo3 (x INT)"
-- ============================================================================
-- 11. 删表
-- ============================================================================
putStrLn ">>> [11] DROP TABLE"
runSql "DROP TABLE demo3"
-- ============================================================================
-- 12. 删后再查,应报 unknown table
-- ============================================================================
putStrLn ">>> [12a] scan after drop (should fail)"
runIPCStorage (scan "demo3")
putStrLn ">>> [12b] describe after drop (should fail)"
doDescribeTable "demo3"
-- ============================================================================
-- 13. 删不存在的表应报错
-- ============================================================================
putStrLn ">>> [13] DROP TABLE nope (should fail)"
runSql "DROP TABLE nope"
-- ============================================================================
-- 14. 列出所有表,不应含 demo3
-- ============================================================================
putStrLn ">>> [14] list_tables"
doListTables
putStrLn ">>> done. check chusql-storage/data/ for disk state"
+56 -13
View File
@@ -23,6 +23,8 @@ import System.IO (Handle)
import System.Process (CreateProcess (..), ProcessHandle, StdStream (NoStream), createProcess, proc, terminateProcess, waitForProcess)
import Test.Hspec
-- 引擎层的 hspec 测试:语法、执行、优化器与 IPC 行为。
users :: Table
users =
Table
@@ -366,7 +368,6 @@ main = hspec $ do
Right (db', _) -> do
rowsOf (parseStatement "SELECT name FROM users WHERE age < 3" >>= runStatement db')
`shouldBe` Right [[("name", VStr "A")], [("name", VStr "B")]]
-- 多行里只要有一行列数不对,整条都要拒绝
runStatement testDB (Insert "users" ["id", "name"] [[LitInt 1, LitStr "A"], [LitInt 2]])
`shouldSatisfy` isLeft
@@ -390,6 +391,36 @@ main = hspec $ do
`shouldBe` Right []
rowsOf (parseStatement "DROP INDEX ON users (age)" >>= runStatement testDB)
`shouldBe` Right []
describe "ChuSQL.Engine (DROP COLUMN)" $ do
it "parses ALTER TABLE ... DROP COLUMN case-insensitively" $ do
parseStatement "ALTER TABLE users DROP COLUMN age"
`shouldBe` Right (DropColumn "users" "age")
parseStatement "alter table users drop column age"
`shouldBe` Right (DropColumn "users" "age")
it "after execution the column definition and every row cell are gone, other columns untouched" $ do
case runStatement testDB (DropColumn "users" "age") of
Left err -> expectationFailure err
Right (db', _) -> do
case lookup "users" db' of
Nothing -> expectationFailure "users table is gone"
Just t -> do
map fst (tableCols t) `shouldBe` ["id", "name"]
tableRows t `shouldSatisfy` all (all (\(k, _) -> k /= "age"))
let rowCountBefore = maybe 0 (length . tableRows) (lookup "users" testDB)
length (tableRows t) `shouldBe` rowCountBefore
tableRows t `shouldBe` map (filter ((/= "age") . fst)) (maybe [] tableRows (lookup "users" testDB))
it "unknown column or unknown table reports an error" $ do
(parseStatement "ALTER TABLE users DROP COLUMN nope" >>= runStatement testDB)
`shouldSatisfy` isLeft
(parseStatement "ALTER TABLE nope DROP COLUMN age" >>= runStatement testDB)
`shouldSatisfy` isLeft
it "the built-in id column cannot be dropped (the UI locates a row by it)" $ do
(parseStatement "ALTER TABLE users DROP COLUMN id" >>= runStatement testDB)
`shouldSatisfy` isLeft
describe "ChuSQL.Engine (DELETE)" $ do
it "parses DELETE with WHERE" $ do
parseStatement "DELETE FROM users WHERE age < 18"
@@ -1009,16 +1040,13 @@ main = hspec $ do
sameResultAsUnoptimized "SELECT name FROM users WHERE id = 2"
it "rewrites an equality on any column into Lookup" $ do
-- 能不能真走索引由存储层回答;优化器只负责说"这里可以点查"
fmap planRoot (optimizedPlan "SELECT name FROM users WHERE age = 25")
`shouldBe` Right (Lookup Nothing "users" "age" 25)
it "keeps a point lookup on a non-indexed column identical to unoptimized" $ do
-- 内存实现没有索引,这条会走"退回扫描"的分支
sameResultAsUnoptimized "SELECT name FROM users WHERE age = 25"
it "keeps an aliased point lookup identical to unoptimized" $ do
-- 别名要留住:回来的一行必须和 Scan 别名 表 长得一样,不然投影取不到列
sameResultAsUnoptimized "SELECT u.name FROM users u WHERE u.id = 1"
it "returns the row for an aliased point lookup" $ do
@@ -1281,13 +1309,11 @@ main = hspec $ do
(Insert "ix_t" ["id", "code"] [[LitInt 1, LitInt 500], [LitInt 2, LitInt 501]])
)
-- 还没建索引:存储层明确回"我帮不上忙",上层会退回扫描
withoutIndex <- runIPCStorage (lookupByColumn "ix_t" "code" 501)
withoutIndex `shouldBe` Right NoIndex
scanned <- runIPCStorage (runStatementM (makeSelect ["code"] "ix_t" (Just (Eq (Col "code") (LitInt 501)))))
scanned `shouldBe` Right [[("code", VInt 501)]]
-- 建索引之后就真的走索引了
created <- runIPCStorage (runStatementM (CreateIndex "ix_t" "code"))
created `shouldBe` Right []
withIndex <- runIPCStorage (lookupByColumn "ix_t" "code" 501)
@@ -1296,7 +1322,6 @@ main = hspec $ do
indexed <- runIPCStorage (runStatementM (makeSelect ["code"] "ix_t" (Just (Eq (Col "code") (LitInt 501)))))
indexed `shouldBe` Right [[("code", VInt 501)]]
-- 建完索引之后插入的行也要进索引
_ <- runIPCStorage (runStatementM (Insert "ix_t" ["id", "code"] [[LitInt 3, LitInt 502]]))
fresh <- runIPCStorage (lookupByColumn "ix_t" "code" 502)
fmap sortRow (indexRow fresh)
@@ -1353,6 +1378,24 @@ main = hspec $ do
parseStatement "CREATE TABLE t (flag BOOL)"
`shouldBe` Right (CreateTable "t" [("flag", TBool)])
it "parses TRUE / FALSE as boolean literals (case-insensitive)" $ do
parseStatement "INSERT INTO t (flag) VALUES (TRUE)"
`shouldBe` Right (Insert "t" ["flag"] [[LitBool True]])
parseStatement "INSERT INTO t (flag) VALUES (false)"
`shouldBe` Right (Insert "t" ["flag"] [[LitBool False]])
it "boolean literals work in WHERE (and don't need a column named true)" $ do
parseStatement "SELECT * FROM t WHERE flag = TRUE"
`shouldBe` Right (makeSelect ["*"] "t" (Just (Eq (Col "flag") (LitBool True))))
it "inserts and reads back a boolean column end-to-end" $ do
let db = [("t", Table "t" [("id", TInt), ("flag", TBool)] [])]
case runStatement db (Insert "t" ["id", "flag"] [[LitInt 1, LitBool True]]) of
Left e -> expectationFailure e
Right (db', _) ->
rowsOf (runStatement db' (makeSelect ["flag"] "t" Nothing))
`shouldBe` Right [[("flag", VBool True)]]
it "parses CREATE TABLE with VARCHAR as TStr" $ do
parseStatement "CREATE TABLE t (name VARCHAR)"
`shouldBe` Right (CreateTable "t" [("name", TStr)])
@@ -1418,17 +1461,17 @@ locateServer = do
Nothing -> pure (Left "chusql-storage directory not found (run these tests inside the repository)")
Just dir -> do
built <-
try (createProcess (proc "cargo" ["build", "--bin", "server"]){cwd = Just dir, std_out = NoStream, std_err = NoStream}) ::
try (createProcess (proc "cargo" ["build", "--bin", "chusql-storage"]){cwd = Just dir, std_out = NoStream, std_err = NoStream}) ::
IO (Either IOException (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle))
case built of
Left e -> pure (Left ("cannot run cargo (Rust is required for the IPC tests): " ++ show e))
Right (_, _, _, ph) -> do
ec <- waitForProcess ph
case ec of
ExitFailure n -> pure (Left ("cargo build --bin server failed with exit code " ++ show n))
ExitFailure n -> pure (Left ("cargo build --bin chusql-storage failed with exit code " ++ show n))
ExitSuccess -> do
found <- firstExisting [dir </> "target" </> "debug" </> "server.exe", dir </> "target" </> "debug" </> "server"]
pure (maybe (Left "cargo build finished but no server executable was found") Right found)
found <- firstExisting [dir </> "target" </> "debug" </> "chusql-storage.exe", dir </> "target" </> "debug" </> "chusql-storage"]
pure (maybe (Left "cargo build finished but no storage executable was found") Right found)
where
firstDir [] = pure Nothing
firstDir (d : ds) = do
@@ -1463,7 +1506,7 @@ startServerAt bin dir = do
waitUntilReady srv 100
pure srv
-- | 每次调用都不同的后缀:CPU 时间精度不够,再加上一个唯一编号。
-- | 每次调用都不同的后缀:CPU 时间加唯一编号
uniqueStamp :: IO String
uniqueStamp = do
cpu <- getCPUTime
@@ -1522,7 +1565,7 @@ withPipeName name act = do
sortRow :: Row -> Row
sortRow = sortOn fst
-- | 从一次索引点查的结果里取出那一行(没索引、没查到都给 Nothing)
-- | 取出索引点查返回的那一行,取不到给 Nothing
indexRow :: Either String IndexResult -> Maybe Row
indexRow (Right (IndexRow r)) = r
indexRow _ = Nothing
+1 -1
View File
@@ -22,7 +22,7 @@ order = 4
[storage]
# 数据目录:一张表一个 <表名>.db,一个索引一个 <表名>.idx。
# 环境变量:CHUSQL_DATA_DIR
data_dir = "data"
data_dir = "../localdata"
[server]
# 命名管道名(Windows 上实际是 \\.\pipe\<这个名字>)。
@@ -1,4 +1,3 @@
use std::collections::{BTreeSet, HashMap};
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
@@ -10,7 +9,7 @@ use chusql_storage::config::{self, Config, Loaded};
use chusql_storage::heap::HeapTable;
use chusql_storage::log;
use chusql_storage::protocol::{
ColumnStatWire, IndexWire, Request, Response, SchemaColumn, TableSchemaWire,
ColumnStatWire, IndexWire, Request, Response, Row, SchemaColumn, TableSchemaWire,
};
use chusql_storage::wal::{Wal, WalOp};
use chusql_storage::{log_debug, log_error, log_info, log_warn};
@@ -20,8 +19,6 @@ use interprocess::TryClone;
// 命名管道服务:按行读写 JSON,Windows 上映射成 \\.\pipe\<name>。
// 启动时读配置并把每项来源打进日志;表句柄按名字缓存复用。
// 服务器
/// 服务器状态:配置 + 表句柄缓存 + 写锁 + 数据字典
struct Server {
cfg: Config,
tables: Mutex<HashMap<String, Arc<Mutex<HeapTable>>>>,
@@ -45,16 +42,12 @@ impl Server {
})
}
// 路径
/// 表名 -> 数据文件
fn table_path(&self, table: &str) -> PathBuf {
self.cfg.data_dir.join(format!("{}.db", table))
}
/// (表名, 列名) -> 索引文件。
///
/// `id` 是内建索引,沿用老文件名 `表名.idx`(老数据目录不用迁移);
/// 其余列是 `表名.列名.idx`。
/// (表名, 列名) 对应哪个索引文件
fn index_path(&self, table: &str, column: &str) -> PathBuf {
if column == "id" {
self.cfg.data_dir.join(format!("{}.idx", table))
@@ -70,7 +63,6 @@ impl Server {
self.cfg.data_dir.join("catalog.json")
}
// 表句柄
/// 表文件存在且非空
fn table_exists(&self, table: &str) -> bool {
if self.tables.lock().unwrap().contains_key(table) {
@@ -86,7 +78,7 @@ impl Server {
.unwrap_or(false)
}
/// 一张表该开哪些索引:内建的 `id`,加上数据字典里记的那些
/// 一张表该开哪些索引
fn index_columns(&self, table: &str) -> Vec<String> {
let mut cols = vec!["id".to_string()];
let c = self.catalog.lock().unwrap();
@@ -105,7 +97,6 @@ impl Server {
if let Some(h) = self.tables.lock().unwrap().get(table) {
return Ok(h.clone());
}
// 先把索引清单取出来,再进 tables 锁(别套着锁去要 catalog)
let columns = self.index_columns(table);
let paths: Vec<(String, PathBuf)> = columns
.iter()
@@ -146,7 +137,6 @@ impl Server {
table: &str,
f: impl FnOnce(&mut HeapTable) -> std::io::Result<R>,
) -> std::io::Result<R> {
// 已经在缓存里的表不用再查磁盘(省一次 metadata 系统调用)
let cached = self.tables.lock().unwrap().contains_key(table);
if !cached && !self.table_exists(table) {
return Err(std::io::Error::new(
@@ -157,7 +147,6 @@ impl Server {
self.with_table(table, f)
}
// 写入路径
/// 把一条 WAL 操作落到磁盘
fn apply_op(&self, op: &WalOp) -> std::io::Result<()> {
match op {
@@ -191,11 +180,23 @@ impl Server {
}
WalOp::ReplaceAll { table, rows } => {
self.with_table(table, |t| t.replace_all(rows))?;
// 整表重写 = 统计与行数从头再数一遍
let mut c = self.catalog.lock().unwrap();
c.rebuild_stats(table, rows);
c.save(self.catalog_path())
}
WalOp::DropColumn { table, column, rows } => {
self.with_table(table, |t| {
t.detach_index(column);
t.replace_all(rows)
})?;
let mut c = self.catalog.lock().unwrap();
c.remove_column(table, column)?;
c.rebuild_stats(table, rows);
c.save(self.catalog_path())?;
drop(c);
let _ = std::fs::remove_file(self.index_path(table, column));
Ok(())
}
}
}
@@ -205,8 +206,6 @@ impl Server {
std::fs::create_dir_all(&self.cfg.data_dir)?;
let wal = &self.wal;
// 只截断不落盘:上一条写操作最后已经把 WAL 刷成空的了,
// 这里的截断只是保证下面追加的是唯一一条,跟着的 append 会顺手把它落下去。
wal.truncate()?;
wal.append(op)?;
@@ -219,7 +218,6 @@ impl Server {
}
}
// 这一步必须落盘:WAL 空 = 这条操作已经提交
wal.clear()?;
result
}
@@ -230,7 +228,8 @@ impl Server {
WalOp::Insert { table, .. }
| WalOp::InsertBatch { table, .. }
| WalOp::DeleteKeys { table, .. }
| WalOp::ReplaceAll { table, .. } => table,
| WalOp::ReplaceAll { table, .. }
| WalOp::DropColumn { table, .. } => table,
};
self.with_table(table, |t| t.flush())
}
@@ -251,10 +250,7 @@ impl Server {
}
}
/// 启动时把列统计重数一遍。
///
/// 统计是"每列见过哪些值"精确数出来的,只在内存里维护(随 catalog 落盘),
/// 所以重启之后要扫一遍表把它重建出来;否则统计会随重启慢慢失真。
/// 启动时重建列统计
fn rebuild_stats(&self) -> std::io::Result<()> {
let names: Vec<String> = self.catalog.lock().unwrap().table_names();
let mut touched = false;
@@ -274,13 +270,12 @@ impl Server {
Ok(())
}
// 索引维护
/// 给一列建索引
fn create_index(&self, table: &str, column: &str) -> std::io::Result<()> {
if column == "id" {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"id 是内建索引,不需要再建",
"id is the built-in index and cannot be created twice",
));
}
{
@@ -321,7 +316,7 @@ impl Server {
if column == "id" {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"id 是内建索引,不能删",
"id is the built-in index and cannot be dropped",
));
}
{
@@ -347,7 +342,48 @@ impl Server {
Ok(())
}
// 请求分发
/// 删一列:定义、索引、行里的字段
fn drop_column(&self, table: &str, column: &str) -> std::io::Result<()> {
if column == "id" {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"the built-in id column cannot be dropped",
));
}
{
let c = self.catalog.lock().unwrap();
let Some(schema) = c.describe(table) else {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("unknown table: {}", table),
));
};
if !schema.columns.iter().any(|c| c.name == column) {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("unknown column: {}", column),
));
}
}
let rows: Vec<Row> = self
.with_existing_table(table, |t| t.scan())?
.into_iter()
.map(|row| {
row.into_iter()
.filter(|(k, _)| k != column)
.collect::<Row>()
})
.collect();
let op = WalOp::DropColumn {
table: table.to_string(),
column: column.to_string(),
rows,
};
self.write_via_wal(&op)
}
/// 一条请求翻成一条响应
fn handle_request(&self, req: Request) -> Response {
match req {
@@ -430,8 +466,6 @@ impl Server {
}
};
if !indexed {
// 没有这个索引:告诉查询层"这条我帮不上忙",让它退回全表扫描。
// 回 NoIndex 而不是空结果,是因为"没有索引"和"没查到"必须分开。
return Response::NoIndex;
}
match self.with_table(&table, |t| t.get_by_column_key(&column, key)) {
@@ -599,10 +633,22 @@ impl Server {
}
}
}
Request::DropColumn { table, column } => {
log_debug!(pipe, "drop_column table={} column={}", table, column);
match self.drop_column(&table, &column) {
Ok(()) => Response::Ok,
Err(e) => {
log_warn!(pipe, "drop_column {}({}): {}", table, column, e);
Response::Error {
message: format!("drop_column {}({}): {}", table, column, e),
}
}
}
}
}
}
// 信息查询
/// 表名:catalog 与目录取并集
fn list_tables(&self) -> std::io::Result<Vec<String>> {
let mut names = BTreeSet::new();
@@ -625,7 +671,7 @@ impl Server {
Ok(names.into_iter().collect())
}
/// 查一张表的 schema(列 + 行数 + 索引 + 统计)
/// 查一张表的 schema
fn describe_table(
&self,
table: &str,
@@ -650,8 +696,7 @@ impl Server {
}
}
/// 一张表的索引定义 -> 线上格式。
/// `id` 是内建索引(表一开出来就有),数据字典里不记它,但对外要报出来。
/// 一张表的索引定义转线上格式
fn index_wires(schema: &chusql_storage::catalog::TableSchema) -> Vec<IndexWire> {
let mut out = vec![IndexWire {
column: "id".to_string(),
@@ -687,7 +732,6 @@ impl Drop for Server {
}
}
// 连接
/// 处理一条连接
fn handle(server: &Server, conn: LocalSocketStream) -> std::io::Result<()> {
let mut writer = conn.try_clone()?;
@@ -716,7 +760,6 @@ fn handle(server: &Server, conn: LocalSocketStream) -> std::io::Result<()> {
Ok(())
}
// 入口
/// 把配置来源打进日志
fn log_config(loaded: &Loaded) {
match &loaded.config_path {
@@ -751,7 +794,6 @@ fn main() -> std::io::Result<()> {
}
if let Err(e) = server.rebuild_stats() {
// 统计重建失败不影响服务:报一声,继续跑(统计会停留在落盘时的值)
log_warn!(core, "stats rebuild skipped: {}", e);
}
+1 -19
View File
@@ -5,26 +5,19 @@ use crate::page::{Page, PageFile, PageId};
// 磁盘 B+ 树:节点占一页,文件头记页大小与 order。
// 布局常量
/// 文件头放在第 0 页
const META_PAGE: PageId = 0;
/// 魔数,用来认出索引文件
const META_MAGIC: &[u8; 4] = b"CBTR";
/// 文件头各字段的偏移
const OFF_META_PAGE_SIZE: usize = 4;
const OFF_META_ORDER: usize = 8;
const OFF_META_ROOT: usize = 10;
/// 节点类型标记
const NODE_LEAF: u8 = 0;
const NODE_INTERNAL: u8 = 1;
/// 节点内布局的偏移
const OFF_TYPE: usize = 0;
const OFF_COUNT: usize = 1;
const OFF_NEXT: usize = 3;
const OFF_KEYS: usize = 12;
// 容量计算
/// 一个节点占多少字节
fn node_bytes(order: usize) -> usize {
OFF_KEYS + max_keys(order) * 8 + order * 8
@@ -50,7 +43,6 @@ pub fn max_order(page_size: usize) -> usize {
page_size.saturating_sub(4) / 16
}
// 小端读写
/// 读一个 u8
fn read_u8(p: &Page, off: usize) -> u8 {
p.data[off]
@@ -93,7 +85,6 @@ fn write_u64(p: &mut Page, off: usize, v: u64) {
p.data[off..off + 8].copy_from_slice(&v.to_le_bytes());
}
// 文件头
/// 写文件头
fn write_meta(page: &mut Page, page_size: usize, order: usize, root: PageId) {
page.zero();
@@ -125,8 +116,6 @@ fn check_meta(page: &Page, page_size: usize, order: usize) -> io::Result<()> {
Ok(())
}
// 节点编解码
/// 节点在内存里的样子
enum NodeData {
Leaf {
keys: Vec<i64>,
@@ -210,8 +199,6 @@ fn child_index(keys: &[i64], key: i64) -> usize {
lo
}
// B+ 树
/// 磁盘 B+ 树
pub struct DiskBTree {
file: PageFile,
order: usize,
@@ -430,12 +417,7 @@ impl DiskBTree {
}
}
/// 删掉一个键;键不存在返回 false。
///
/// 这是**惰性删除**:只把叶子里那一对 (key, value) 拿掉,不做合并、也不向兄弟借位。
/// 于是树可能变稀、甚至留下空叶子,但查找依然正确——内部节点的分隔键仍然满足
/// "小于等于它的往左、大于它的往右",空叶子二分找不到东西自然返回 None。
/// 代价是空间不回收(节点删空了也不还页),整表重写(`clear`)时会一起重建。
/// 删掉一个键,键不存在返回 false
pub fn delete(&mut self, key: i64) -> io::Result<bool> {
match self.root()? {
None => Ok(false),
+23 -26
View File
@@ -1,4 +1,3 @@
use std::collections::hash_map::DefaultHasher;
use std::collections::{BTreeMap, HashSet};
use std::hash::{Hash, Hasher};
@@ -10,32 +9,22 @@ use serde::{Deserialize, Serialize};
use crate::protocol::{ColumnType, Row, SchemaColumn};
// 数据字典:记每张表有哪些列、什么类型、多少行、有哪些索引,以及列级统计。
//
// 统计只在内存里精确维护(每列见过哪些值的哈希,有上限),随 catalog.json 一起落盘;
// 服务启动时会扫一遍表把统计重建出来,所以重启之后数字仍然是对的。
/// 一列的不同值个数最多精确统计到这里;超过就只报"下界"
const STATS_DISTINCT_CAP: u64 = 4096;
// 表结构
#[derive(Debug, Clone, Serialize, Deserialize)]
/// 一条索引定义:只记"盯的是哪一列"(索引按列命名,一列最多一个)
pub struct IndexSchema {
pub column: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
/// 一列的统计
pub struct ColumnStat {
/// 不同值个数(`capped` 为真时是下界)
pub distinct: u64,
/// 不同值太多,已经不再精确统计
#[serde(default)]
pub capped: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
/// 一张表的 schema + 统计
pub struct TableSchema {
pub columns: Vec<SchemaColumn>,
#[serde(default)]
@@ -47,16 +36,13 @@ pub struct TableSchema {
}
#[derive(Debug, Default, Serialize, Deserialize)]
/// 数据字典:表名到 schema
pub struct Catalog {
tables: BTreeMap<String, TableSchema>,
/// 每列"见过的值"的哈希集合(只在内存里,不落盘;有上限)
#[serde(skip)]
seen: BTreeMap<String, HashSet<u64>>,
}
impl Catalog {
// 读取与保存
/// 从文件读;不存在给空字典
pub fn load<P: AsRef<Path>>(path: P) -> io::Result<Self> {
if !path.as_ref().exists() {
@@ -80,7 +66,6 @@ impl Catalog {
std::fs::write(path, bytes)
}
// 查询
/// 一张表的 schema
pub fn describe(&self, table: &str) -> Option<&TableSchema> {
self.tables.get(table)
@@ -91,7 +76,6 @@ impl Catalog {
self.tables.keys().cloned().collect()
}
// 更新
/// 按一行数据补列
pub fn ensure_columns(&mut self, table: &str, row: &Row) {
let entry = self.tables.entry(table.to_string()).or_default();
@@ -118,7 +102,7 @@ impl Catalog {
Ok(())
}
/// 记一次插入:补列 + 行数 +1 + 更新列统计;返回"列有没有变化"(决定要不要立刻写盘)
/// 记一次插入,返回列有没有变化
pub fn record_insert(&mut self, table: &str, row: &Row) -> bool {
let changed = {
let entry = self.tables.entry(table.to_string()).or_default();
@@ -141,7 +125,6 @@ impl Catalog {
if stat.capped {
continue;
}
// 这个值彻底没了才减:集合里删得掉说明没有别的行还用它
if seen.remove(&fold(name, value_hash(v))) {
stat.distinct = stat.distinct.saturating_sub(1);
}
@@ -149,7 +132,7 @@ impl Catalog {
}
}
/// 重数一遍统计与行数(启动时重建、整表改写完都走它);返回"列有没有变化"
/// 重数一遍统计与行数
pub fn rebuild_stats(&mut self, table: &str, rows: &[Row]) -> bool {
self.seen.remove(table);
{
@@ -157,12 +140,11 @@ impl Catalog {
entry.stats.clear();
entry.row_count = 0;
}
// 逐行"插入"地数:行数、列、统计一次到位
rows.iter()
.fold(false, |changed, r| self.record_insert(table, r) || changed)
}
/// 列统计:这一列第一次见到这个值就 distinct +1(到上限之后只报下界)
/// 这一列第一次见到这个值就加一
fn count_values(&mut self, table: &str, row: &Row) {
let entry = self.tables.entry(table.to_string()).or_default();
let seen = self.seen.entry(table.to_string()).or_default();
@@ -175,7 +157,7 @@ impl Catalog {
stat.distinct += 1;
if stat.distinct >= STATS_DISTINCT_CAP {
stat.capped = true;
seen.clear(); // 到上限就不再精确统计,把内存还回去
seen.clear();
}
}
}
@@ -192,7 +174,23 @@ impl Catalog {
Ok(())
}
// 索引
/// 删一列;之前没有也返回成功(重放幂等)
pub fn remove_column(&mut self, table: &str, column: &str) -> io::Result<bool> {
let entry = self
.tables
.get_mut(table)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("unknown table: {}", table)))?;
let had_column = {
let before = entry.columns.len();
entry.columns.retain(|c| c.name != column);
entry.columns.len() != before
};
entry.stats.remove(column);
entry.indexes.retain(|i| i.column != column);
self.seen.remove(table);
Ok(had_column)
}
/// 记下一条索引定义
pub fn add_index(&mut self, table: &str, column: &str) -> io::Result<()> {
let entry = self
@@ -230,7 +228,6 @@ impl Catalog {
}
}
// 推断
/// 把行里"字典还不知道的列"补进来;返回有没有变化
fn add_missing_columns(entry: &mut TableSchema, row: &Row) -> bool {
let mut changed = false;
@@ -259,14 +256,14 @@ fn infer_type(v: &serde_json::Value) -> Option<ColumnType> {
}
}
/// 一个值的哈希(按它的 JSON 文本算;统计"不同值个数"用)
/// 一个值的哈希,统计不同值个数用
fn value_hash(v: &serde_json::Value) -> u64 {
let mut h = DefaultHasher::new();
v.to_string().hash(&mut h);
h.finish()
}
/// 把"列名"和"值哈希"合成一个哈希:避免两列的同值互相干扰
/// 列名与值哈希合成一个哈希
fn fold(column: &str, value_hash: u64) -> u64 {
let mut h = DefaultHasher::new();
column.hash(&mut h);
+2 -27
View File
@@ -6,32 +6,20 @@ use crate::log::Level;
// 配置:环境变量优先于 TOML,最后回退内置默认值。
// 默认值
/// 默认配置文件路径
pub const DEFAULT_CONFIG_PATH: &str = "chusql-storage.toml";
/// 默认池容量(页数)
pub const DEFAULT_POOL_SIZE: usize = 1024;
/// 默认页大小
pub const DEFAULT_PAGE_SIZE: usize = 4096;
/// 默认 B+ 树 order
pub const DEFAULT_BTREE_ORDER: usize = 4;
/// 默认数据目录
pub const DEFAULT_DATA_DIR: &str = "data";
/// 默认管道名
pub const DEFAULT_DATA_DIR: &str = "../localdata";
pub const DEFAULT_PIPE_NAME: &str = "chusql-storage";
/// 默认日志等级
pub const DEFAULT_LOG_LEVEL: &str = "info";
/// 页大小允许范围
const MIN_PAGE_SIZE: usize = 512;
const MAX_PAGE_SIZE: usize = 65536;
/// order 最小值
const MIN_BTREE_ORDER: usize = 3;
#[derive(Debug, Clone, PartialEq, Eq)]
// 配置
/// 生效的配置
pub struct Config {
pub page_size: usize,
pub btree_order: usize,
@@ -41,7 +29,6 @@ pub struct Config {
pub log_level: Level,
}
/// 全默认值
impl Default for Config {
fn default() -> Self {
Config {
@@ -56,7 +43,6 @@ impl Default for Config {
}
#[derive(Debug, Clone, PartialEq, Eq)]
/// 一个选项的来源
pub enum Origin {
Default,
File(PathBuf),
@@ -75,7 +61,6 @@ impl Origin {
}
#[derive(Debug, Clone)]
/// 配置 + 每项来源
pub struct Loaded {
pub config: Config,
pub config_path: Option<PathBuf>,
@@ -84,8 +69,6 @@ pub struct Loaded {
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
// 文件形状
/// TOML 的形状(字段都可选)
struct FileConfig {
page: Option<FilePage>,
btree: Option<FileBtree>,
@@ -97,47 +80,40 @@ struct FileConfig {
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
/// [buffer] 段
struct FileBuffer {
pool_size: Option<usize>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
/// [page] 段
struct FilePage {
size: Option<usize>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
/// [btree] 段
struct FileBtree {
order: Option<usize>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
/// [storage] 段
struct FileStorage {
data_dir: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
/// [server] 段
struct FileServer {
pipe_name: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
/// [log] 段
struct FileLog {
level: Option<String>,
}
// 加载
/// 按真实环境加载
pub fn load() -> Result<Loaded, String> {
let path = match std::env::var("CHUSQL_CONFIG") {
@@ -252,7 +228,7 @@ pub fn resolve(
let origins = vec![
("page.size", page_size.to_string(), page_origin),
("btree.order", btree_order.to_string(), order_origin),
("buffer.pool_size", pool_size.to_string(), pool_origin), // 新增
("buffer.pool_size", pool_size.to_string(), pool_origin),
("storage.data_dir", data_dir.clone(), dir_origin),
("server.pipe_name", pipe_name.clone(), pipe_origin),
("log.level", log_level.name().to_string(), log_origin),
@@ -291,7 +267,6 @@ fn pick<T: Clone>(
Ok((default, Origin::Default))
}
// 校验
/// 检查选项是否自洽
fn validate_layout(page_size: usize, btree_order: usize) -> Result<(), String> {
if !(MIN_PAGE_SIZE..=MAX_PAGE_SIZE).contains(&page_size) || !page_size.is_power_of_two() {
+13 -39
View File
@@ -8,16 +8,9 @@ use crate::page::{Page, PageFile};
use crate::protocol::Row;
// 堆表:行按插入顺序进页,支持全表扫描、按索引取行、行级删除。
//
// 索引由**表自己维护**:有哪些索引记在数据字典里,插入时按索引的列从行里取值写进去,
// 调用方不需要知道有哪些索引,也不需要自己指定 key。
// 页内布局
/// 页头:slot_count 占 2 字节
const HEADER_SIZE: usize = 2;
/// 一个槽位占 8 字节
const SLOT_SIZE: usize = 8;
/// 槽位目录起点
const SLOT_DIR_START: usize = HEADER_SIZE;
/// 读一个小端 u16
@@ -40,7 +33,6 @@ fn write_u32(buf: &mut [u8], off: usize, v: u32) {
buf[off..off + 4].copy_from_slice(&v.to_le_bytes());
}
// 索引位置
/// (页号, 槽位号) 打包成 u64
fn pack_position(page_id: PageId, slot: u16) -> u64 {
(page_id << 16) | (slot as u64)
@@ -59,8 +51,6 @@ fn row_int(row: &Row, column: &str) -> Option<i64> {
}
}
// 页内操作
/// 只管一页里的字节
pub struct HeapPage;
impl HeapPage {
@@ -115,7 +105,7 @@ impl HeapPage {
Some(n)
}
/// 删掉一个槽位:只把长度置 0,行字节原地留着(读的时候按"长度 0 = 没有这一行"处理)
/// 删掉一个槽位,只把长度置 0
pub fn delete_tuple(page: &mut Page, slot: u16) -> bool {
let n = Self::slot_count(page);
if slot >= n {
@@ -150,15 +140,11 @@ impl HeapPage {
}
}
// 索引
/// 表上的一个索引:盯着一列,键是这一列的整数值
struct NamedIndex {
column: String,
tree: DiskBTree,
}
// 堆表
/// 一个数据文件 = 若干页;外加若干索引文件
pub struct HeapTable {
file: PageFile,
indexes: Vec<NamedIndex>,
@@ -173,7 +159,7 @@ impl HeapTable {
})
}
/// 打开表 + `id` 列索引(`id` 沿用老文件名 `表名.idx`)
/// 打开表并挂上 id 列索引
pub fn open_indexed<P: AsRef<Path>, Q: AsRef<Path>>(
path: P,
index_path: Q,
@@ -189,7 +175,7 @@ impl HeapTable {
Ok(t)
}
/// 打开表 + 任意组索引:(列名, 索引文件路径)
/// 打开表并挂上多组索引
pub fn open_with_indexes<P: AsRef<Path>>(
path: P,
indexes: &[(String, std::path::PathBuf)],
@@ -207,7 +193,7 @@ impl HeapTable {
Ok(t)
}
/// 挂上一棵索引树(只打开/创建文件,不填数据)
/// 挂上一棵索引树,不填数据
fn attach_index<P: AsRef<Path>>(
&mut self,
column: &str,
@@ -229,11 +215,7 @@ impl HeapTable {
self.indexes.iter().any(|i| i.column == column)
}
/// 建索引:先扫一遍查重,再建树填数据。
///
/// **要求这一列的整数取值唯一**,不唯一就报错、一个索引文件都不留。
/// 这样"走索引取一行"和"全表扫一遍再筛"必然得到同一个答案;
/// 非唯一索引要等 B+ 树支持重复键(记在项目计划的 P2-② 里)。
/// 建索引:要求这一列取值唯一
pub fn build_index(
&mut self,
column: &str,
@@ -249,18 +231,17 @@ impl HeapTable {
));
}
// 第 1 步:扫全表,查重并攒下 (键, 位置)
let mut seen: HashSet<i64> = HashSet::new();
let mut entries: Vec<(i64, u64)> = Vec::new();
for (page_id, slot, r) in self.scan_with_positions()? {
let Some(k) = row_int(&r, column) else {
continue; // 这一列不是整数的行不进索引
continue;
};
if !seen.insert(k) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"column \"{}\" has duplicate value {} — 暂不支持非唯一索引",
"column \"{}\" has duplicate value {} -- non-unique indexes are not supported yet",
column, k
),
));
@@ -268,10 +249,9 @@ impl HeapTable {
entries.push((k, pack_position(page_id, slot)));
}
// 第 2 步:建树
self.attach_index(column, path, page_size, btree_order, pool_size)?;
let i = self.indexes.len() - 1;
self.indexes[i].tree.clear()?; // 清掉可能残留的旧文件内容
self.indexes[i].tree.clear()?;
for (k, pos) in entries {
self.indexes[i].tree.insert(k, pos)?;
}
@@ -289,19 +269,17 @@ impl HeapTable {
}
}
/// 插入一行:进堆表,并按每个索引的列顺手写索引
/// 插入一行,并顺手写各索引
pub fn insert_row(&mut self, row: &Row) -> io::Result<()> {
self.insert_row_returning_position(row).map(|_| ())
}
/// 插入一行并返回它的位置
pub fn insert_row_returning_position(&mut self, row: &Row) -> io::Result<(PageId, u16)> {
// 先把键算好并查重:**查重必须在落堆之前**,
// 否则某个索引写不进去时行已经进堆了,表和索引就不一致了。
let mut pending: Vec<(usize, i64)> = Vec::new();
for (i, idx) in self.indexes.iter_mut().enumerate() {
let Some(k) = row_int(row, &idx.column) else {
continue; // 这一列不是整数(或没这一列):这行不进这个索引
continue;
};
if idx.tree.get(k)?.is_some() {
return Err(io::Error::new(
@@ -320,7 +298,7 @@ impl HeapTable {
Ok((page_id, slot))
}
/// 批量插入(只写缓冲池,落盘由调用方 `flush` 一次完成)
/// 批量插入,落盘由调用方负责
pub fn insert_rows(&mut self, rows: &[Row]) -> io::Result<()> {
for r in rows {
self.insert_row(r)?;
@@ -387,9 +365,8 @@ impl HeapTable {
}
}
/// 按位置删一行:槽位长度置 0,从所有索引里摘掉,并把这一行交出来
/// 按位置删一行并返回它
pub fn delete_at(&mut self, page_id: PageId, slot: u16) -> io::Result<Option<Row>> {
// 先读出来:删完就不知道这行各列是什么,索引也就无从摘起
let row = self.read_at(page_id, slot)?;
let existed = self.file.update_page(page_id, |p| HeapPage::delete_tuple(p, slot))?;
if !existed {
@@ -405,9 +382,7 @@ impl HeapTable {
Ok(row)
}
/// 按 `id` 批量删行,返回被删掉的那些行(数据字典要用它更新统计)。
///
/// 定位靠 `id` 索引;没有这个索引就退化成一趟全表扫描(仍然只删目标行)。
/// 按 id 批量删行,返回删掉的行
pub fn delete_by_keys(&mut self, keys: &[i64]) -> io::Result<Vec<Row>> {
let mut positions: Vec<(PageId, u16)> = Vec::new();
@@ -500,7 +475,6 @@ impl HeapTable {
self.file.flush()
}
// 缓冲池统计
/// 当前缓存页数
pub fn cached_pages(&self) -> usize { self.file.cached_pages() }
/// 命中次数
-19
View File
@@ -5,19 +5,12 @@ use std::sync::OnceLock;
// 极简日志:等级用原子变量,关掉的等级只花一次原子读。
// 颜色
/// 转义序列收尾
const RESET: &str = "\u{1b}[0m";
/// 加粗红(ERROR)
const BOLD_RED: &str = "\u{1b}[1m\u{1b}[31m";
/// 加粗橙(WARN)
const BOLD_ORANGE: &str = "\u{1b}[1m\u{1b}[38;5;208m";
/// 只加粗(DEBUG)
const BOLD: &str = "\u{1b}[1m";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
// 等级与来源
/// 日志等级
pub enum Level {
Off,
Error,
@@ -27,7 +20,6 @@ pub enum Level {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// 来源:服务自身或管道请求
pub enum Channel {
Core,
Pipe,
@@ -92,7 +84,6 @@ static LEVEL: AtomicU8 = AtomicU8::new(Level::Info as u8);
static COLOR: OnceLock<bool> = OnceLock::new();
// 写入
/// 设置全局等级
pub fn set_level(level: Level) {
LEVEL.store(level as u8, Ordering::Relaxed);
@@ -140,8 +131,6 @@ fn render(level: Level, channel: Channel, color: bool, stamp: &Stamp, msg: &str)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
// 时间
/// 本地日期时间
pub struct Stamp {
pub year: u16,
pub month: u16,
@@ -152,7 +141,6 @@ pub struct Stamp {
pub milli: u16,
}
/// 格式:年-月-日 时:分:秒.毫秒
impl fmt::Display for Stamp {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
@@ -238,7 +226,6 @@ pub fn civil_from_days(days: i64) -> (i64, u16, u16) {
(y + i64::from(month <= 2), month, day)
}
// 终端
/// 打开 Windows 的 VT 处理
#[cfg(windows)]
fn enable_vt() {
@@ -265,8 +252,6 @@ fn enable_vt() {
#[cfg(not(windows))]
fn enable_vt() {}
// 宏
/// debug 日志
#[macro_export]
macro_rules! log_debug {
(core, $($arg:tt)*) => {
@@ -277,7 +262,6 @@ macro_rules! log_debug {
};
}
/// info 日志
#[macro_export]
macro_rules! log_info {
(core, $($arg:tt)*) => {
@@ -288,7 +272,6 @@ macro_rules! log_info {
};
}
/// warn 日志
#[macro_export]
macro_rules! log_warn {
(core, $($arg:tt)*) => {
@@ -299,7 +282,6 @@ macro_rules! log_warn {
};
}
/// error 日志
#[macro_export]
macro_rules! log_error {
(core, $($arg:tt)*) => {
@@ -310,7 +292,6 @@ macro_rules! log_error {
};
}
/// 四个宏的公共部分
#[doc(hidden)]
#[macro_export]
macro_rules! log_line {
+1 -8
View File
@@ -5,15 +5,11 @@ use std::path::Path;
// 页管理:定长页的读写,外加一层页缓冲池。
// 页
/// 页号:从 0 开始
pub type PageId = u64;
/// 缓冲池默认容量(页数)
pub const DEFAULT_POOL_SIZE: usize = 64;
#[derive(Debug, Clone)]
/// 一页:定长字节 + 页号
pub struct Page {
pub id: PageId,
pub data: Vec<u8>,
@@ -35,7 +31,6 @@ impl Page {
}
#[derive(Debug)]
/// 页文件:定长页读写 + 页缓存
pub struct PageFile {
file: File,
page_size: usize,
@@ -84,7 +79,6 @@ impl PageFile {
self.page_size
}
// 缓冲池
/// 池容量(页数)
pub fn capacity(&self) -> usize {
self.capacity
@@ -188,7 +182,7 @@ impl PageFile {
Ok(r)
}
/// 记一次访问:命中加 hits,未命中读进来并记 misses
/// 记一次访问:命中 / 未命中都记数
fn note_access(&mut self, id: PageId) -> io::Result<()> {
if self.cache.contains_key(&id) {
self.hits += 1;
@@ -287,7 +281,6 @@ impl PageFile {
}
}
/// 退出前把脏页写回
impl Drop for PageFile {
fn drop(&mut self) {
let _ = self.flush();
+4 -15
View File
@@ -3,12 +3,10 @@ use std::collections::HashMap;
// 协议:Haskell 与 Rust 之间的请求 / 响应类型。
/// 一行:列名到 JSON 值
pub type Row = HashMap<String, serde_json::Value>;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
/// 列类型(对应 Haskell 的 TInt / TStr / TBool)
pub enum ColumnType {
Int,
Str,
@@ -16,52 +14,43 @@ pub enum ColumnType {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
/// 一列的 schema
pub struct SchemaColumn {
pub name: String,
pub ty: ColumnType,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
/// 一列的统计(随数据字典一起走)
pub struct ColumnStatWire {
pub name: String,
/// 不同值个数;`capped` 为真时它只是下界
pub distinct: u64,
#[serde(default)]
pub capped: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
/// 一条索引定义
pub struct IndexWire {
pub column: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "method", rename_all = "snake_case")]
/// 请求:method 字段决定类型
pub enum Request {
Ping,
Scan {
table: String,
},
/// 插一行;要不要写索引由表上的索引决定(调用方不用管)
Insert {
table: String,
row: Row,
},
/// 一批行一起插:一次 WAL、一次 fsync
InsertBatch {
table: String,
rows: Vec<Row>,
},
/// 按 id 批量删行
DeleteKeys {
table: String,
keys: Vec<i64>,
},
/// 按某一列的索引取一行(列名默认 "id",兼容老客户端)
LookupByIndex {
table: String,
#[serde(default = "default_id_column")]
@@ -84,16 +73,18 @@ pub enum Request {
table: String,
},
ListCatalog,
/// 给某一列建索引(要求这一列整数取值唯一)
CreateIndex {
table: String,
column: String,
},
/// 去掉某一列的索引
DropIndex {
table: String,
column: String,
},
DropColumn {
table: String,
column: String,
},
}
/// 没写 column 时按 `id` 算
@@ -103,7 +94,6 @@ fn default_id_column() -> String {
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
/// 响应:status 字段决定类型
pub enum Response {
Pong,
Rows { rows: Vec<Row> },
@@ -118,7 +108,6 @@ pub enum Response {
#[serde(default)]
stats: Vec<ColumnStatWire>,
},
/// 这一列没有索引 —— 调用方应该退回全表扫描
NoIndex,
Ok,
Error { message: String },
+31 -14
View File
@@ -1,4 +1,3 @@
use std::fs::{File, OpenOptions};
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
@@ -14,20 +13,18 @@ const OP_INSERT: u8 = 1;
const OP_REPLACE_ALL: u8 = 2;
const OP_INSERT_BATCH: u8 = 3;
const OP_DELETE_KEYS: u8 = 4;
const OP_DROP_COLUMN: u8 = 5;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
/// 一条待写入的操作
pub enum WalOp {
Insert {
table: String,
row: Row,
},
/// 一批行:一次日志、一次 fsync 就能全部落盘
InsertBatch {
table: String,
rows: Vec<Row>,
},
/// 按 id 批量删行(重放是幂等的:再删一次等于什么都没删)
DeleteKeys {
table: String,
keys: Vec<i64>,
@@ -36,20 +33,25 @@ pub enum WalOp {
table: String,
rows: Vec<Row>,
},
DropColumn {
table: String,
column: String,
rows: Vec<Row>,
},
}
#[derive(Debug, Serialize, Deserialize)]
struct DropColumnPayload {
column: String,
rows: Vec<Row>,
}
/// WAL 文件
///
/// 句柄只开一次并一直留着:写入路径是每条操作一次
/// `truncate -> append -> truncate`,每次都重新 open/close 文件要多花
/// 一倍的时间(见 benchmark/chusql-storage)。
pub struct Wal {
path: PathBuf,
handle: Mutex<Option<File>>,
}
impl Wal {
// 读写
/// 绑定 WAL 文件路径
pub fn new<P: AsRef<Path>>(path: P) -> Self {
Wal {
@@ -87,8 +89,7 @@ impl Wal {
})
}
/// 只截断、不落盘:紧跟其后的 `append` 会 fsync,
/// 那一次会把截断一起落下去,所以这里不必单独再刷一遍。
/// 只截断,落盘交给紧随的 append
pub fn truncate(&self) -> io::Result<()> {
self.with_handle(truncate)
}
@@ -123,14 +124,13 @@ impl Wal {
}
}
/// 清空文件并回到起点(句柄不是 append 模式,位置要自己摆正)
/// 清空文件并把读写位置归零
fn truncate(f: &mut File) -> io::Result<()> {
f.set_len(0)?;
f.seek(SeekFrom::Start(0))?;
Ok(())
}
// 编解码
/// 把操作编成字节
fn encode(op: &WalOp) -> io::Result<Vec<u8>> {
let (op_type, table, payload) = match op {
@@ -150,6 +150,14 @@ fn encode(op: &WalOp) -> io::Result<Vec<u8>> {
let p = serde_json::to_vec(rows).map_err(io::Error::other)?;
(OP_REPLACE_ALL, table.clone(), p)
}
WalOp::DropColumn { table, column, rows } => {
let p = serde_json::to_vec(&DropColumnPayload {
column: column.clone(),
rows: rows.clone(),
})
.map_err(io::Error::other)?;
(OP_DROP_COLUMN, table.clone(), p)
}
};
let table_bytes = table.as_bytes();
@@ -214,6 +222,15 @@ fn decode(buf: &[u8]) -> Result<Option<WalOp>, String> {
.map_err(|e| format!("bad replace_all payload: {}", e))?;
WalOp::ReplaceAll { table, rows }
}
OP_DROP_COLUMN => {
let p: DropColumnPayload = serde_json::from_slice(payload)
.map_err(|e| format!("bad drop_column payload: {}", e))?;
WalOp::DropColumn {
table,
column: p.column,
rows: p.rows,
}
}
other => return Err(format!("unknown op type: {}", other)),
};
Ok(Some(op))
+2 -6
View File
@@ -169,21 +169,19 @@ fn delete_removes_only_that_key() {
t.insert(i, i as u64 + 1).unwrap();
}
assert!(t.delete(50).unwrap(), "删一个存在的键应该返回 true");
assert!(t.delete(50).unwrap(), "deleting an existing key should return true");
assert_eq!(t.get(50).unwrap(), None);
assert_eq!(t.get(49).unwrap(), Some(50));
assert_eq!(t.get(51).unwrap(), Some(52));
// 再删一次:它已经不在了
assert!(!t.delete(50).unwrap());
// 遍历里也不能再有它
let all = t.iter_all().unwrap();
assert_eq!(all.len(), 199);
assert!(all.iter().all(|(k, _)| *k != 50));
}
/// 删过之后树还能继续插(惰性删除会留下稀疏节点,但不能影响正确性)
/// 删过之后树还能继续插
#[test]
fn insert_after_delete_still_works() {
let dir = tempfile::tempdir().unwrap();
@@ -197,11 +195,9 @@ fn insert_after_delete_still_works() {
for i in 0..300 {
t.insert(i, i as u64).unwrap();
}
// 把偶数键全删掉
for i in (0..300).step_by(2) {
assert!(t.delete(i).unwrap());
}
// 再插一批新的
for i in 1000..1100 {
t.insert(i, i as u64).unwrap();
}
+25
View File
@@ -65,3 +65,28 @@ fn describe_unknown_returns_none() {
let c = Catalog::load(&path).unwrap();
assert!(c.describe("nope").is_none());
}
/// 删列同时清掉统计与索引
#[test]
fn remove_column_drops_definition_stats_and_index() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("catalog.json");
let mut c = Catalog::load(&path).unwrap();
c.ensure_columns("t", &row(&[("id", json!(1)), ("code", json!(5))]));
c.add_index("t", "code").unwrap();
c.rebuild_stats("t", &[row(&[("id", json!(1)), ("code", json!(5))])]);
let removed = c.remove_column("t", "code").unwrap();
assert!(removed, "first remove should hit the column");
let s = c.describe("t").unwrap();
assert_eq!(s.columns.len(), 1);
assert_eq!(s.columns[0].name, "id");
assert!(s.indexes.is_empty(), "index on code should be gone");
assert!(s.stats.get("code").is_none(), "stats for code should be gone");
let again = c.remove_column("t", "code").unwrap();
assert!(!again);
assert!(c.remove_column("nope", "code").is_err());
}
+8 -13
View File
@@ -135,9 +135,9 @@ fn replace_all_rebuilds_index() {
])
.unwrap();
assert!(t.get_by_key(1).unwrap().is_none(), "旧键必须作废");
assert!(t.get_by_key(1).unwrap().is_none(), "old key must be invalidated");
assert!(t.get_by_key(2).unwrap().is_none());
let got = t.get_by_key(7).unwrap().expect("新行应该按 id 建好索引");
let got = t.get_by_key(7).unwrap().expect("new row should be indexed by id");
assert_eq!(got["name"], json!("Zoe"));
assert_eq!(t.scan().unwrap().len(), 2);
}
@@ -161,7 +161,7 @@ fn delete_by_keys_removes_rows() {
}
let deleted = t.delete_by_keys(&[2, 4, 99]).unwrap();
assert_eq!(deleted.len(), 2, "只有 2、4 真的删掉了");
assert_eq!(deleted.len(), 2, "only 2 and 4 were actually deleted");
let ids: Vec<i64> = t
.scan()
@@ -169,13 +169,12 @@ fn delete_by_keys_removes_rows() {
.iter()
.map(|r| r["id"].as_i64().unwrap())
.collect();
assert_eq!(ids, vec![1, 3, 5], "剩下的行要保持原顺序");
assert_eq!(ids, vec![1, 3, 5], "remaining rows must keep their order");
assert!(t.get_by_key(2).unwrap().is_none(), "索引条目要跟着删");
assert!(t.get_by_key(2).unwrap().is_none(), "index entry should be deleted too");
assert!(t.get_by_key(4).unwrap().is_none());
assert!(t.get_by_key(3).unwrap().is_some());
// 删掉的位置可以再插回来
t.insert_row(&row(&[("id", json!(2)), ("name", json!("u2b"))]))
.unwrap();
assert_eq!(t.get_by_key(2).unwrap().unwrap()["name"], json!("u2b"));
@@ -202,7 +201,6 @@ fn insert_rows_batch() {
assert_eq!(t.scan().unwrap().len(), 20);
assert_eq!(t.get_by_key(20).unwrap().unwrap()["name"], json!("u20"));
// 批里出现重复键要报错(整批之前就查出来,不会插一半)
let dup = vec![
row(&[("id", json!(100))]),
row(&[("id", json!(100))]),
@@ -241,16 +239,13 @@ fn secondary_index_is_maintained() {
assert!(t.has_index("code"));
assert_eq!(t.get_by_column_key("code", 7002).unwrap().unwrap()["id"], json!(2));
// 建索引之后新插入的行也进索引
t.insert_row(&row(&[("id", json!(3)), ("code", json!(7003))]))
.unwrap();
assert_eq!(t.get_by_column_key("code", 7003).unwrap().unwrap()["id"], json!(3));
// 删掉一行,索引条目跟着走
t.delete_by_keys(&[2]).unwrap();
assert!(t.get_by_column_key("code", 7002).unwrap().is_none());
// code 已经有索引,重复的 code 插不进去(索引列是唯一的)
assert!(t.insert_row(&row(&[("id", json!(4)), ("code", json!(7001))])).is_err());
let mut t2 = HeapTable::open(&dir.path().join("d.db"), DEFAULT_PAGE_SIZE, DEFAULT_POOL_SIZE).unwrap();
t2.insert_rows(&[
@@ -265,8 +260,8 @@ fn secondary_index_is_maintained() {
chusql_storage::config::DEFAULT_BTREE_ORDER,
DEFAULT_POOL_SIZE,
);
assert!(err.is_err(), "重复值不给建索引");
assert!(!t2.has_index("age"), "失败之后不留半成品");
assert!(err.is_err(), "duplicate values must not be indexed");
assert!(!t2.has_index("age"), "no half-built index after failure");
}
/// 同一个 id 插两次要报错
@@ -285,6 +280,6 @@ fn duplicate_key_is_rejected() {
t.insert_row(&row(&[("id", json!(1))])).unwrap();
let r = t.insert_row(&row(&[("id", json!(1))]));
assert!(r.is_err());
assert_eq!(t.scan().unwrap().len(), 1, "失败的那一行不应该进堆表");
assert_eq!(t.scan().unwrap().len(), 1, "the failed row should not reach the heap");
}
+52 -15
View File
@@ -21,7 +21,6 @@ fn unique_pipe_name() -> String {
format!("chusql-test-{}-{}", id, ns)
}
/// 持有 server 子进程,退出时杀掉
struct ServerProc(Child);
impl Drop for ServerProc {
@@ -36,7 +35,7 @@ impl Drop for ServerProc {
fn start_server(pipe: &str) -> (ServerProc, tempfile::TempDir) {
let data = tempfile::tempdir().unwrap();
let child = Command::new(env!("CARGO_BIN_EXE_server"))
let child = Command::new(env!("CARGO_BIN_EXE_chusql-storage"))
.env("CHUSQL_PIPE", pipe)
.env("CHUSQL_DATA_DIR", data.path())
.env("CHUSQL_PAGE_SIZE", "4096")
@@ -251,7 +250,7 @@ fn delete_keys_removes_row_and_index_entry() {
assert!(r.contains(r#""rows":[]"#), "index entry should be gone: {}", r);
let r = send(&mut c, r#"{"method":"insert","table":"del_t","row":{"id":2,"name":"Bob2"}}"#);
assert!(r.contains(r#""status":"ok""#), "id 2 应该能再插进来: {}", r);
assert!(r.contains(r#""status":"ok""#), "id 2 should be insertable again: {}", r);
}
/// 给第二列建索引后能按那一列查
@@ -269,7 +268,6 @@ fn create_index_on_secondary_column() {
send(&mut c, &req);
}
// 没建之前:明确回 no_index,而不是回空结果
let r = send(&mut c, r#"{"method":"lookup_by_index","table":"sec_t","column":"code","key":5002}"#);
assert!(r.contains(r#""status":"no_index""#), "got: {}", r);
@@ -279,23 +277,64 @@ fn create_index_on_secondary_column() {
let r = send(&mut c, r#"{"method":"lookup_by_index","table":"sec_t","column":"code","key":5002}"#);
assert!(r.contains(r#""id":2"#), "lookup by code: {}", r);
// 建完索引之后新插入的行也要进索引
send(&mut c, r#"{"method":"insert","table":"sec_t","row":{"id":4,"code":5004}}"#);
let r = send(&mut c, r#"{"method":"lookup_by_index","table":"sec_t","column":"code","key":5004}"#);
assert!(r.contains(r#""id":4"#), "lookup new row: {}", r);
// schema 里能看到这条索引
let r = send(&mut c, r#"{"method":"describe_table","table":"sec_t"}"#);
assert!(r.contains(r#""column":"code""#), "describe: {}", r);
// 删掉索引后回到 no_index
let r = send(&mut c, r#"{"method":"drop_index","table":"sec_t","column":"code"}"#);
assert!(r.contains(r#""status":"ok""#), "drop_index: {}", r);
let r = send(&mut c, r#"{"method":"lookup_by_index","table":"sec_t","column":"code","key":5002}"#);
assert!(r.contains(r#""status":"no_index""#), "after drop: {}", r);
}
/// 有重复值的列不给建索引(不然"走索引"和"全表扫"会给出不同答案)
/// 删列同时摘掉索引与行内该格
#[test]
fn drop_column_removes_column_index_and_values() {
let pipe = unique_pipe_name();
let (_srv, _data) = start_server(&pipe);
let mut c = connect(&pipe).unwrap();
for (id, code, note) in [(1, 5001, "a"), (2, 5002, "b")] {
let req = format!(
r#"{{"method":"insert","table":"drop_t","row":{{"id":{},"code":{},"note":"{}"}}}}"#,
id, code, note
);
send(&mut c, &req);
}
let r = send(&mut c, r#"{"method":"create_index","table":"drop_t","column":"code"}"#);
assert!(r.contains(r#""status":"ok""#), "create_index: {}", r);
let r = send(&mut c, r#"{"method":"drop_column","table":"drop_t","column":"code"}"#);
assert!(r.contains(r#""status":"ok""#), "drop_column: {}", r);
let r = send(&mut c, r#"{"method":"describe_table","table":"drop_t"}"#);
assert!(!r.contains(r#""name":"code""#), "code column should be gone: {}", r);
assert!(r.contains(r#""name":"note""#), "note column should still be there: {}", r);
assert!(!r.contains(r#""column":"code""#), "index on code should be gone: {}", r);
let r = send(&mut c, r#"{"method":"scan","table":"drop_t"}"#);
assert!(r.contains(r#""note":"a""#), "row should still be there: {}", r);
assert!(!r.contains(r#""code""#), "row should no longer carry code: {}", r);
let r = send(
&mut c,
r#"{"method":"lookup_by_index","table":"drop_t","column":"code","key":5001}"#,
);
assert!(r.contains(r#""status":"no_index""#), "{}", r);
let r = send(&mut c, r#"{"method":"describe_table","table":"drop_t"}"#);
assert!(r.contains(r#""row_count":2"#), "row count should still be 2: {}", r);
let r = send(&mut c, r#"{"method":"drop_column","table":"drop_t","column":"id"}"#);
assert!(r.contains(r#""status":"error""#), "the built-in id column must not be dropped: {}", r);
let r = send(&mut c, r#"{"method":"drop_column","table":"drop_t","column":"nope"}"#);
assert!(r.contains(r#""status":"error""#), "a missing column should error: {}", r);
}
/// 重复值列不给建索引
#[test]
fn create_index_rejects_duplicate_values() {
let pipe = unique_pipe_name();
@@ -309,7 +348,6 @@ fn create_index_rejects_duplicate_values() {
assert!(r.contains(r#""status":"error""#), "got: {}", r);
assert!(r.contains("duplicate value"), "got: {}", r);
// 失败之后不留半成品:查这一列仍然是 no_index
let r = send(&mut c, r#"{"method":"lookup_by_index","table":"dup_t","column":"age","key":30}"#);
assert!(r.contains(r#""status":"no_index""#), "got: {}", r);
}
@@ -331,10 +369,9 @@ fn stats_report_distinct_values() {
let r = send(&mut c, r#"{"method":"describe_table","table":"stat_t"}"#);
assert!(r.contains(r#""row_count":3"#), "row_count: {}", r);
assert!(r.contains(r#""name":"age","distinct":2"#), "age 有 2 个不同值: {}", r);
assert!(r.contains(r#""name":"id","distinct":3"#), "id 有 3 个不同值: {}", r);
assert!(r.contains(r#""name":"age","distinct":2"#), "age has 2 distinct values: {}", r);
assert!(r.contains(r#""name":"id","distinct":3"#), "id has 3 distinct values: {}", r);
// 删掉一行,统计跟着掉
send(&mut c, r#"{"method":"delete_keys","table":"stat_t","keys":[1]}"#);
let r = send(&mut c, r#"{"method":"describe_table","table":"stat_t"}"#);
assert!(r.contains(r#""row_count":2"#), "after delete: {}", r);
@@ -348,7 +385,7 @@ fn lookup_survives_reopen() {
let data = tempfile::tempdir().unwrap();
{
let exe = env!("CARGO_BIN_EXE_server");
let exe = env!("CARGO_BIN_EXE_chusql-storage");
let child = Command::new(exe)
.env("CHUSQL_PIPE", &pipe)
.env("CHUSQL_DATA_DIR", data.path())
@@ -375,7 +412,7 @@ fn lookup_survives_reopen() {
thread::sleep(Duration::from_millis(200));
let (_srv, _data) = {
let child = Command::new(env!("CARGO_BIN_EXE_server"))
let child = Command::new(env!("CARGO_BIN_EXE_chusql-storage"))
.env("CHUSQL_PIPE", &pipe)
.env("CHUSQL_DATA_DIR", data.path())
.stdout(Stdio::null())
@@ -410,7 +447,7 @@ fn config_file_is_used() {
);
std::fs::write(&config_path, text).unwrap();
let child = Command::new(env!("CARGO_BIN_EXE_server"))
let child = Command::new(env!("CARGO_BIN_EXE_chusql-storage"))
.env("CHUSQL_CONFIG", &config_path)
.stdout(Stdio::null())
.stderr(Stdio::null())
+20
View File
@@ -48,6 +48,26 @@ fn append_and_read_replace_all() {
assert_eq!(back, op);
}
/// 写读 DropColumn 操作,行随日志落盘
#[test]
fn append_and_read_drop_column() {
let dir = tempfile::tempdir().unwrap();
let wal = Wal::new(dir.path().join("wal.log"));
let op = WalOp::DropColumn {
table: "users".into(),
column: "age".into(),
rows: vec![
row(&[("id", json!(1)), ("name", json!("Alice"))]),
row(&[("id", json!(2)), ("name", json!("Bob"))]),
],
};
wal.append(&op).unwrap();
let back = wal.read().unwrap().unwrap();
assert_eq!(back, op);
}
/// 文件不存在返回 None
#[test]
fn missing_file_returns_none() {
View File
Whitespace-only changes.
+344
View File
@@ -0,0 +1,344 @@
{-# LANGUAGE OverloadedStrings #-}
module Main (main) where
import ChuSQL.Web.Api (AppEnv (..), Live (..), newAppEnv, setLive, webApp)
import ChuSQL.Web.Auth (SessionPolicy (..), newSessionStore)
import ChuSQL.Web.Backend (Backend, ipcBackend)
import ChuSQL.Web.Demo (SeedReport (..), seedDemo)
import ChuSQL.Web.Config (
WebConfig (..),
defaultPassword,
defaultUser,
loadWebConfig,
resolveCredential,
resolveStaticDir,
staticDirCandidates,
usingDefaultCredentials,
)
import ChuSQL.Web.RateLimit (newRateLimiter)
import ChuSQL.Web.Settings (effectiveSettings)
import ChuSQL.Web.StorageProcess (
StorageProcess (..),
findStorageServer,
startStorageProcess,
stopStorageProcess,
waitForStorage,
)
import Control.Exception (IOException, bracket, try)
import Data.Char (toLower)
import Data.String (fromString)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import Data.Time.Clock (getCurrentTime)
import Network.Wai (Application)
import qualified Network.Wai.Handler.Warp as Warp
import System.Environment (getArgs)
import System.Exit (exitFailure)
import System.IO (BufferMode (LineBuffering), hSetBuffering, stdout)
import Text.Read (readMaybe)
-- Web 管理端入口:读配置、备凭据、拉起存储进程,最后起 Warp。
data Options = Options
{ optHost :: Maybe String
, optPort :: Maybe Int
, optStatic :: Maybe FilePath
, optStorage :: Maybe FilePath
, optUser :: Maybe Text
, optCookieSecure :: Maybe Bool
, optBodyLimit :: Maybe Int
, optSessionIdle :: Maybe Int
, optSessionMax :: Maybe Int
, optLoginMaxAttempts :: Maybe Int
, optLoginWindow :: Maybe Int
, optPageSize :: Maybe Int
, optMaxPageSize :: Maybe Int
, optMaxRows :: Maybe Int
, optMaxSqlLength :: Maybe Int
, optSeedDemo :: Maybe Bool
, optHelp :: Bool
}
-- | 一个开关都没给
emptyOptions :: Options
emptyOptions =
Options
{ optHost = Nothing
, optPort = Nothing
, optStatic = Nothing
, optStorage = Nothing
, optUser = Nothing
, optCookieSecure = Nothing
, optBodyLimit = Nothing
, optSessionIdle = Nothing
, optSessionMax = Nothing
, optLoginMaxAttempts = Nothing
, optLoginWindow = Nothing
, optPageSize = Nothing
, optMaxPageSize = Nothing
, optMaxRows = Nothing
, optMaxSqlLength = Nothing
, optSeedDemo = Nothing
, optHelp = False
}
-- | 解析参数
parseArgs :: [String] -> Either String Options
parseArgs = go emptyOptions
where
go opts [] = Right opts
go opts ("--help" : rest) = go opts{optHelp = True} rest
go opts ("-h" : rest) = go opts{optHelp = True} rest
go opts ("--host" : v : rest) = go opts{optHost = Just v} rest
go opts ("--static" : v : rest) = go opts{optStatic = Just v} rest
go opts ("--storage" : v : rest) = go opts{optStorage = Just v} rest
go opts ("--user" : v : rest) = go opts{optUser = Just (T.pack v)} rest
go opts ("--port" : v : rest) = withInt "--port" v (\n -> go opts{optPort = Just n} rest)
go opts ("--body-limit" : v : rest) = withInt "--body-limit" v (\n -> go opts{optBodyLimit = Just n} rest)
go opts ("--session-idle" : v : rest) = withInt "--session-idle" v (\n -> go opts{optSessionIdle = Just n} rest)
go opts ("--session-max" : v : rest) = withInt "--session-max" v (\n -> go opts{optSessionMax = Just n} rest)
go opts ("--login-max-attempts" : v : rest) = withInt "--login-max-attempts" v (\n -> go opts{optLoginMaxAttempts = Just n} rest)
go opts ("--login-window" : v : rest) = withInt "--login-window" v (\n -> go opts{optLoginWindow = Just n} rest)
go opts ("--page-size" : v : rest) = withInt "--page-size" v (\n -> go opts{optPageSize = Just n} rest)
go opts ("--max-page-size" : v : rest) = withInt "--max-page-size" v (\n -> go opts{optMaxPageSize = Just n} rest)
go opts ("--max-rows" : v : rest) = withInt "--max-rows" v (\n -> go opts{optMaxRows = Just n} rest)
go opts ("--max-sql-length" : v : rest) = withInt "--max-sql-length" v (\n -> go opts{optMaxSqlLength = Just n} rest)
go opts ("--seed" : v : rest) = go opts{optSeedDemo = Just (isTrue v)} rest
go opts ("--cookie-secure" : v : rest) = go opts{optCookieSecure = Just (isTrue v)} rest
go _ (a : _) = Left ("unrecognized argument or missing value: " ++ a)
-- | 读一个整数开关
withInt :: String -> String -> (Int -> Either String Options) -> Either String Options
withInt name v k = maybe (Left (name ++ " needs an integer, got: " ++ v)) k (readMaybe v)
-- | 布尔开关认 1/true/yes/on
isTrue :: String -> Bool
isTrue s = map toLower s `elem` ["1", "true", "yes", "on"]
-- | 帮助
usage :: IO ()
usage = do
putStrLn "usage: chusql-web [options]"
putStrLn ""
putStrLn " --host H listen address (CHUSQL_WEB_HOST, default 127.0.0.1)"
putStrLn " --port N listen port (CHUSQL_WEB_PORT, default 7777)"
putStrLn " --static DIR static asset directory (CHUSQL_WEB_STATIC, default static)"
putStrLn " --user NAME account name (CHUSQL_WEB_USER, default admin)"
putStrLn " --storage SERVER Rust storage exe to spawn (CHUSQL_STORAGE_SERVER)"
putStrLn " --cookie-secure BOOL add Secure to the cookie (CHUSQL_WEB_COOKIE_SECURE, default false)"
putStrLn " --body-limit N max request body bytes (CHUSQL_WEB_BODY_LIMIT, default 65536)"
putStrLn " --session-idle N session idle timeout, sec (CHUSQL_WEB_SESSION_IDLE, default 28800)"
putStrLn " --session-max N session max age, sec (CHUSQL_WEB_SESSION_MAX, default 86400)"
putStrLn " --login-max-attempts N failed logins before 429 (CHUSQL_WEB_LOGIN_MAX_ATTEMPTS, default 5)"
putStrLn " --login-window N lockout window, sec (CHUSQL_WEB_LOGIN_WINDOW, default 300)"
putStrLn " --page-size N rows per page by default (CHUSQL_WEB_PAGE_SIZE, default 25)"
putStrLn " --max-page-size N rows per page ceiling (CHUSQL_WEB_MAX_PAGE_SIZE, default 500)"
putStrLn " --max-rows N rows returned per query (CHUSQL_WEB_MAX_ROWS, default 1000)"
putStrLn " --max-sql-length N max SQL characters (CHUSQL_WEB_MAX_SQL_LENGTH, default 20000)"
putStrLn " --seed BOOL load demo data when the db is empty (CHUSQL_WEB_SEED, default false)"
putStrLn ""
putStrLn "password: CHUSQL_WEB_PASSWORD_HASH > CHUSQL_WEB_PASSWORD > built-in demo password"
putStrLn ("without any of them the demo account is " ++ T.unpack defaultUser ++ " / " ++ T.unpack defaultPassword)
putStrLn "storage knobs for the spawned process: CHUSQL_PAGE_SIZE / CHUSQL_BTREE_ORDER /"
putStrLn " CHUSQL_BUFFER_POOL_SIZE / CHUSQL_LOG / CHUSQL_PIPE / CHUSQL_DATA_DIR"
-- | 命令行覆盖配置
applyOptions :: WebConfig -> Options -> WebConfig
applyOptions cfg opts =
cfg
{ wcHost = orElse (optHost opts) (wcHost cfg)
, wcPort = orElse (optPort opts) (wcPort cfg)
, wcStaticDir = orElse (optStatic opts) (wcStaticDir cfg)
, wcStorageServer = case optStorage opts of
Just p -> Just p
Nothing -> wcStorageServer cfg
, wcUser = orElse (optUser opts) (wcUser cfg)
, wcCookieSecure = orElse (optCookieSecure opts) (wcCookieSecure cfg)
, wcBodyLimit = orElse (optBodyLimit opts) (wcBodyLimit cfg)
, wcSessionIdle = orElse (optSessionIdle opts) (wcSessionIdle cfg)
, wcSessionMax = orElse (optSessionMax opts) (wcSessionMax cfg)
, wcLoginMaxAttempts = orElse (optLoginMaxAttempts opts) (wcLoginMaxAttempts cfg)
, wcLoginWindow = orElse (optLoginWindow opts) (wcLoginWindow cfg)
, wcPageSize = orElse (optPageSize opts) (wcPageSize cfg)
, wcMaxPageSize = orElse (optMaxPageSize opts) (wcMaxPageSize cfg)
, wcMaxRows = orElse (optMaxRows opts) (wcMaxRows cfg)
, wcMaxSqlLength = orElse (optMaxSqlLength opts) (wcMaxSqlLength cfg)
, wcSeedDemo = orElse (optSeedDemo opts) (wcSeedDemo cfg)
}
where
-- | 开关给了用开关的,否则保留原值
orElse :: Maybe a -> a -> a
orElse (Just x) _ = x
orElse Nothing y = y
-- | 配了存储进程路径就拉起来,没配就假设外面已经跑着
startStorageIfWanted :: WebConfig -> IO (Either String (Maybe StorageProcess))
startStorageIfWanted cfg = case wcStorageServer cfg of
Nothing -> pure (Right Nothing)
Just given -> do
found <- findStorageServer (Just given)
case found of
Left e -> pure (Left e)
Right bin -> fmap (fmap Just) (startStorageProcess bin)
-- | 入口
main :: IO ()
main = do
hSetBuffering stdout LineBuffering
args <- getArgs
case parseArgs args of
Left err -> do
putStrLn ("bad arguments: " ++ err)
usage
exitFailure
Right opts
| optHelp opts -> usage
| otherwise -> run opts
-- | 起服务
run :: Options -> IO ()
run opts = do
base <- loadWebConfig
let cfg = applyOptions base opts
foundStatic <- resolveStaticDir (wcStaticDir cfg)
case foundStatic of
Nothing -> do
putStrLn ("static directory not found; looked in: " ++ unwords (staticDirCandidates (wcStaticDir cfg)))
exitFailure
Just staticDir -> do
cred <- resolveCredential cfg
storage <- startStorageIfWanted cfg
case storage of
Left err -> do
putStrLn err
exitFailure
Right maybeStorage -> do
backend <- ipcBackend
let policy =
SessionPolicy
(fromIntegral (wcSessionIdle cfg))
(fromIntegral (wcSessionMax cfg))
sessions <- newSessionStore getCurrentTime policy
limiter <-
newRateLimiter
getCurrentTime
(wcLoginMaxAttempts cfg)
(fromIntegral (wcLoginWindow cfg))
env0 <- newAppEnv backend sessions cred limiter staticDir
let env =
env0
{ aeCookieSecure = wcCookieSecure cfg
, aeEffective = effectiveSettings cfg
, aeLog = \msg -> TIO.putStrLn ("[web] " <> msg)
}
setLive env (liveFromConfig cfg)
app <- webApp env
printBanner cfg staticDir maybeStorage
reportCredential cfg
waitForStorageReport
seedIfWanted cfg backend
let settings =
Warp.setPort (wcPort cfg) (Warp.setHost (fromString (wcHost cfg)) Warp.defaultSettings)
case maybeStorage of
Nothing -> serve settings app
Just sp -> bracket (pure sp) stopStorageProcess (const (serve settings app))
-- | 本次启动的配置摊成热改参数
liveFromConfig :: WebConfig -> Live
liveFromConfig cfg =
Live
{ lvBodyLimit = wcBodyLimit cfg
, lvSessionIdle = wcSessionIdle cfg
, lvSessionMax = wcSessionMax cfg
, lvLoginMaxAttempts = wcLoginMaxAttempts cfg
, lvLoginWindow = wcLoginWindow cfg
, lvPageSize = wcPageSize cfg
, lvMaxPageSize = wcMaxPageSize cfg
, lvMaxRows = wcMaxRows cfg
, lvMaxSqlLength = wcMaxSqlLength cfg
}
-- | 起 HTTP 服务,绑不上端口给人话
serve :: Warp.Settings -> Application -> IO ()
serve settings app = do
result <- try (Warp.runSettings settings app) :: IO (Either IOException ())
case result of
Right () -> pure ()
Left e -> do
putStrLn ("failed to serve HTTP: " ++ show e)
putStrLn " the port is probably taken or reserved by the system; try another one: --port 7778"
putStrLn " on Windows: netsh interface ipv4 show excludedportrange protocol=tcp"
exitFailure
-- | 口令是从哪来的(用了内置默认口令就明确警告)
reportCredential :: WebConfig -> IO ()
reportCredential cfg
| usingDefaultCredentials cfg = do
putStrLn ""
putStrLn " !! DEMO CREDENTIALS IN USE:"
putStrLn (" !! user " ++ T.unpack (wcUser cfg) ++ " / password " ++ T.unpack defaultPassword)
putStrLn " !! set CHUSQL_WEB_PASSWORD (or CHUSQL_WEB_PASSWORD_HASH) before exposing this server."
putStrLn ""
| otherwise = putStrLn ("password: from CHUSQL_WEB_PASSWORD(_HASH) (user " ++ T.unpack (wcUser cfg) ++ ")")
-- | 配了 seed 就补一份演示数据
seedIfWanted :: WebConfig -> Backend -> IO ()
seedIfWanted cfg backend
| not (wcSeedDemo cfg) = pure ()
| otherwise = do
result <- seedDemo backend
case result of
Left e -> TIO.putStrLn ("[web] demo data skipped: " <> T.pack e)
Right report ->
TIO.putStrLn
( "[web] demo data: "
<> (if null (srCreated report) then "already present" else "created " <> T.pack (unwords (srCreated report)))
<> (if null (srSkipped report) then "" else ", kept " <> T.pack (unwords (srSkipped report)))
<> (if null (srIndexes report) then "" else ", index on " <> T.pack (unwords (srIndexes report)))
)
-- | 报一下存储进程通不通
waitForStorageReport :: IO ()
waitForStorageReport = do
up <- waitForStorage 20
putStrLn
( if up
then "storage: up (named pipe answered ping)"
else "storage: DOWN (no ping reply; start the Rust storage process or pass --storage)"
)
-- | 启动横幅
printBanner :: WebConfig -> FilePath -> Maybe StorageProcess -> IO ()
printBanner cfg staticDir maybeStorage = do
putStrLn "==================== ChuSQL Web ===================="
putStrLn ("url: http://" ++ wcHost cfg ++ ":" ++ show (wcPort cfg) ++ "/")
putStrLn ("static dir: " ++ staticDir)
putStrLn
( "limits: body "
++ show (wcBodyLimit cfg)
++ "B, page "
++ show (wcPageSize cfg)
++ "/"
++ show (wcMaxPageSize cfg)
++ " rows, query rows "
++ show (wcMaxRows cfg)
++ ", sql "
++ show (wcMaxSqlLength cfg)
++ " chars"
)
putStrLn
( "auth: session idle "
++ show (wcSessionIdle cfg)
++ "s / max "
++ show (wcSessionMax cfg)
++ "s, lockout after "
++ show (wcLoginMaxAttempts cfg)
++ " failures in "
++ show (wcLoginWindow cfg)
++ "s"
)
case maybeStorage of
Just sp -> putStrLn ("storage proc: " ++ spPipe sp ++ " data dir " ++ spDataDir sp)
Nothing -> putStrLn "storage proc: assumed to run elsewhere (pipe name from CHUSQL_PIPE)"
+87
View File
@@ -0,0 +1,87 @@
cabal-version: 2.4
name: chusql-web
version: 1.0.0
synopsis: ChuSQL Web 管理端:Scotty + REST API + 静态页面
description: ChuSQL 的浏览器管理端。负责 HTTP 交付、鉴权、静态文件,
以及把用户 SQL 转交给 chusql-engine(再由 IPC 打到 Rust 存储进程)。
build-type: Simple
library
hs-source-dirs: src
exposed-modules: ChuSQL.Web.Actions
ChuSQL.Web.Api
ChuSQL.Web.Auth
ChuSQL.Web.Backend
ChuSQL.Web.Config
ChuSQL.Web.Demo
ChuSQL.Web.RateLimit
ChuSQL.Web.Secure
ChuSQL.Web.Settings
ChuSQL.Web.Static
ChuSQL.Web.StorageProcess
ChuSQL.Web.UiSettings
build-depends: base >= 4.14 && < 5
, aeson
, bytestring
, chusql-engine
, containers
, crypton
, directory
, filepath
, http-types
, memory
, process
, scientific
, scotty
, text
, time
, vector
, wai
default-language: Haskell2010
ghc-options: -Wall
executable chusql-web
main-is: Main.hs
hs-source-dirs: app
build-depends: base
, bytestring
, chusql-engine
, chusql-web
, crypton
, directory
, filepath
, http-types
, memory
, text
, time
, wai
, warp
default-language: Haskell2010
ghc-options: -Wall
test-suite chusql-web-test
type: exitcode-stdio-1.0
main-is: Spec.hs
hs-source-dirs: test
build-depends: base
, aeson
, bytestring
, chusql-engine
, chusql-web
, containers
, directory
, filepath
, hspec
, hspec-wai
, http-client
, http-types
, process
, text
, time
, vector
, wai
, wai-extra
, warp
default-language: Haskell2010
ghc-options: -Wall
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env node
// CLI 占位入口:功能尚未实现,仅提示用户。
console.log('ChuSQL CLI is not implemented yet. Coming soon.');
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env node
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createServer } from 'vite';
import { parseArgs } from './lib/args.js';
import {
defaultConfigPath, getAtPath, parseConfigValue, readConfig, setAtPath, unsetAtPath, writeConfig,
} from './lib/config.js';
import { openBrowser } from './lib/open.js';
// ChuSQL 前端 CLI:启动开发服务器并管理本地配置。
const cyan = (text) => `\u001b[36m${text}\u001b[0m`;
const green = (text) => `\u001b[32m${text}\u001b[0m`;
const red = (text) => `\u001b[31m${text}\u001b[0m`;
const help = `Usage: chusql <command> [options]
Commands:
web Start the web server
config list List all configuration
config get <key> Get a configuration value
config set <key> <val> Set a configuration value
config unset <key> Remove a configuration value
config path Print the configuration file path
help Show this help message
Global options:
--port <n> Specify port
--host <h> Specify host
--no-open Do not open the browser automatically
--config <path> Use a custom configuration file
--adapter <mock|rest> Specify the data source adapter
--help, -h Show help
--version, -v Show version
Run "chusql <command> --help" for command-specific options.`;
function configCommand(args, configPath) {
const config = readConfig(configPath);
const [key, rawValue] = args.operands;
if (!args.action || args.action === 'list') console.log(JSON.stringify(config, null, 2));
else if (args.action === 'path') console.log(configPath);
else if (args.action === 'get') {
if (!key) throw new Error('Usage: chusql config get <key>');
const value = getAtPath(config, key);
if (value === undefined) throw new Error(`Configuration key not found: ${key}`);
console.log(typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value));
} else if (args.action === 'set') {
if (!key || rawValue === undefined) throw new Error('Usage: chusql config set <key> <value>');
setAtPath(config, key, parseConfigValue(rawValue));
writeConfig(configPath, config);
console.log(green(`Saved ${key}.`));
} else if (args.action === 'unset') {
if (!key) throw new Error('Usage: chusql config unset <key>');
if (!unsetAtPath(config, key)) throw new Error(`Configuration key not found: ${key}`);
writeConfig(configPath, config);
console.log(green(`Removed ${key}.`));
} else throw new Error(`Unknown config command: ${args.action}`);
}
async function webCommand(args, configPath) {
const stored = readConfig(configPath);
const config = {
...stored,
...(args.port === undefined ? {} : { port: args.port }),
...(args.host === undefined ? {} : { host: args.host }),
...(args.adapter === undefined ? {} : { adapter: args.adapter }),
...(args.autoOpenBrowser === undefined ? {} : { autoOpenBrowser: args.autoOpenBrowser }),
};
if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) throw new Error('Configured port must be an integer between 1 and 65535.');
if (!['mock', 'rest'].includes(config.adapter)) throw new Error('Configured adapter must be mock or rest.');
process.env.VITE_CHUSQL_ADAPTER = config.adapter;
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const server = await createServer({ root, server: { host: config.host, port: config.port, strictPort: true } });
await server.listen();
const url = `http://${config.host}:${config.port}/`;
console.log(`${cyan('➜')} ${green('ChuSQL Web is running at:')}`);
console.log(` ${url}`);
console.log('Press Ctrl+C to stop.');
if (config.autoOpenBrowser) openBrowser(url);
}
try {
const args = parseArgs(process.argv.slice(2));
const configPath = path.resolve(args.configPath ?? defaultConfigPath());
if (args.help || args.command === 'help') console.log(help);
else if (args.command === 'version') console.log('ChuSQL 1.0.0');
else if (args.command === 'config') configCommand(args, configPath);
else if (args.command === 'web') await webCommand(args, configPath);
else throw new Error(`Unknown command: ${args.command}`);
} catch (error) {
console.error(red(`Error: ${error instanceof Error ? error.message : String(error)}`));
process.exitCode = 1;
}
+54
View File
@@ -0,0 +1,54 @@
// 命令行参数解析:命令、动作、全局选项与取值校验。
const HOST_PATTERN = /^[A-Za-z0-9.:[\]-]+$/;
function valueAfter(argv, index, label) {
const value = argv[index + 1];
if (!value || value.startsWith('--')) throw new Error(`${label} requires a value.`);
return value;
}
export function parseArgs(argv) {
const result = { command: 'help', action: '', operands: [], autoOpenBrowser: undefined };
let index = 0;
if (argv[index] && !argv[index].startsWith('-')) {
result.command = argv[index];
index += 1;
} else if (argv.length) {
result.command = argv[0] === '--version' || argv[0] === '-v' ? 'version' : 'help';
}
if (result.command === 'config' && argv[index] && !argv[index].startsWith('-')) {
result.action = argv[index];
index += 1;
}
while (index < argv.length) {
const token = argv[index];
if (token === '--help' || token === '-h') result.help = true;
else if (token === '--no-open') result.autoOpenBrowser = false;
else if (token === '--port') {
const raw = valueAfter(argv, index, 'Port');
if (!/^\d+$/.test(raw) || Number(raw) < 1 || Number(raw) > 65535) throw new Error('Port must be an integer between 1 and 65535.');
result.port = Number(raw);
index += 1;
} else if (token === '--host') {
const host = valueAfter(argv, index, 'Host');
if (!HOST_PATTERN.test(host)) throw new Error('Host contains invalid characters.');
result.host = host;
index += 1;
} else if (token === '--adapter') {
const adapter = valueAfter(argv, index, 'Adapter');
if (adapter !== 'mock' && adapter !== 'rest') throw new Error('Adapter must be mock or rest.');
result.adapter = adapter;
index += 1;
} else if (token === '--config') {
result.configPath = valueAfter(argv, index, 'Config');
index += 1;
} else if (token.startsWith('-')) {
throw new Error(`Unknown option: ${token}`);
} else {
result.operands.push(token);
}
index += 1;
}
return result;
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { parseArgs } from './args.js';
// 覆盖 CLI 参数解析与非法取值拒绝。
describe('parseArgs', () => {
it('parses command-specific and global options', () => {
expect(parseArgs(['web', '--port', '9000', '--no-open', '--adapter', 'rest'])).toMatchObject({
command: 'web', port: 9000, autoOpenBrowser: false, adapter: 'rest',
});
});
it('rejects unsafe or invalid values', () => {
expect(() => parseArgs(['web', '--port', 'nope'])).toThrow('Port must be an integer');
expect(() => parseArgs(['web', '--host', 'x & calc'])).toThrow('Host contains invalid characters');
expect(() => parseArgs(['web', '--adapter', 'other'])).toThrow('Adapter must be mock or rest');
});
});
+76
View File
@@ -0,0 +1,76 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
// CLI 配置文件读写:默认值、点号路径访问与类型解析。
export const DEFAULT_CONFIG = Object.freeze({
port: 5173,
host: '127.0.0.1',
autoOpenBrowser: true,
adapter: 'mock',
});
export function defaultConfigPath() {
return path.join(os.homedir(), '.chusql', 'config.json');
}
export function readConfig(file = defaultConfigPath()) {
if (!fs.existsSync(file)) {
writeConfig(file, DEFAULT_CONFIG);
return { ...DEFAULT_CONFIG };
}
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
} catch {
throw new Error(`Configuration file is not valid JSON: ${file}`);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(`Configuration must be a JSON object: ${file}`);
return { ...DEFAULT_CONFIG, ...parsed };
}
export function writeConfig(file, value) {
fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
export function parseConfigValue(raw) {
if (/^-?\d+(?:\.\d+)?$/.test(raw)) return Number(raw);
if (raw === 'true') return true;
if (raw === 'false') return false;
return raw;
}
const FORBIDDEN_PATH_PARTS = new Set(['__proto__', 'prototype', 'constructor']);
function configPathParts(dottedPath) {
const parts = dottedPath.split('.').filter(Boolean);
if (!parts.length) throw new Error('Configuration key must not be empty.');
if (parts.some((part) => FORBIDDEN_PATH_PARTS.has(part))) throw new Error('Configuration key contains a forbidden path segment.');
return parts;
}
export function getAtPath(value, dottedPath) {
return configPathParts(dottedPath).reduce((current, part) => current && typeof current === 'object' ? current[part] : undefined, value);
}
export function setAtPath(value, dottedPath, nextValue) {
const parts = configPathParts(dottedPath);
let cursor = value;
for (const part of parts.slice(0, -1)) {
if (!cursor[part] || typeof cursor[part] !== 'object' || Array.isArray(cursor[part])) cursor[part] = {};
cursor = cursor[part];
}
cursor[parts.at(-1)] = nextValue;
}
export function unsetAtPath(value, dottedPath) {
const parts = configPathParts(dottedPath);
let cursor = value;
for (const part of parts.slice(0, -1)) {
if (!cursor[part] || typeof cursor[part] !== 'object') return false;
cursor = cursor[part];
}
return parts.length ? delete cursor[parts.at(-1)] : false;
}
@@ -0,0 +1,49 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { DEFAULT_CONFIG, getAtPath, parseConfigValue, readConfig, setAtPath, unsetAtPath, writeConfig } from './config.js';
// 覆盖 CLI 配置读写、取值解析与路径安全。
const temporaryDirectories = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) fs.rmSync(directory, { recursive: true, force: true });
});
function temporaryConfig() {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'chusql-cli-'));
temporaryDirectories.push(directory);
return path.join(directory, 'nested', 'config.json');
}
describe('CLI configuration', () => {
it('creates the formatted default configuration on first read', () => {
const file = temporaryConfig();
expect(readConfig(file)).toEqual(DEFAULT_CONFIG);
expect(fs.readFileSync(file, 'utf8')).toBe(`${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`);
});
it('persists dotted paths and can remove them', () => {
const file = temporaryConfig();
const config = readConfig(file);
setAtPath(config, 'db.host', 'localhost');
writeConfig(file, config);
expect(getAtPath(readConfig(file), 'db.host')).toBe('localhost');
expect(unsetAtPath(config, 'db.host')).toBe(true);
expect(getAtPath(config, 'db.host')).toBeUndefined();
});
it('parses numbers and booleans without changing ordinary text', () => {
expect(parseConfigValue('5173')).toBe(5173);
expect(parseConfigValue('true')).toBe(true);
expect(parseConfigValue('rest')).toBe('rest');
});
it('rejects prototype-polluting configuration paths', () => {
const config = {};
expect(() => setAtPath(config, '__proto__.polluted', true)).toThrow('forbidden path segment');
expect({}.polluted).toBeUndefined();
});
});
+11
View File
@@ -0,0 +1,11 @@
import { spawn } from 'node:child_process';
// 用系统默认浏览器打开本地地址,并校验 URL 合法性。
export function openBrowser(url) {
if (!/^http:\/\/[A-Za-z0-9.:[\]-]+:\d+\/$/.test(url)) throw new Error('Refusing to open an invalid local URL.');
const command = process.platform === 'win32' ? 'cmd.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open';
const args = process.platform === 'win32' ? ['/d', '/s', '/c', 'start', '', url] : [url];
const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true });
child.unref();
}
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<!-- ChuSQL 前端页面壳:挂载根节点并加载入口脚本。 -->
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>ChuSQL 数据库终端</title>
<link rel="stylesheet" href="/src/styles/global.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
+40
View File
@@ -0,0 +1,40 @@
{
"name": "chusql-web-ide",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "ChuSQL database Web IDE and command-line launcher",
"bin": {
"chusql": "./bin/chusql.js",
"chusql-cli": "./bin/chusql-cli.js"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run",
"test:watch": "vitest",
"check": "npm run test && npm run build"
},
"dependencies": {
"lucide-react": "0.468.0",
"monaco-editor": "0.52.2",
"react": "18.3.1",
"react-dom": "18.3.1",
"vite": "8.3.1",
"zustand": "5.0.3"
},
"devDependencies": {
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.1.0",
"@types/node": "22.20.4",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@vitejs/plugin-react": "6.1.1",
"jsdom": "25.0.1",
"typescript": "5.7.2",
"vitest": "5.0.2"
},
"engines": {
"node": ">=20.19"
}
}
+18
View File
@@ -0,0 +1,18 @@
/* 工作台布局样式:编辑器面板、标签页、底部面板与提示。 */
.editorPane { display: grid; grid-template-rows: 30px minmax(0, 1fr); height: 100%; min-width: 0; min-height: 0; overflow: hidden; }
.tabs { align-items: stretch; background: var(--bg-elevated); border-bottom: 1px solid var(--border); display: flex; overflow-x: auto; }
.tab, .tabActive { align-items: center; background: var(--bg-base); border-right: 1px solid var(--border); border-top: 1px solid transparent; display: flex; flex: 0 0 auto; }
.tab { color: var(--fg-muted); }
.tab:hover { color: var(--fg-primary); }
.tabActive { border-top-color: var(--accent); color: var(--fg-primary); }
.tabLabel { background: transparent; border: 0; color: inherit; cursor: pointer; font: inherit; max-width: 180px; overflow: hidden; padding: 0 8px 0 12px; text-overflow: ellipsis; white-space: nowrap; }
.tabs button[aria-label] { flex-shrink: 0; height: 20px; margin-right: 4px; opacity: 0; width: 20px; }
.tab:hover button[aria-label], .tabActive button[aria-label], .tabs button[aria-label]:focus-visible { opacity: 1; }
.tabs svg { flex-shrink: 0; }
.notice, .welcome { color: var(--fg-muted); padding: 24px; }
.welcome { align-items: center; display: flex; height: 100%; justify-content: center; text-align: center; }
.bottomTabs { border-bottom: 1px solid var(--border); display: flex; gap: 3px; height: 28px; }
.bottomTabs button { background: transparent; border: 0; color: var(--fg-primary); font: inherit; padding: 0 9px; }
.bottomTabs button:first-child { border-bottom: 1px solid var(--accent); }
.message { color: var(--fg-muted); padding: 8px; }
+150
View File
@@ -0,0 +1,150 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useChangeStore } from './store/changeStore';
import { App } from './App';
import { MockAdapter } from './adapters/MockAdapter';
// App 组件测试:校验行删除、编辑提交与多标签编辑区。
vi.mock('./components/sql/SqlConsole', () => ({ SqlConsole: () => <div data-testid="sql-console" /> }));
describe('App table editing flow', () => {
beforeEach(() => useChangeStore.getState().clear());
it('stages row deletion on the first click and allows undo', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
const commit = vi.spyOn(adapter, 'commitChanges');
render(<App adapter={adapter} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
fireEvent.click(await screen.findByText('User_001'));
fireEvent.click(screen.getByRole('button', { name: '删除选中行' }));
expect(useChangeStore.getState().changes.deletes.size).toBe(1);
expect(commit).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
expect(useChangeStore.getState().changes.deletes.size).toBe(0);
});
it('keeps edits local and commits the complete change set once', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
const commit = vi.spyOn(adapter, 'commitChanges');
render(<App adapter={adapter} />);
expect(screen.getByText('资源管理器')).toBeInTheDocument();
fireEvent.click(await screen.findByRole('button', { name: /打开表 users/ }));
const cell = await screen.findByText('User_001');
fireEvent.doubleClick(cell);
const editor = screen.getByRole('textbox', { name: '编辑 name' });
fireEvent.change(editor, { target: { value: 'edited' } });
fireEvent.keyDown(editor, { key: 'Enter' });
expect(screen.getByRole('button', { name: '提交 (1)' })).toBeEnabled();
expect(commit).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '提交 (1)' }));
await waitFor(() => expect(commit).toHaveBeenCalledTimes(1));
expect(commit.mock.calls[0][0].changes).toHaveLength(1);
});
});
describe('App tabs', () => {
beforeEach(() => useChangeStore.getState().clear());
it('keeps two tables open and switches back to the first one', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
expect(await screen.findByText('User_001')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '打开表 orders' }));
expect(screen.getAllByRole('tab')).toHaveLength(2);
expect(screen.getByRole('tab', { name: 'orders' })).toHaveAttribute('aria-selected', 'true');
fireEvent.click(screen.getByRole('tab', { name: 'users' }));
expect(screen.getByRole('tab', { name: 'users' })).toHaveAttribute('aria-selected', 'true');
expect(await screen.findByText('User_001')).toBeInTheDocument();
});
it('does not add a second tab for a table that is already open', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
fireEvent.click(screen.getByRole('button', { name: '打开表 users' }));
expect(screen.getAllByRole('tab')).toHaveLength(1);
expect(await screen.findByText('User_001')).toBeInTheDocument();
});
it('activates the neighbouring tab after closing the active one', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
await screen.findByText('User_001');
fireEvent.click(screen.getByRole('button', { name: '打开表 orders' }));
fireEvent.click(screen.getByRole('button', { name: '关闭 orders' }));
expect(screen.getAllByRole('tab')).toHaveLength(1);
expect(screen.queryByRole('tab', { name: 'orders' })).not.toBeInTheDocument();
expect(await screen.findByText('User_001')).toBeInTheDocument();
});
it('opens one tab for every new query', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '新建查询' }));
fireEvent.click(screen.getByRole('button', { name: '新建查询' }));
expect(screen.getAllByRole('tab')).toHaveLength(2);
expect(await screen.findByTestId('sql-console')).toBeInTheDocument();
expect(screen.getAllByTestId('sql-console')).toHaveLength(1);
});
});
describe('App pending changes', () => {
beforeEach(() => useChangeStore.getState().clear());
it('shows a staged row edit as highlighted SQL instead of JSON', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
fireEvent.doubleClick(await screen.findByText('User_001'));
const editor = screen.getByRole('textbox', { name: '编辑 name' });
fireEvent.change(editor, { target: { value: "A'B" } });
fireEvent.keyDown(editor, { key: 'Enter' });
const code = await screen.findByTitle("UPDATE users SET name = 'A''B' WHERE id = 1;");
expect(code).toHaveTextContent("UPDATE users SET name = 'A''B' WHERE id = 1;");
expect(within(code).getByText('UPDATE')).toHaveStyle({ color: '#569CD6' });
expect(within(code).getByText("'A''B'")).toHaveStyle({ color: '#CE9178' });
expect(within(code).getByText('users')).toHaveStyle({ color: 'rgb(200, 200, 200)' });
expect(screen.queryByText(/\{"id":1/)).not.toBeInTheDocument();
});
it('counts a staged index change as a pending change and previews its SQL', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
await screen.findByText('User_001');
fireEvent.click(screen.getByRole('button', { name: '管理 users 的索引' }));
const indexRows = within(screen.getByRole('dialog')).getAllByRole('listitem');
fireEvent.click(within(indexRows[2]).getByRole('button', { name: '建索引' }));
expect(await screen.findByTitle('CREATE INDEX ON users (age);')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '提交 (1)' })).toBeEnabled();
});
it('creates a staged table only when the change set is committed', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
render(<App adapter={adapter} />);
fireEvent.click(await screen.findByRole('button', { name: '新建表' }));
fireEvent.change(screen.getByLabelText('表名'), { target: { value: 'items' } });
fireEvent.change(screen.getByLabelText('列名 2'), { target: { value: 'title' } });
fireEvent.click(screen.getByRole('button', { name: '建表' }));
expect(await screen.findByTitle('CREATE TABLE items (id int, title str);')).toBeInTheDocument();
expect((await adapter.listTables('chusql')).some((table) => table.name === 'items')).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '提交 (1)' }));
await waitFor(async () => {
expect((await adapter.listTables('chusql')).some((table) => table.name === 'items')).toBe(true);
});
expect(await screen.findByRole('button', { name: '打开表 items' })).toBeInTheDocument();
});
it('selects a single row on click, toggles with Ctrl and extends with Shift', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
fireEvent.click(await screen.findByText('User_001'));
expect(screen.getByText('已选 1 行')).toBeInTheDocument();
fireEvent.click(screen.getByText('User_002'), { ctrlKey: true });
expect(screen.getByText('已选 2 行')).toBeInTheDocument();
fireEvent.click(screen.getByText('User_002'), { ctrlKey: true });
expect(screen.getByText('已选 1 行')).toBeInTheDocument();
fireEvent.click(screen.getByText('User_001'));
fireEvent.click(screen.getByText('User_004'), { shiftKey: true });
expect(screen.getByText('已选 4 行')).toBeInTheDocument();
fireEvent.click(screen.getByText('User_003'));
expect(screen.getByText('已选 1 行')).toBeInTheDocument();
});
});
+126
View File
@@ -0,0 +1,126 @@
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { MockAdapter } from './adapters/MockAdapter';
import { IconButton } from './components/common/IconButton';
import { TableEditor } from './components/grid/TableEditor';
import { BottomPanel } from './components/layout/BottomPanel';
import { Explorer } from './components/layout/Explorer';
import { Workbench } from './components/layout/Workbench';
import { SchemaDialog, type SchemaAction } from './components/layout/SchemaDialog';
import { SettingsPanel } from './components/settings/SettingsPanel';
import type { QueryHistoryEntry } from './components/sql/SqlConsole';
import { changeCount, toCommitPayload } from './core/changes';
import { closeTab, openQueryTab, openSettingsTab, openTableTab, type Tab, type TabState } from './core/tabs';
import { syncUiSettings } from './core/uiSettings';
import { zhCN } from './i18n/zh-CN';
import { useChangeStore } from './store/changeStore';
import { fontFamilyValue, useSettingsStore } from './store/settingsStore';
import type { DataSourceAdapter, SchemaChange, StatementResult, TableMeta } from './types';
import styles from './App.module.css';
// 应用根组件:装配数据源、多标签编辑区、变更提交与设置视图。
interface AppProps { adapter?: DataSourceAdapter }
const SqlConsole = lazy(async () => ({ default: (await import('./components/sql/SqlConsole')).SqlConsole }));
function initialHistory(): QueryHistoryEntry[] {
localStorage.removeItem('chusql.sql.history.v1');
try { const value = JSON.parse(localStorage.getItem('chusql.sql.history.v2') ?? '[]'); return Array.isArray(value) ? value : []; }
catch { return []; }
}
export function App({ adapter: providedAdapter }: AppProps) {
const adapter = useMemo(() => providedAdapter ?? new MockAdapter(), [providedAdapter]);
const [tables, setTables] = useState<TableMeta[]>([]);
const [tabs, setTabs] = useState<Tab[]>([]);
const [activeId, setActiveId] = useState<string>();
const [totals, setTotals] = useState<Record<string, number>>({});
const [messages, setMessages] = useState<string[]>([]);
const [results, setResults] = useState<StatementResult[]>([]);
const [history, setHistory] = useState<QueryHistoryEntry[]>(initialHistory);
const [revision, setRevision] = useState(0);
const [committing, setCommitting] = useState(false);
const [schemaAction, setSchemaAction] = useState<SchemaAction>();
const idRef = useRef(0);
const queryRef = useRef(0);
const { changes, undoStack, redoStack, undoOne, undo, redo, clear, acknowledge } = useChangeStore();
const settings = useSettingsStore((state) => state.settings);
const count = changeCount(changes);
const activeTab = tabs.find((tab) => tab.id === activeId);
const activeTableTab = activeTab?.kind === 'table' ? activeTab : undefined;
const activeMeta = activeTableTab ? tables.find((table) => table.name === activeTableTab.table) : undefined;
const pushMessage = (message: string) => setMessages((current) => [...current, message].slice(-100));
const nextTabId = () => `tab-${(idRef.current += 1)}`;
const applyTabs = (state: TabState) => { setTabs(state.tabs); setActiveId(state.activeId); };
const forgetTotal = (id: string) => setTotals((current) => { const next = { ...current }; delete next[id]; return next; });
const loadTables = async () => {
try { setTables(await adapter.listTables(zhCN.database)); }
catch (reason) { pushMessage(`${zhCN.loadError}: ${reason instanceof Error ? reason.message : String(reason)}`); }
};
useEffect(() => { void loadTables(); }, [adapter]);
useEffect(() => syncUiSettings(), []);
useEffect(() => {
const root = document.documentElement;
root.style.setProperty('--ui-font', fontFamilyValue(settings.uiFonts));
root.style.setProperty('--grid-font', fontFamilyValue(settings.gridFonts));
root.style.setProperty('--grid-font-size', `${settings.gridFontSize}px`);
root.style.setProperty('--grid-row-height', `${settings.gridRowHeight}px`);
root.style.fontSize = `${settings.uiFontSize}px`;
}, [settings]);
useEffect(() => {
const warn = (event: BeforeUnloadEvent) => { if (count) event.preventDefault(); };
window.addEventListener('beforeunload', warn);
return () => window.removeEventListener('beforeunload', warn);
}, [count]);
const rollback = () => { clear(); setRevision((value) => value + 1); };
const applySchema = async (change: SchemaChange) => {
if (change.op === 'createTable') await adapter.createTable(change.table, change.columns);
else if (change.op === 'dropTable') await adapter.dropTable(change.table);
else if (change.op === 'createIndex') await adapter.createIndex(change.table, change.column);
else await adapter.dropIndex(change.table, change.column);
};
const commit = async () => {
if (committing) return;
setCommitting(true);
try {
const rows = toCommitPayload(changes);
const schemas = Array.from(changes.schemas.values());
const creates = schemas.filter((change) => change.op === 'createTable' || change.op === 'createIndex');
const drops = schemas.filter((change) => change.op === 'dropIndex' || change.op === 'dropTable');
let applied = 0;
let failure = '';
try {
for (const change of creates) { await applySchema(change); applied += 1; }
if (rows.length) {
const result = await adapter.commitChanges({ db: zhCN.database, changes: rows });
applied += result.ok ? rows.length : result.applied;
if (!result.ok) failure = result.errors?.map((item) => item.message).join('; ') ?? '';
}
if (!failure) for (const change of drops) { await applySchema(change); applied += 1; }
} catch (reason) { failure = reason instanceof Error ? reason.message : String(reason); }
if (applied) acknowledge(changes, applied);
if (failure) pushMessage(`${zhCN.commitFailed} (${applied} applied; retry submits only the remainder): ${failure}`);
else pushMessage(zhCN.committed(applied));
await loadTables(); setRevision((value) => value + 1);
} finally { setCommitting(false); }
};
const requestCommit = () => { if (count) void commit(); };
const openTable = (name: string) => applyTabs(openTableTab(tabs, name, nextTabId()));
const newQuery = (sql?: string) => applyTabs(openQueryTab(tabs, zhCN.queryTab((queryRef.current += 1)), nextTabId(), sql));
const openSettings = () => applyTabs(openSettingsTab(tabs, zhCN.settings, nextTabId()));
const closeTabById = (tab: Tab) => {
forgetTotal(tab.id);
applyTabs(closeTab(tabs, tab.id, activeId));
};
let editor = <div className={styles.welcome}>{zhCN.emptyWorkspace}</div>;
if (tabs.length) {
let body = <div className={styles.welcome}>{zhCN.emptyWorkspace}</div>;
if (activeTab?.kind === 'sql') body = <Suspense fallback={<div className={styles.notice}>{zhCN.loading}</div>}><SqlConsole key={activeTab.id} adapter={adapter} settings={settings} initialSql={activeTab.sql} onResult={(result) => setResults((current) => [...current, result])} onMessage={pushMessage} onHistory={setHistory} /></Suspense>;
else if (activeTab?.kind === 'settings') body = <SettingsPanel />;
else if (activeTableTab && activeMeta) body = <TableEditor key={activeTableTab.id} adapter={adapter} table={activeMeta} settings={settings} revision={revision} onTotal={(value) => setTotals((current) => ({ ...current, [activeTableTab.id]: value }))} onMessage={pushMessage} />;
editor = <section className={styles.editorPane}><div className={styles.tabs} role="tablist">{tabs.map((tab) => <div key={tab.id} className={tab.id === activeId ? styles.tabActive : styles.tab}><button role="tab" aria-selected={tab.id === activeId} className={styles.tabLabel} onClick={() => setActiveId(tab.id)}>{tab.title}</button><IconButton compact icon={<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true"><path d="m4 4 8 8m0-8-8 8" /></svg>} label={zhCN.closeTab(tab.title)} onClick={() => closeTabById(tab)} /></div>)}</div>{body}</section>;
}
return <><Workbench explorer={<Explorer tables={tables} activeTable={activeMeta?.name} history={history} onOpenTable={openTable} onOpenSql={newQuery} onCreateTable={() => setSchemaAction({ kind: 'create' })} onIndexes={(table) => setSchemaAction({ kind: 'indexes', table })} onDropTable={(table) => setSchemaAction({ kind: 'drop', table })} />} editor={editor} bottom={<BottomPanel changes={changes} results={results} messages={messages} onUndoOne={undoOne} />} status={<><span>{zhCN.connection}</span><span>{zhCN.database}</span>{activeMeta && <span>{activeMeta.name}</span>}{activeTableTab && activeMeta && <span>{zhCN.rowCount(totals[activeTableTab.id] ?? 0)}</span>}<span>{zhCN.pendingCount(count)}</span></>} changeCount={count} canUndo={undoStack.length > 0} canRedo={redoStack.length > 0} onCommit={requestCommit} onRollback={rollback} onUndo={undo} onRedo={redo} onSql={() => newQuery()} onSettings={openSettings} />{schemaAction && <SchemaDialog action={schemaAction} onClose={() => setSchemaAction(undefined)} onChanged={pushMessage} />}</>;
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { MockAdapter } from './MockAdapter';
// MockAdapter 测试:列表行数、排序筛选与变更集提交。
describe('MockAdapter', () => {
it('ships users and orders with 500 rows each', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
const tables = await adapter.listTables('chusql');
expect(tables.map((table) => [table.name, table.rowCount])).toEqual([
['users', 500],
['orders', 500],
]);
});
it('sorts and filters before paginating', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
const result = await adapter.fetchRows({
db: 'chusql', table: 'users', page: 0, pageSize: 3,
sort: [{ column: 'age', dir: 'desc' }], filters: [{ column: 'age', expression: '>60' }],
});
expect(result.total).toBeGreaterThan(3);
expect(result.rows).toHaveLength(3);
expect(Number(result.rows[0].age)).toBeGreaterThanOrEqual(Number(result.rows[1].age));
});
it('applies a complete change set in one call', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
const response = await adapter.commitChanges({
db: 'chusql',
changes: [
{ op: 'update', table: 'users', pk: 1, set: { name: 'edited' } },
{ op: 'delete', table: 'users', pk: 2 },
],
});
expect(response).toEqual({ ok: true, applied: 2 });
const page = await adapter.fetchRows({ db: 'chusql', table: 'users', page: 0, pageSize: 3, sort: [], filters: [] });
expect(page.rows[0].name).toBe('edited');
expect(page.rows.some((row) => row.id === 2)).toBe(false);
});
it('refuses an index on a column that holds duplicate values', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
await expect(adapter.createIndex('users', 'age')).rejects.toThrow('duplicate values');
const users = (await adapter.listTables('chusql')).find((table) => table.name === 'users')!;
expect(users.columns.find((column) => column.name === 'age')?.indexed).toBeFalsy();
});
});
@@ -0,0 +1,178 @@
import { matchesFilter } from '../core/filters';
import type {
ColumnMeta, CommitChange, DataSourceAdapter, FetchRowsParams, StatementResult, TableMeta,
} from '../types';
// 内存数据源:内置 mock 表,供无后端环境演示与测试。
type TableData = { columns: ColumnMeta[]; rows: Array<Record<string, unknown>> };
function makeData(): Map<string, TableData> {
const users = Array.from({ length: 500 }, (_, index) => ({
id: index + 1,
name: `User_${String(index + 1).padStart(3, '0')}`,
age: 18 + (index % 55),
active: index % 4 !== 0,
joined_at: `202${index % 5}-${String((index % 12) + 1).padStart(2, '0')}-${String((index % 28) + 1).padStart(2, '0')}`,
}));
const orders = Array.from({ length: 500 }, (_, index) => ({
id: index + 1,
user_id: (index % 500) + 1,
product: ['Keyboard', 'Monitor', 'Database Course', 'Hard Drive'][index % 4],
amount: 49 + ((index * 37) % 2400),
paid: index % 3 !== 0,
}));
return new Map([
['users', {
columns: [
{ name: 'id', type: 'int', primaryKey: true, indexed: true },
{ name: 'name', type: 'str' },
{ name: 'age', type: 'int' },
{ name: 'active', type: 'bool' },
{ name: 'joined_at', type: 'date' },
], rows: users,
}],
['orders', {
columns: [
{ name: 'id', type: 'int', primaryKey: true, indexed: true },
{ name: 'user_id', type: 'int', indexed: true },
{ name: 'product', type: 'str' },
{ name: 'amount', type: 'int' },
{ name: 'paid', type: 'bool' },
], rows: orders,
}],
]);
}
function compareValues(left: unknown, right: unknown): number {
if (left === right) return 0;
if (left === null || left === undefined) return -1;
if (right === null || right === undefined) return 1;
if (typeof left === 'number' && typeof right === 'number') return left - right;
return String(left).localeCompare(String(right), 'zh-CN', { numeric: true });
}
export class MockAdapter implements DataSourceAdapter {
async createTable(name: string, columns: Array<Pick<ColumnMeta, 'name' | 'type'>>): Promise<void> {
await this.delay();
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || this.tables.has(name)) throw new Error('Invalid or already existing table name');
if (!columns.length || new Set(columns.map((column) => column.name)).size !== columns.length) throw new Error('Column names must be non-empty and unique');
this.tables.set(name, { columns: columns.map((column) => ({ ...column, primaryKey: column.name === 'id', indexed: column.name === 'id' })), rows: [] });
}
async dropTable(table: string): Promise<void> {
await this.delay();
if (!this.tables.delete(table)) throw new Error('Table does not exist');
}
async createIndex(table: string, column: string): Promise<void> {
await this.delay();
const found = this.tables.get(table);
const meta = found?.columns.find((item) => item.name === column);
if (!found || !meta || meta.type !== 'int' || meta.indexed) throw new Error('Only unindexed integer columns can be indexed');
if (new Set(found.rows.map((row) => row[column])).size !== found.rows.length) throw new Error('Index column contains duplicate values and must be unique');
meta.indexed = true;
}
async dropIndex(table: string, column: string): Promise<void> {
await this.delay();
const meta = this.tables.get(table)?.columns.find((item) => item.name === column);
if (!meta?.indexed || column === 'id') throw new Error('Index does not exist or is a built-in index that cannot be dropped');
meta.indexed = false;
}
private readonly tables = makeData();
private readonly latencyMs: number | [number, number];
constructor(options: { latencyMs?: number | [number, number] } = {}) {
this.latencyMs = options.latencyMs ?? [80, 200];
}
private async delay(): Promise<void> {
const milliseconds = Array.isArray(this.latencyMs)
? this.latencyMs[0] + Math.random() * (this.latencyMs[1] - this.latencyMs[0])
: this.latencyMs;
if (milliseconds > 0) await new Promise((resolve) => window.setTimeout(resolve, milliseconds));
}
async listTables(_db: string): Promise<TableMeta[]> {
await this.delay();
return Array.from(this.tables, ([name, table]) => ({ name, rowCount: table.rows.length, columns: table.columns }));
}
async getColumns(_db: string, table: string): Promise<ColumnMeta[]> {
await this.delay();
const found = this.tables.get(table);
if (!found) throw new Error(`Unknown table: ${table}`);
return found.columns.map((column) => ({ ...column }));
}
async fetchRows(params: FetchRowsParams): Promise<{ rows: Record<string, unknown>[]; total: number }> {
await this.delay();
const found = this.tables.get(params.table);
if (!found) throw new Error(`Unknown table: ${params.table}`);
let rows = found.rows.filter((row) => params.filters.every((filter) => matchesFilter(row[filter.column], filter.expression)));
if (params.search?.trim()) {
const term = params.search.toLocaleLowerCase();
rows = rows.filter((row) => Object.values(row).some((value) => String(value ?? '').toLocaleLowerCase().includes(term)));
}
if (params.sort.length) {
rows = [...rows].sort((left, right) => {
for (const sort of params.sort) {
const compared = compareValues(left[sort.column], right[sort.column]);
if (compared !== 0) return sort.dir === 'asc' ? compared : -compared;
}
return 0;
});
}
const start = params.page * params.pageSize;
return { rows: rows.slice(start, start + params.pageSize).map((row) => ({ ...row })), total: rows.length };
}
async commitChanges(payload: { db: string; changes: CommitChange[] }): Promise<{ ok: boolean; applied: number; errors?: Array<{ index: number; message: string }> }> {
await this.delay();
const working = new Map(Array.from(this.tables, ([name, table]) => [name, { columns: table.columns, rows: table.rows.map((row) => ({ ...row })) }]));
const errors: Array<{ index: number; message: string }> = [];
payload.changes.forEach((change, index) => {
const table = working.get(change.table);
if (!table) {
errors.push({ index, message: `Unknown table: ${change.table}` });
return;
}
if (change.op === 'insert') {
if (table.rows.some((row) => row.id === change.values.id)) errors.push({ index, message: `Duplicate id: ${String(change.values.id)}` });
else table.rows.push({ ...change.values });
return;
}
const rowIndex = table.rows.findIndex((row) => row.id === change.pk);
if (rowIndex < 0) {
errors.push({ index, message: `Row not found: ${String(change.pk)}` });
} else if (change.op === 'delete') {
table.rows.splice(rowIndex, 1);
} else {
table.rows[rowIndex] = { ...table.rows[rowIndex], ...change.set };
}
});
if (errors.length) return { ok: false, applied: 0, errors };
this.tables.clear();
for (const [name, table] of working) this.tables.set(name, table);
return { ok: true, applied: payload.changes.length };
}
async execStatement({ sql }: { db: string; sql: string }): Promise<StatementResult> {
const started = performance.now();
await this.delay();
const normalized = sql.trim().replace(/;$/, '');
const select = normalized.match(/^select\s+(.+?)\s+from\s+([A-Za-z_][A-Za-z0-9_]*)/is);
if (select) {
const table = this.tables.get(select[2]);
if (!table) return { type: 'error', durationMs: performance.now() - started, message: `Unknown table: ${select[2]}` };
const columns = select[1].trim() === '*' ? table.columns.map((column) => column.name) : select[1].split(',').map((item) => item.trim());
return {
type: 'rows', columns,
rows: table.rows.slice(0, 200).map((row) => columns.map((column) => row[column])),
durationMs: performance.now() - started,
};
}
if (/^(insert|update|delete|create|drop)\b/i.test(normalized)) {
return { type: 'affected', affected: 1, durationMs: performance.now() - started };
}
return { type: 'error', durationMs: performance.now() - started, message: 'MockAdapter cannot execute this statement' };
}
}
@@ -0,0 +1,53 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { RestAdapter } from './RestAdapter';
// RestAdapter 测试:接口路径编码、响应归一化与错误处理。
describe('RestAdapter', () => {
afterEach(() => vi.unstubAllGlobals());
it('uses structured schema endpoints and encodes identifiers in paths', async () => {
const request = vi.fn().mockImplementation(async () => new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', request);
const adapter = new RestAdapter();
await adapter.createTable('items', [{ name: 'id', type: 'int' }]);
await adapter.createIndex('items', 'sku');
await adapter.dropIndex('items', 'sku');
await adapter.dropTable('a/b');
expect(request.mock.calls.map(([path, init]) => [path, init.method, init.body])).toEqual([
['/api/tables', 'POST', JSON.stringify({ name: 'items', columns: [{ name: 'id', type: 'int' }] })],
['/api/tables/items/indexes', 'POST', JSON.stringify({ column: 'sku' })],
['/api/tables/items/indexes/sku', 'DELETE', undefined],
['/api/tables/a%2Fb', 'DELETE', undefined],
]);
});
it('normalizes the existing Haskell catalog response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify([{
table: 'users', rowCount: 2,
columns: [{ name: 'id', type: 'int', primaryKey: true, indexed: true }],
indexes: [{ column: 'id', builtIn: true }],
}]), { status: 200, headers: { 'Content-Type': 'application/json' } })));
const tables = await new RestAdapter().listTables('chusql');
expect(tables).toEqual([{ name: 'users', rowCount: 2, columns: [{ name: 'id', type: 'int', primaryKey: true, indexed: true }] }]);
});
it('maps pages, sorting and filters to the existing rows endpoint', async () => {
const request = vi.fn().mockResolvedValue(new Response(JSON.stringify({
columns: ['id', 'name'], rows: [[1, 'Ada']], total: 1,
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
vi.stubGlobal('fetch', request);
const result = await new RestAdapter().fetchRows({
db: 'chusql', table: 'users', page: 2, pageSize: 20,
sort: [{ column: 'name', dir: 'desc' }], filters: [{ column: 'name', expression: 'Ada' }],
});
expect(result).toEqual({ rows: [{ id: 1, name: 'Ada' }], total: 1 });
expect(String(request.mock.calls[0][0])).toContain('/api/tables/users/rows?');
expect(String(request.mock.calls[0][0])).toContain('offset=40');
expect(String(request.mock.calls[0][0])).toContain('sort=name');
});
it('turns non-JSON and structured API failures into safe errors', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('<stack trace>', { status: 500 })));
await expect(new RestAdapter().listTables('chusql')).rejects.toThrow('Request failed with status 500');
});
});
@@ -0,0 +1,153 @@
import type {
ColumnMeta, CommitChange, DataSourceAdapter, FetchRowsParams, StatementResult, TableMeta,
} from '../types';
// REST 数据源:调用后端 JSON 接口并校验返回结构。
type JsonObject = Record<string, unknown>;
function isObject(value: unknown): value is JsonObject {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function asColumns(value: unknown): ColumnMeta[] {
if (!Array.isArray(value)) throw new Error('The server returned an invalid column list.');
return value.map((entry) => {
if (!isObject(entry) || typeof entry.name !== 'string' || typeof entry.type !== 'string') {
throw new Error('The server returned an invalid column description.');
}
if (!['int', 'str', 'bool', 'date'].includes(entry.type)) throw new Error(`Unsupported column type: ${entry.type}`);
return {
name: entry.name,
type: entry.type as ColumnMeta['type'],
...(typeof entry.primaryKey === 'boolean' ? { primaryKey: entry.primaryKey } : {}),
...(typeof entry.indexed === 'boolean' ? { indexed: entry.indexed } : {}),
...(typeof entry.prime === 'boolean' ? { prime: entry.prime } : {}),
...(typeof entry.distinct === 'number' ? { distinct: entry.distinct } : {}),
...(typeof entry.statsCapped === 'boolean' ? { statsCapped: entry.statsCapped } : {}),
};
});
}
function asTable(value: unknown): TableMeta {
if (!isObject(value) || typeof value.table !== 'string' || typeof value.rowCount !== 'number') {
throw new Error('The server returned an invalid table description.');
}
return { name: value.table, rowCount: value.rowCount, columns: asColumns(value.columns) };
}
async function requestJson(url: string, init?: RequestInit): Promise<unknown> {
const response = await fetch(url, { credentials: 'same-origin', ...init });
let body: unknown;
try {
body = await response.json();
} catch {
body = undefined;
}
if (!response.ok) {
const message = isObject(body) && typeof body.message === 'string'
? body.message
: `Request failed with status ${response.status}.`;
throw new Error(message);
}
return body;
}
function jsonInit(method: string, body?: unknown): RequestInit {
return {
method,
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
};
}
function changeRequest(change: CommitChange): [string, RequestInit] {
const table = encodeURIComponent(change.table);
if (change.op === 'insert') return [`/api/tables/${table}/rows`, jsonInit('POST', { values: change.values })];
const pk = encodeURIComponent(String(change.pk));
if (change.op === 'delete') return [`/api/tables/${table}/rows/${pk}`, jsonInit('DELETE')];
return [`/api/tables/${table}/rows/${pk}`, jsonInit('PATCH', { values: change.set })];
}
export class RestAdapter implements DataSourceAdapter {
async createTable(name: string, columns: Array<Pick<ColumnMeta, 'name' | 'type'>>): Promise<void> {
await requestJson('/api/tables', jsonInit('POST', { name, columns }));
}
async dropTable(table: string): Promise<void> {
await requestJson(`/api/tables/${encodeURIComponent(table)}`, jsonInit('DELETE'));
}
async createIndex(table: string, column: string): Promise<void> {
await requestJson(`/api/tables/${encodeURIComponent(table)}/indexes`, jsonInit('POST', { column }));
}
async dropIndex(table: string, column: string): Promise<void> {
await requestJson(`/api/tables/${encodeURIComponent(table)}/indexes/${encodeURIComponent(column)}`, jsonInit('DELETE'));
}
async listTables(_db: string): Promise<TableMeta[]> {
const body = await requestJson('/api/tables');
if (!Array.isArray(body)) throw new Error('The server returned an invalid table list.');
return body.map(asTable);
}
async getColumns(_db: string, table: string): Promise<ColumnMeta[]> {
return asTable(await requestJson(`/api/tables/${encodeURIComponent(table)}`)).columns;
}
async fetchRows(params: FetchRowsParams): Promise<{ rows: Record<string, unknown>[]; total: number }> {
const query = new URLSearchParams({
limit: String(params.pageSize),
offset: String(params.page * params.pageSize),
});
const firstSort = params.sort[0];
if (firstSort) {
query.set('sort', firstSort.column);
query.set('dir', firstSort.dir);
}
if (params.filters.length) {
query.set('filter', JSON.stringify(params.filters.map((filter) => ({ column: filter.column, value: filter.expression }))));
}
const body = await requestJson(`/api/tables/${encodeURIComponent(params.table)}/rows?${query}`);
if (!isObject(body) || !Array.isArray(body.columns) || !body.columns.every((column) => typeof column === 'string') || !Array.isArray(body.rows) || typeof body.total !== 'number') {
throw new Error('The server returned an invalid row page.');
}
const columns = body.columns as string[];
const rows = body.rows.map((raw) => {
if (!Array.isArray(raw)) throw new Error('The server returned an invalid row.');
return Object.fromEntries(columns.map((column, index) => [column, raw[index]]));
});
return { rows, total: body.total };
}
async commitChanges(payload: { db: string; changes: CommitChange[] }): Promise<{ ok: boolean; applied: number; errors?: Array<{ index: number; message: string }> }> {
let applied = 0;
for (const [index, change] of payload.changes.entries()) {
const [url, init] = changeRequest(change);
try {
await requestJson(url, init);
applied += 1;
} catch (error) {
return {
ok: false,
applied,
errors: [{ index, message: error instanceof Error ? error.message : String(error) }],
};
}
}
return { ok: true, applied };
}
async execStatement({ sql }: { db: string; sql: string }): Promise<StatementResult> {
const started = performance.now();
try {
const body = await requestJson('/api/query', jsonInit('POST', { sql }));
if (!isObject(body) || !Array.isArray(body.columns) || !Array.isArray(body.rows)) {
throw new Error('The server returned an invalid query result.');
}
const durationMs = performance.now() - started;
return body.columns.length
? { type: 'rows', columns: body.columns as string[], rows: body.rows as unknown[][], durationMs }
: { type: 'affected', affected: typeof body.rowCount === 'number' ? body.rowCount : 0, durationMs };
} catch (error) {
return { type: 'error', durationMs: performance.now() - started, message: error instanceof Error ? error.message : String(error) };
}
}
}
@@ -0,0 +1,25 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AuthGate } from './AuthGate';
// 覆盖登录门的会话校验与登录流程。
describe('AuthGate', () => {
afterEach(() => vi.unstubAllGlobals());
it('keeps REST content private until a successful login', async () => {
const request = vi.fn()
.mockResolvedValueOnce(new Response('', { status: 401 }))
.mockResolvedValueOnce(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', request);
render(<AuthGate><p>protected content</p></AuthGate>);
expect(await screen.findByRole('heading', { name: '登录 ChuSQL' })).toBeInTheDocument();
expect(screen.queryByText('protected content')).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'chusql' } });
fireEvent.click(screen.getByRole('button', { name: '登录' }));
await waitFor(() => expect(screen.getByText('protected content')).toBeInTheDocument());
expect(request).toHaveBeenLastCalledWith('/api/login', expect.objectContaining({ method: 'POST' }));
});
});
@@ -0,0 +1,56 @@
import { type FormEvent, type ReactNode, useEffect, useState } from 'react';
import { Database } from 'lucide-react';
import { zhCN } from '../../i18n/zh-CN';
import styles from './auth.module.css';
// 登录门组件:先查会话状态,未登录时渲染登录表单。
type AuthState = 'checking' | 'signed-out' | 'signed-in';
export function AuthGate({ children }: { children: ReactNode }) {
const [state, setState] = useState<AuthState>('checking');
const [user, setUser] = useState('root');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [failed, setFailed] = useState(false);
useEffect(() => {
const controller = new AbortController();
void fetch('/api/session', { credentials: 'same-origin', signal: controller.signal })
.then((response) => setState(response.ok ? 'signed-in' : 'signed-out'))
.catch((error: unknown) => {
if (!(error instanceof DOMException && error.name === 'AbortError')) setState('signed-out');
});
return () => controller.abort();
}, []);
const submit = async (event: FormEvent) => {
event.preventDefault();
setBusy(true);
setFailed(false);
try {
const response = await fetch('/api/login', {
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user, password }),
});
if (response.ok) setState('signed-in');
else setFailed(true);
} catch {
setFailed(true);
} finally {
setBusy(false);
}
};
if (state === 'signed-in') return children;
if (state === 'checking') return <main className={styles.center}><p>{zhCN.signingIn}</p></main>;
return <main className={styles.center}>
<form className={styles.login} onSubmit={(event) => void submit(event)}>
<header><Database size={18} /><h1>{zhCN.signInTitle}</h1></header>
<label><span>{zhCN.userName}</span><input autoFocus autoComplete="username" value={user} onChange={(event) => setUser(event.target.value)} /></label>
<label><span>{zhCN.password}</span><input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} /></label>
{failed && <p role="alert">{zhCN.signInFailed}</p>}
<button disabled={busy || !user || !password}>{zhCN.signIn}</button>
</form>
</main>;
}
@@ -0,0 +1,19 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import styles from './common.module.css';
// 通用图标按钮:支持紧凑模式,标签兼作无障碍名。
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
icon: ReactNode;
label: string;
compact?: boolean;
}
export function IconButton({ icon, label, compact = false, ...props }: IconButtonProps) {
return (
<button className={`${styles.iconButton} ${compact ? styles.compact : ''}`} aria-label={label} title={label} {...props}>
{icon}
{!compact && <span>{label}</span>}
</button>
);
}
@@ -0,0 +1,39 @@
import { useEffect, useId, useRef, type ReactNode } from 'react';
import { X } from 'lucide-react';
import { zhCN } from '../../i18n/zh-CN';
import { IconButton } from './IconButton';
import styles from './common.module.css';
// 通用模态对话框:标题栏、内容区与可选页脚。
interface ModalProps {
title: string;
children: ReactNode;
footer?: ReactNode;
onClose: () => void;
}
export function Modal({ title, children, footer, onClose }: ModalProps) {
const titleId = useId();
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return undefined;
if (typeof dialog.showModal === 'function') dialog.showModal();
else dialog.setAttribute('open', '');
return () => {
if (typeof dialog.close === 'function') dialog.close();
else dialog.removeAttribute('open');
};
}, []);
return (
<dialog ref={dialogRef} aria-labelledby={titleId} className={styles.dialog} onCancel={(event) => { event.preventDefault(); onClose(); }}>
<header className={styles.dialogHeader}>
<h2 id={titleId}>{title}</h2>
<IconButton icon={<X size={14} />} label={zhCN.close} compact onClick={onClose} />
</header>
<div className={styles.dialogBody}>{children}</div>
{footer && <footer className={styles.dialogFooter}>{footer}</footer>}
</dialog>
);
}
@@ -0,0 +1,11 @@
/* 登录页样式:居中卡片与表单控件。 */
.center { align-items: center; background: var(--bg-base); display: flex; height: 100%; justify-content: center; }
.center > p { color: var(--fg-muted); }
.login { background: var(--bg-elevated); border: 1px solid var(--border); display: grid; gap: 12px; padding: 18px; width: 320px; }
.login header { align-items: center; display: flex; gap: 8px; }
.login h1 { font-size: 14px; margin: 0; }
.login label { display: grid; gap: 5px; }
.login label span { color: var(--fg-muted); font-size: 11px; }
.login input { background: var(--bg-base); border: 1px solid var(--border); color: var(--fg-primary); height: 24px; padding: 0 6px; }
.login p { color: var(--danger); margin: 0; }
.login button { background: var(--accent-2); border: 1px solid var(--accent); color: #fff; height: 26px; }
@@ -0,0 +1,41 @@
/* 通用组件样式:图标按钮与模态对话框。 */
.iconButton {
align-items: center;
background: transparent;
border: 1px solid transparent;
border-radius: 2px;
color: var(--fg-primary);
cursor: pointer;
display: inline-flex;
font: inherit;
gap: 6px;
height: 24px;
padding: 0 7px;
}
.iconButton:hover:not(:disabled) { background: var(--bg-active); }
.iconButton:focus-visible { border-color: var(--accent); outline: 0; }
.iconButton:disabled { color: var(--fg-disabled); cursor: default; }
.compact { justify-content: center; padding: 0; width: 24px; }
.dialog {
background: var(--bg-overlay);
border: 1px solid var(--border);
border-radius: 2px;
color: var(--fg-primary);
max-height: min(720px, 85vh);
max-width: min(760px, 90vw);
padding: 0;
width: 620px;
}
.dialog::backdrop { background: rgb(0 0 0 / 55%); }
.dialogHeader { align-items: center; background: var(--bg-titlebar); display: flex; height: 34px; justify-content: space-between; padding: 0 8px; }
.dialogHeader h2 { font-size: 13px; margin: 0; }
.dialogBody { max-height: 60vh; overflow: auto; padding: 12px; }
.dialogFooter { border-top: 1px solid var(--border); display: flex; gap: 8px; justify-content: flex-end; padding: 8px; }
.iconButton { flex-shrink: 0; }
.dialog button:not(.iconButton) { cursor: pointer; min-height: 26px; }
.dialog button:disabled { cursor: default; opacity: .5; }
.dialogFooter button, .dialogBody button { background: var(--bg-active); border: 1px solid var(--border); color: var(--fg-primary); padding: 4px 8px; }
.dialog [role="alert"] { color: var(--danger); }
@@ -0,0 +1,54 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { DataGrid } from './DataGrid';
// 数据网格测试:表头排序、筛选弹层、行选中模式与列宽调整。
describe('DataGrid column controls', () => {
it('keeps row selection out of the grid columns and exposes header sorting and filtering', () => {
const onFilter = vi.fn();
const onSort = vi.fn();
const onSelectRow = vi.fn();
const onWidth = vi.fn();
render(<DataGrid
table="users"
columns={[{ name: 'id', type: 'int', primaryKey: true }, { name: 'name', type: 'str' }]}
rows={[{ id: 1, name: 'Ada' }]}
changes={{ updates: new Map(), inserts: new Map(), deletes: new Map(), schemas: new Map() }}
nullText="NULL"
filters={{}}
widths={{}}
selectedRows={new Set()}
onSort={onSort}
onFilter={onFilter}
onWidth={onWidth}
onSelectRow={onSelectRow}
onEdit={vi.fn()}
onDelete={vi.fn()}
/>);
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
expect(screen.queryByText('#')).not.toBeInTheDocument();
expect(screen.queryByRole('textbox', { name: '筛选 name' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'name' }));
expect(onSort).toHaveBeenCalledWith('name');
fireEvent.click(screen.getByRole('button', { name: '筛选 name' }));
fireEvent.change(screen.getByRole('textbox', { name: '筛选 name' }), { target: { value: 'Ada' } });
expect(onFilter).toHaveBeenCalledWith('name', 'Ada');
fireEvent.click(screen.getByText('Ada'));
expect(onSelectRow).toHaveBeenCalledWith(0, 'single');
fireEvent.click(screen.getByText('Ada'), { ctrlKey: true });
expect(onSelectRow).toHaveBeenCalledWith(0, 'toggle');
fireEvent.click(screen.getByText('Ada'), { shiftKey: true });
expect(onSelectRow).toHaveBeenCalledWith(0, 'range');
fireEvent.keyDown(screen.getByText('Ada'), { key: ' ' });
expect(onSelectRow).toHaveBeenCalledTimes(4);
fireEvent.keyDown(screen.getByRole('separator', { name: '调整 name 列宽' }), { key: 'ArrowRight' });
expect(onWidth).toHaveBeenCalledWith('name', 150);
expect(screen.getByRole('table')).toHaveStyle({ width: '280px' });
fireEvent.keyDown(screen.getByRole('textbox', { name: '筛选 name' }), { key: 'Escape' });
expect(screen.queryByRole('textbox', { name: '筛选 name' })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,111 @@
import { useMemo, useState, type ClipboardEvent, type KeyboardEvent, type PointerEvent } from 'react';
import type { ChangeSet, ColumnMeta } from '../../types';
import { changeKey } from '../../core/changes';
import { zhCN } from '../../i18n/zh-CN';
import styles from './grid.module.css';
// 数据网格:列排序、筛选、列宽拖拽、行选中与单元格编辑。
export interface GridRow extends Record<string, unknown> { _tempId?: string }
export type SelectionMode = 'single' | 'toggle' | 'range';
interface DataGridProps {
table: string;
columns: ColumnMeta[];
rows: GridRow[];
changes: ChangeSet;
nullText: string;
sort?: { column: string; dir: 'asc' | 'desc' };
filters: Record<string, string>;
widths: Record<string, number>;
selectedRows: Set<string>;
onSort: (column: string) => void;
onFilter: (column: string, expression: string) => void;
onWidth: (column: string, width: number) => void;
onSelectRow: (index: number, mode: SelectionMode) => void;
onEdit: (row: GridRow, column: string, oldValue: unknown, newValue: unknown) => void;
onDelete: (row: GridRow) => void;
}
interface EditingCell { rowIndex: number; column: string; value: unknown }
// 行键:临时行用 tempId,其余用主键。
export function rowKey(row: GridRow, index: number): string { return row._tempId ?? String(row.id ?? index); }
function valueFor(row: GridRow, table: string, column: string, changes: ChangeSet): unknown {
if (row._tempId) return changes.inserts.get(row._tempId)?.values[column] ?? row[column];
return changes.updates.get(changeKey(table, row.id, column))?.newValue ?? row[column];
}
function Editor({ column, value, onChange, onKeyDown, onBlur }: { column: ColumnMeta; value: unknown; onChange: (value: unknown) => void; onKeyDown: (event: KeyboardEvent<HTMLInputElement | HTMLSelectElement>) => void; onBlur: () => void }) {
if (column.type === 'bool') return <select autoFocus aria-label={zhCN.editCell(column.name)} value={String(value)} onChange={(event) => onChange(event.target.value)} onKeyDown={onKeyDown} onBlur={onBlur}><option value="true">true</option><option value="false">false</option></select>;
return <input autoFocus aria-label={zhCN.editCell(column.name)} type={column.type === 'int' ? 'number' : column.type === 'date' ? 'date' : 'text'} value={String(value ?? '')} onChange={(event) => onChange(event.target.value)} onKeyDown={onKeyDown} onBlur={onBlur} />;
}
export function DataGrid(props: DataGridProps) {
const [editing, setEditing] = useState<EditingCell>();
const [filterColumn, setFilterColumn] = useState<string>();
const deleted = useMemo(() => new Set(Array.from(props.changes.deletes.values()).filter((item) => item.table === props.table).map((item) => String(item.pk))), [props.changes, props.table]);
const commitEdit = () => {
if (!editing) return;
const row = props.rows[editing.rowIndex];
const column = props.columns.find((item) => item.name === editing.column);
if (!row || !column) return setEditing(undefined);
let next: unknown = editing.value;
if (column.type === 'int') next = Number(editing.value);
if (column.type === 'bool') next = editing.value === true || editing.value === 'true';
props.onEdit(row, column.name, valueFor(row, props.table, column.name, props.changes), next);
setEditing(undefined);
};
const onEditorKeyDown = (event: KeyboardEvent<HTMLInputElement | HTMLSelectElement>) => {
if (event.key === 'Escape') setEditing(undefined);
if (event.key === 'Enter' || event.key === 'Tab') { event.preventDefault(); commitEdit(); }
};
const paste = (event: ClipboardEvent<HTMLTableCellElement>, rowIndex: number, columnIndex: number) => {
const matrix = event.clipboardData.getData('text/plain').replace(/\r/g, '').split('\n').filter((line) => line.length).map((line) => line.split('\t'));
if (!matrix.length) return;
event.preventDefault();
matrix.forEach((values, y) => values.forEach((value, x) => {
const row = props.rows[rowIndex + y];
const column = props.columns[columnIndex + x];
if (row && column && !column.primaryKey) props.onEdit(row, column.name, valueFor(row, props.table, column.name, props.changes), value);
}));
};
const beginResize = (event: PointerEvent<HTMLSpanElement>, column: string) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = props.widths[column] ?? 140;
const move = (next: globalThis.PointerEvent) => props.onWidth(column, Math.max(70, startWidth + next.clientX - startX));
const stop = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', stop); };
window.addEventListener('pointermove', move); window.addEventListener('pointerup', stop);
};
return (
<table className={styles.grid} style={{ width: props.columns.reduce((sum, column) => sum + (props.widths[column.name] ?? 140), 0) }}>
<colgroup>{props.columns.map((column) => <col key={column.name} style={{ width: props.widths[column.name] ?? 140 }} />)}</colgroup>
<thead>
<tr>{props.columns.map((column) => <th key={column.name} aria-sort={props.sort?.column === column.name ? props.sort.dir === 'asc' ? 'ascending' : 'descending' : 'none'}>
<div className={styles.columnHeader}><button className={styles.columnButton} aria-label={column.name} onClick={() => props.onSort(column.name)}>{column.name}<span>{props.sort?.column === column.name ? props.sort.dir === 'asc' ? '▲' : '▼' : ''}</span></button>
<button className={styles.filterButton} aria-label={zhCN.filter(column.name)} aria-expanded={filterColumn === column.name} aria-pressed={Boolean(props.filters[column.name])} onClick={() => setFilterColumn(filterColumn === column.name ? undefined : column.name)}><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true"><path d="M2 3h12L9.5 8v5l-3-1.5V8Z" /></svg></button></div>
{filterColumn === column.name && <div className={styles.filterPopover}><input autoFocus aria-label={zhCN.filter(column.name)} placeholder={zhCN.filterPlaceholder} value={props.filters[column.name] ?? ''} onChange={(event) => props.onFilter(column.name, event.target.value)} onKeyDown={(event) => { if (event.key === 'Escape' || event.key === 'Enter') { setFilterColumn(undefined); event.currentTarget.closest('th')?.querySelector('button')?.focus(); } }} /><button onClick={() => props.onFilter(column.name, '')}>{zhCN.clear}</button></div>}
<span className={styles.resizeHandle} role="separator" aria-label={zhCN.resizeColumn(column.name)} aria-orientation="vertical" tabIndex={0} onKeyDown={(event) => { if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); props.onWidth(column.name, Math.max(70, (props.widths[column.name] ?? 140) + (event.key === 'ArrowRight' ? 10 : -10))); } }} onPointerDown={(event) => beginResize(event, column.name)} />
</th>)}</tr>
</thead>
<tbody>
{props.rows.map((row, rowIndex) => {
const key = rowKey(row, rowIndex);
const isDeleted = !row._tempId && deleted.has(String(row.id));
return <tr key={key} aria-selected={props.selectedRows.has(key)} className={`${isDeleted ? styles.deleted : ''} ${row._tempId ? styles.inserted : ''}`} onContextMenu={(event) => { event.preventDefault(); props.onDelete(row); }}>
{props.columns.map((column, columnIndex) => {
const value = valueFor(row, props.table, column.name, props.changes);
const dirty = row._tempId || props.changes.updates.has(changeKey(props.table, row.id, column.name));
const isEditing = editing?.rowIndex === rowIndex && editing.column === column.name;
return <td key={column.name} className={dirty ? styles.dirty : ''} tabIndex={0} onClick={(event) => { if (!isEditing && event.detail < 2) props.onSelectRow(rowIndex, event.ctrlKey || event.metaKey ? 'toggle' : event.shiftKey ? 'range' : 'single'); }} onKeyDown={(event) => { if (!isEditing && event.key === ' ') { event.preventDefault(); props.onSelectRow(rowIndex, event.ctrlKey || event.metaKey ? 'toggle' : 'single'); } }} onPaste={(event) => paste(event, rowIndex, columnIndex)} onDoubleClick={() => !column.primaryKey && setEditing({ rowIndex, column: column.name, value })}>
{isEditing ? <Editor column={column} value={editing.value} onChange={(value) => setEditing({ ...editing, value })} onKeyDown={onEditorKeyDown} onBlur={commitEdit} /> : value === null || value === undefined ? <span className={styles.nullValue}>{props.nullText}</span> : String(value)}
</td>;
})}
</tr>;
})}
</tbody>
</table>
);
}
@@ -0,0 +1,16 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, it, vi } from 'vitest';
import { RowDialog } from './RowDialog';
// 插入行对话框测试:整数校验与暂存值。
it('requires an explicit integer key and stages typed values without generating an id', () => {
const onInsert = vi.fn();
render(<RowDialog table={{ name: 'users', rowCount: 500, columns: [{ name: 'id', type: 'int', primaryKey: true }, { name: 'name', type: 'str' }, { name: 'active', type: 'bool' }] }} onClose={vi.fn()} onInsert={onInsert} />);
expect(screen.getByLabelText('id')).toHaveValue('');
fireEvent.click(screen.getByRole('button', { name: '加入待提交' }));
expect(onInsert).not.toHaveBeenCalled();
expect(screen.getByRole('alert')).toHaveTextContent('id:请输入合法整数');
fireEvent.change(screen.getByLabelText('id'), { target: { value: '800' } });
fireEvent.change(screen.getByLabelText('name'), { target: { value: "Ada's" } });
fireEvent.click(screen.getByRole('button', { name: '加入待提交' }));
expect(onInsert).toHaveBeenCalledWith({ id: 800, name: "Ada's", active: false });
});
@@ -0,0 +1,26 @@
import { useState } from 'react';
import { columnTypeLabels, zhCN } from '../../i18n/zh-CN';
import type { TableMeta } from '../../types';
import { Modal } from '../common/Modal';
import styles from './grid.module.css';
// 插入行对话框:按列类型校验并暂存待提交变更。
export function RowDialog({ table, onClose, onInsert }: { table: TableMeta; onClose: () => void; onInsert: (values: Record<string, unknown>) => void }) {
const [draft, setDraft] = useState<Record<string, string>>(() => Object.fromEntries(table.columns.map((column) => [column.name, column.type === 'bool' ? 'false' : ''])));
const [error, setError] = useState('');
const submit = () => {
const values: Record<string, unknown> = {};
for (const column of table.columns) {
const value = draft[column.name];
if (column.type === 'int' && (!/^-?\d+$/.test(value) || !Number.isSafeInteger(Number(value)))) return setError(zhCN.invalidInteger(column.name));
if (column.type === 'date' && !/^\d{4}-\d{2}-\d{2}$/.test(value)) return setError(zhCN.chooseDate(column.name));
values[column.name] = column.type === 'int' ? Number(value) : column.type === 'bool' ? value === 'true' : value;
}
onInsert(values);
};
return <Modal title={zhCN.insertRowTitle(table.name)} onClose={onClose} footer={<><button onClick={onClose}>{zhCN.cancel}</button><button className={styles.primaryButton} onClick={submit}>{zhCN.addToPending}</button></>}>
<p>{zhCN.insertRowHint}</p>
<div className={styles.formFields}>{table.columns.map((column) => <label key={column.name}><span>{column.name} <small>{columnTypeLabels[column.type] ?? column.type}{column.primaryKey ? zhCN.primaryKeyBadge : ''}</small></span>{column.type === 'bool' ? <select aria-label={column.name} value={draft[column.name]} onChange={(event) => setDraft({ ...draft, [column.name]: event.target.value })}><option value="false">{zhCN.booleanFalse}</option><option value="true">{zhCN.booleanTrue}</option></select> : <input aria-label={column.name} type={column.type === 'date' ? 'date' : 'text'} inputMode={column.type === 'int' ? 'numeric' : undefined} value={draft[column.name]} onChange={(event) => setDraft({ ...draft, [column.name]: event.target.value })} />}</label>)}</div>
{error && <p role="alert">{error}</p>}
</Modal>;
}
@@ -0,0 +1,113 @@
import { Plus, RefreshCw, Replace, Trash2 } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { DataSourceAdapter, FilterExpr, TableMeta } from '../../types';
import type { IdeSettings } from '../../store/settingsStore';
import { useChangeStore } from '../../store/changeStore';
import { zhCN } from '../../i18n/zh-CN';
import { IconButton } from '../common/IconButton';
import { DataGrid, rowKey, type GridRow, type SelectionMode } from './DataGrid';
import { RowDialog } from './RowDialog';
import styles from './grid.module.css';
// 表数据编辑器:分页加载、工具栏操作与批量删除/替换。
interface TableEditorProps { adapter: DataSourceAdapter; table: TableMeta; settings: IdeSettings; revision: number; onTotal: (count: number) => void; onMessage: (message: string) => void }
export function TableEditor({ adapter, table, settings, revision, onTotal, onMessage }: TableEditorProps) {
const [rows, setRows] = useState<GridRow[]>([]);
const [page, setPage] = useState(0);
const [total, setTotal] = useState(0);
const [sort, setSort] = useState<{ column: string; dir: 'asc' | 'desc' }>();
const [filters, setFilters] = useState<Record<string, string>>({});
const [widths, setWidths] = useState<Record<string, number>>(() => JSON.parse(localStorage.getItem(`chusql.widths.${table.name}`) ?? '{}'));
const [selectedRows, setSelectedRows] = useState(new Set<string>());
const [selectionAnchor, setSelectionAnchor] = useState<number>();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [replaceOpen, setReplaceOpen] = useState(false);
const [find, setFind] = useState('');
const [replacement, setReplacement] = useState('');
const [insertOpen, setInsertOpen] = useState(false);
const requestId = useRef(0);
const { changes, updateCell, insertRow, updateInsert, deleteRow } = useChangeStore();
const inserted = useMemo(() => Array.from(changes.inserts.values()).filter((item) => item.table === table.name).map((item): GridRow => ({ ...item.values, _tempId: item.tempId })), [changes, table.name]);
const visibleRows: GridRow[] = [...rows, ...inserted];
const activeFilters: FilterExpr[] = Object.entries(filters).filter(([, value]) => value.trim()).map(([column, expression]) => ({ column, expression }));
const load = async (nextPage: number, append: boolean) => {
const id = ++requestId.current; setLoading(true); setError('');
try {
const result = await adapter.fetchRows({ db: zhCN.database, table: table.name, page: nextPage, pageSize: settings.pageSize, sort: sort ? [sort] : [], filters: activeFilters });
if (id !== requestId.current) return;
setRows((current) => append ? [...current, ...result.rows] : result.rows);
if (!append) { setSelectedRows(new Set()); setSelectionAnchor(undefined); }
setTotal(result.total); setPage(nextPage); onTotal(result.total);
} catch (reason) { if (id === requestId.current) setError(reason instanceof Error ? reason.message : String(reason)); }
finally { if (id === requestId.current) setLoading(false); }
};
useEffect(() => { const timer = window.setTimeout(() => void load(0, false), 180); return () => window.clearTimeout(timer); }, [table.name, settings.pageSize, sort?.column, sort?.dir, JSON.stringify(filters), revision]);
const sortColumn = (column: string) => setSort((current) => current?.column !== column ? { column, dir: 'asc' } : { column, dir: current.dir === 'asc' ? 'desc' : 'asc' });
const setWidth = (column: string, width: number) => setWidths((current) => { const next = { ...current, [column]: width }; localStorage.setItem(`chusql.widths.${table.name}`, JSON.stringify(next)); return next; });
const addRow = (values: Record<string, unknown>) => {
const tempId = `temp-${crypto.randomUUID()}`;
insertRow(table.name, tempId, values);
setInsertOpen(false);
onMessage(zhCN.rowStaged);
};
const stageDelete = (row: GridRow) => {
deleteRow(table.name, row._tempId ?? row.id, row, Boolean(row._tempId));
};
const selectRow = (index: number, mode: SelectionMode) => {
const key = rowKey(visibleRows[index], index);
if (mode === 'range' && selectionAnchor !== undefined) {
const from = Math.min(selectionAnchor, index);
const to = Math.max(selectionAnchor, index);
setSelectedRows(new Set(visibleRows.slice(from, to + 1).map((row, offset) => rowKey(row, from + offset))));
return;
}
if (mode === 'toggle') {
setSelectedRows((current) => { const next = new Set(current); if (next.has(key)) next.delete(key); else next.add(key); return next; });
setSelectionAnchor(index);
return;
}
setSelectedRows(new Set([key]));
setSelectionAnchor(index);
};
const removeSelected = () => {
if (!selectedRows.size) return onMessage(zhCN.selectedRowsRequired);
visibleRows.forEach((row, index) => { if (selectedRows.has(row._tempId ?? String(row.id ?? index))) stageDelete(row); });
setSelectedRows(new Set());
setSelectionAnchor(undefined);
};
const replaceSelected = () => {
if (!selectedRows.size) return onMessage(zhCN.selectedRowsRequired);
visibleRows.forEach((row, index) => {
const key = row._tempId ?? String(row.id ?? index); if (!selectedRows.has(key)) return;
table.columns.filter((column) => !column.primaryKey && column.type === 'str').forEach((column) => {
const oldValue = row._tempId ? changes.inserts.get(row._tempId)?.values[column.name] : row[column.name];
if (String(oldValue ?? '').includes(find)) {
const next = String(oldValue ?? '').replaceAll(find, replacement);
if (row._tempId) updateInsert(row._tempId, { ...(changes.inserts.get(row._tempId)?.values ?? row), [column.name]: next });
else updateCell(table.name, row.id, column.name, row[column.name], next);
}
});
}); setReplaceOpen(false);
};
return <section className={styles.tableEditor}>
<div className={styles.toolbar}>
<IconButton icon={<RefreshCw size={14} />} label={zhCN.refresh} onClick={() => void load(0, false)} />
<IconButton icon={<Plus size={14} />} label={zhCN.insertRow} onClick={() => setInsertOpen(true)} />
<IconButton icon={<Trash2 size={14} />} label={zhCN.deleteRows} disabled={!selectedRows.size} onClick={removeSelected} />
<IconButton icon={<Replace size={14} />} label={zhCN.batchReplace} onClick={() => selectedRows.size ? setReplaceOpen(true) : onMessage(zhCN.selectedRowsRequired)} />
<span className={styles.spacer} /><span title={zhCN.selectRowsHint}>{zhCN.selectedRows(selectedRows.size)}</span><span>{zhCN.rowCount(total)}</span>
</div>
{replaceOpen && <div className={styles.replaceBar}><label>{zhCN.findText}<input value={find} onChange={(event) => setFind(event.target.value)} /></label><label>{zhCN.replaceText}<input value={replacement} onChange={(event) => setReplacement(event.target.value)} /></label><button onClick={replaceSelected}>{zhCN.replaceSelected}</button><button onClick={() => setReplaceOpen(false)}>{zhCN.close}</button></div>}
<div className={styles.viewport} onScroll={(event) => { const target = event.currentTarget; if (!loading && rows.length < total && target.scrollTop + target.clientHeight >= target.scrollHeight - 32) void load(page + 1, true); }}>
{error ? <div role="alert" className={styles.empty}>{zhCN.loadError}: {error} <button onClick={() => void load(0, false)}>{zhCN.retry}</button></div> : <DataGrid table={table.name} columns={table.columns} rows={visibleRows} changes={changes} nullText={settings.nullText} sort={sort} filters={filters} widths={widths} selectedRows={selectedRows} onSort={sortColumn} onFilter={(column, expression) => setFilters((current) => ({ ...current, [column]: expression }))} onWidth={setWidth} onSelectRow={selectRow} onEdit={(row, column, oldValue, newValue) => row._tempId ? updateInsert(row._tempId, { ...(changes.inserts.get(row._tempId)?.values ?? row), [column]: newValue }) : updateCell(table.name, row.id, column, oldValue, newValue)} onDelete={stageDelete} />}
{loading && <div className={styles.loading}>{zhCN.loading}</div>}
{!loading && !visibleRows.length && !error && <div className={styles.empty}>{zhCN.noRows}</div>}
</div>
{insertOpen && <RowDialog table={table} onClose={() => setInsertOpen(false)} onInsert={addRow} />}
</section>;
}
@@ -0,0 +1,54 @@
/* 数据网格与表结构对话框的样式 */
.tableEditor { display: flex; flex-direction: column; height: 100%; min-height: 0; }
.toolbar { align-items: center; background: var(--bg-elevated); border-bottom: 1px solid var(--border); display: flex; gap: 2px; padding: 2px 5px; }
.spacer { flex: 1; }
.replaceBar { align-items: center; background: var(--bg-overlay); border-bottom: 1px solid var(--border); display: flex; gap: 10px; min-height: 32px; padding: 3px 7px; }
.replaceBar label { align-items: center; display: flex; gap: 5px; }
.replaceBar input { background: var(--bg-base); border: 1px solid var(--border); color: var(--fg-primary); height: 24px; }
.replaceBar button, .empty button { background: var(--accent-2); border: 1px solid var(--accent); color: #fff; height: 24px; }
.viewport { flex: 1; min-height: 0; overflow: auto; position: relative; }
.grid { border-collapse: collapse; font-family: var(--grid-font); font-size: var(--grid-font-size); table-layout: fixed; }
.grid th, .grid td { border-bottom: 1px solid var(--border-subtle); border-right: 1px solid var(--border); height: var(--grid-row-height); overflow: hidden; padding: 0 7px; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.grid thead { background: var(--bg-elevated); position: sticky; top: 0; z-index: 2; }
.grid thead th { color: var(--info); font-weight: 400; position: relative; overflow: visible; }
.filterRow th { background: var(--bg-elevated); position: sticky; top: var(--grid-row-height); }
.filterRow input { background: var(--bg-base); border: 1px solid var(--border); color: var(--fg-primary); height: 20px; padding: 0 4px; width: 100%; }
.columnButton { background: transparent; border: 0; color: var(--info); cursor: pointer; font: inherit; height: 100%; padding: 0; text-align: left; }
.columnButton span { color: var(--fg-primary); margin-left: 5px; }
.resizeHandle { bottom: 0; cursor: col-resize; position: absolute; right: -3px; top: 0; width: 7px; }
.grid thead small { color: var(--fg-muted); float: right; font-size: 10px; }
.grid tbody tr:hover { background: rgb(255 255 255 / 4%); }
.rowNumber { background: var(--bg-elevated); color: var(--fg-muted); min-width: 42px !important; text-align: right !important; width: 42px; }
.selectColumn { width: 28px; }
.numberColumn { width: 42px; }
.dirty { background: rgb(226 192 141 / 8%); position: relative; }
.dirty::before { border-right: 5px solid transparent; border-top: 5px solid var(--modified); content: ''; left: 0; position: absolute; top: 0; }
.deleted { color: var(--fg-disabled); text-decoration: line-through; }
.inserted { border-left: 2px solid var(--modified); }
.nullValue { color: var(--fg-muted); font-style: italic; }
.grid input, .grid select { background: var(--bg-base); border: 1px solid var(--accent); color: var(--fg-primary); font: inherit; height: calc(var(--grid-row-height) - 2px); margin: 0 -6px; outline: 0; padding: 0 5px; width: calc(100% + 12px); }
.empty { color: var(--fg-muted); padding: 28px; text-align: center; }
.loading { background: var(--bg-elevated); bottom: 0; color: var(--info); left: 0; padding: 4px 8px; position: sticky; }
.primaryButton { background: var(--accent-2); border: 1px solid var(--accent); color: #fff; min-height: 24px; }
.columnHeader { display: flex; align-items: center; height: var(--grid-row-height); gap: 4px; }
.columnButton { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.filterButton { display: flex; align-items: center; justify-content: center; background: transparent; border: 0; padding: 2px; color: var(--fg-muted); flex-shrink: 0; cursor: pointer; }
.filterButton:hover, .filterButton[aria-pressed="true"] { color: var(--info); background: var(--bg-active); }
.filterPopover { position: absolute; top: 100%; left: 0; z-index: 4; padding: 8px; width: 220px; background: var(--bg-overlay); border: 1px solid var(--border); }
.filterPopover input { width: 100%; margin: 0 0 6px; }
.filterPopover button { background: var(--bg-active); color: var(--fg-primary); border: 1px solid var(--border); }
.grid tbody tr[aria-selected="true"] { background: color-mix(in srgb, var(--accent) 18%, var(--bg-base)); }
.grid td:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
.resizeHandle { touch-action: none; z-index: 3; }
.toolbar, .replaceBar { flex-shrink: 0; }
.formFields { display: grid; gap: 12px; }
.formFields label { display: grid; gap: 5px; }
.formFields input, .formFields select, .columnDefinition input, .columnDefinition select { background: var(--bg-base); border: 1px solid var(--border); color: var(--fg-primary); padding: 5px 7px; min-width: 0; }
.formFields small, .indexList small { color: var(--fg-muted); }
.columnDefinition { display: grid; grid-template-columns: minmax(0, 1fr) 100px 56px; gap: 8px; }
.indexList { list-style: none; padding: 0; margin: 12px 0; }
.indexList li { display: grid; grid-template-columns: minmax(80px, 1fr) 120px 90px; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid var(--border); }
.operationConfirm { border: 1px solid var(--border); padding: 10px; }
.operationConfirm button + button { margin-left: 8px; }
.dangerButton { background: transparent; border: 1px solid var(--danger); color: var(--danger); padding: 4px 8px; }
.dangerButton:disabled { opacity: .45; }
@@ -0,0 +1,44 @@
import { Undo2 } from 'lucide-react';
import { useState } from 'react';
import type { ChangeSet, SchemaChange, StatementResult } from '../../types';
import { deleteSql, insertSql, schemaSql, updateSql } from '../../core/changeSql';
import { SQL_PLAIN_COLOR, SQL_TOKEN_COLORS, tokenizeSql } from '../../core/sqlHighlight';
import { zhCN } from '../../i18n/zh-CN';
import { IconButton } from '../common/IconButton';
import styles from './bottom.module.css';
// 底部面板:待提交变更、查询结果与消息三个标签页。
interface BottomPanelProps { changes: ChangeSet; results: StatementResult[]; messages: string[]; onUndoOne: (kind: keyof ChangeSet, key: string) => void }
// 结构变更的中文描述。
function schemaLabel(change: SchemaChange): string {
if (change.op === 'createTable') return zhCN.createTableChange(change.table);
if (change.op === 'dropTable') return zhCN.dropTableChange(change.table);
if (change.op === 'createIndex') return zhCN.createIndexChange(change.table, change.column);
return zhCN.dropIndexChange(change.table, change.column);
}
// 按执行器的 token 配色渲染一条 SQL。
function SqlText({ sql }: { sql: string }) {
return <>{tokenizeSql(sql).map((token, index) => <span key={index} style={{ color: token.kind === 'plain' ? SQL_PLAIN_COLOR : SQL_TOKEN_COLORS[token.kind] }}>{token.text}</span>)}</>;
}
export function BottomPanel({ changes, results, messages, onUndoOne }: BottomPanelProps) {
const [tab, setTab] = useState<'changes' | 'results' | 'messages'>('changes');
const [resultIndex, setResultIndex] = useState(0);
const entries = [
...Array.from(changes.updates, ([key, value]) => ({ kind: 'updates' as const, key, label: zhCN.updateChange(value.table, value.column), detail: updateSql(value.table, value.pk, value.column, value.newValue) })),
...Array.from(changes.inserts, ([key, value]) => ({ kind: 'inserts' as const, key, label: zhCN.insertChange(value.table), detail: insertSql(value.table, value.values) })),
...Array.from(changes.deletes, ([key, value]) => ({ kind: 'deletes' as const, key, label: zhCN.deleteChange(value.table), detail: deleteSql(value.table, value.pk) })),
...Array.from(changes.schemas, ([key, value]) => ({ kind: 'schemas' as const, key, label: schemaLabel(value), detail: schemaSql(value) })),
];
const result = results[resultIndex];
return <div className={styles.panel}>
<nav><button className={tab === 'changes' ? styles.active : ''} onClick={() => setTab('changes')}>{zhCN.pendingChanges}</button><button className={tab === 'results' ? styles.active : ''} onClick={() => setTab('results')}>{zhCN.results}</button><button className={tab === 'messages' ? styles.active : ''} onClick={() => setTab('messages')}>{zhCN.messages}</button></nav>
<div className={styles.body}>
{tab === 'changes' && (entries.length ? <ul className={styles.changes}>{entries.map((entry) => <li key={`${entry.kind}-${entry.key}`}><strong>{entry.label}</strong><code title={entry.detail}><SqlText sql={entry.detail} /></code><IconButton compact icon={<Undo2 size={13} />} label={zhCN.undoChange} onClick={() => onUndoOne(entry.kind, entry.key)} /></li>)}</ul> : <p>{zhCN.noPendingChanges}</p>)}
{tab === 'results' && (result ? <><select aria-label={zhCN.results} value={resultIndex} onChange={(event) => setResultIndex(Number(event.target.value))}>{results.map((item, index) => <option key={index} value={index}>{zhCN.resultLabel(index + 1, item.rows?.length ?? item.affected ?? 0, item.durationMs)}</option>)}</select>{result.type === 'rows' ? <div className={styles.resultScroll}><table><thead><tr>{result.columns?.map((column) => <th key={column}>{column}</th>)}</tr></thead><tbody>{result.rows?.map((row, rowIndex) => <tr key={rowIndex}>{row.map((value, columnIndex) => <td key={columnIndex}>{value === null ? zhCN.nullValue : String(value)}</td>)}</tr>)}</tbody></table></div> : <p>{result.message ?? zhCN.executionAffected(result.affected ?? 0, result.durationMs)}</p>}</> : <p>{zhCN.noResults}</p>)}
{tab === 'messages' && <ol className={styles.messages}>{messages.map((message, index) => <li key={`${index}-${message}`}>{message}</li>)}</ol>}
</div>
</div>;
}
@@ -0,0 +1,23 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, it, vi } from 'vitest';
import { Explorer } from './Explorer';
// 资源管理器测试:列图标、展开收起与打开表。
it('expands columns with distinct primary key, index and ordinary column icons', () => {
const onOpenTable = vi.fn();
render(<Explorer tables={[{ name: 'users', rowCount: 1, columns: [
{ name: 'id', type: 'int', primaryKey: true },
{ name: 'email', type: 'str', indexed: true },
{ name: 'name', type: 'str' },
] }]} history={[]} onOpenTable={onOpenTable} onOpenSql={vi.fn()} />);
const expand = screen.getByRole('button', { name: '展开 users 的列' });
expect(screen.queryByText('email')).not.toBeInTheDocument();
fireEvent.click(expand);
expect(expand).toHaveAttribute('aria-expanded', 'true');
for (const name of ['主键列', '索引列', '普通列']) expect(screen.getByRole('img', { name })).toBeInTheDocument();
expect(onOpenTable).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '打开表 users' }));
expect(onOpenTable).toHaveBeenCalledWith('users');
fireEvent.click(expand);
expect(screen.queryByText('email')).not.toBeInTheDocument();
});
@@ -0,0 +1,59 @@
import { ChevronDown, Database, FileClock, GitCompareArrows, Table2, Plus, ListFilter, Trash2 } from 'lucide-react';
import { IconButton } from '../common/IconButton';
import type { ColumnMeta, TableMeta } from '../../types';
import { columnTypeLabels, zhCN } from '../../i18n/zh-CN';
import styles from './layout.module.css';
import { useState } from 'react';
// 资源管理器:库-表-列树、变更集与查询历史入口。
interface ExplorerProps {
tables: TableMeta[];
activeTable?: string;
history: Array<{ sql: string; at: string }>;
onOpenTable: (name: string) => void;
onOpenSql: (sql?: string) => void;
onCreateTable?: () => void;
onIndexes?: (table: TableMeta) => void;
onDropTable?: (table: TableMeta) => void;
}
// 列图标的可访问名,按主键/索引/普通区分。
function columnKind(column: ColumnMeta): string {
if (column.primaryKey) return zhCN.columnPrimaryKey;
return column.indexed ? zhCN.columnIndexed : zhCN.columnOrdinary;
}
export function Explorer({ tables, activeTable, history, onOpenTable, onOpenSql, onCreateTable, onIndexes, onDropTable }: ExplorerProps) {
const [expanded, setExpanded] = useState(new Set<string>());
return (
<aside className={styles.sidebar} aria-label={zhCN.explorer}>
<h2>{zhCN.explorer}</h2>
{onCreateTable && <IconButton icon={<Plus size={14} />} label={zhCN.newTable} onClick={onCreateTable} />}
<div className={styles.sectionTitle}><ChevronDown size={13} />{zhCN.databaseSection}</div>
<div className={styles.treeRoot}><Database size={14} /><span>{zhCN.database}</span></div>
<ul className={styles.tableList}>
{tables.map((table) => (
<li key={table.name}>
<div className={styles.tableEntry}>
<button className={styles.expandButton} aria-label={zhCN.expandColumns(table.name)} aria-expanded={expanded.has(table.name)} onClick={() => setExpanded((current) => { const next = new Set(current); if (next.has(table.name)) next.delete(table.name); else next.add(table.name); return next; })}><svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d={expanded.has(table.name) ? 'm4 6 4 4 4-4' : 'm6 4 4 4-4 4'} /></svg></button>
<button
className={activeTable === table.name ? styles.activeTreeItem : styles.treeItem}
aria-label={zhCN.openTable(table.name)}
onClick={() => onOpenTable(table.name)}
>
<Table2 size={13} /><span>{table.name}</span><small>{table.rowCount}</small>
</button>
{onIndexes && <IconButton compact icon={<ListFilter size={13} />} label={zhCN.manageIndexes(table.name)} onClick={() => onIndexes(table)} />}
{onDropTable && <IconButton compact icon={<Trash2 size={13} />} label={zhCN.dropTableAction(table.name)} onClick={() => onDropTable(table)} />}
</div>
{expanded.has(table.name) && <ul className={styles.columnList}>{table.columns.map((column) => <li key={column.name}><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" role="img" aria-label={columnKind(column)}><title>{columnKind(column)}</title>{column.primaryKey ? <><circle cx="5" cy="5" r="3" /><path d="m7 7 6 6m-3-3 2-2m0 4 2-2" /></> : column.indexed ? <><path d="M2 3h9v10H2ZM5 3v10M2 6h9M2 10h9m10-5 4 3-4 3" /></> : <><rect x="4" y="2" width="8" height="12" rx="1" /><path d="M4 6h8M4 10h8" /></>}</svg><span>{column.name}</span><small>{columnTypeLabels[column.type] ?? column.type}</small></li>)}</ul>}
</li>
))}
</ul>
<div className={styles.sectionTitle}><GitCompareArrows size={13} />{zhCN.changesSection}</div>
<div className={styles.sectionTitle}><FileClock size={13} />{zhCN.historySection}</div>
<button className={styles.treeItem} onClick={() => onOpenSql()}>{zhCN.newQuery}</button>
<ul className={styles.historyList}>{history.slice(0, 8).map((entry) => <li key={`${entry.at}-${entry.sql}`}><button title={entry.sql} onClick={() => onOpenSql(entry.sql)}>{entry.sql}</button></li>)}</ul>
</aside>
);
}
@@ -0,0 +1,73 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { MockAdapter } from '../../adapters/MockAdapter';
import { useChangeStore } from '../../store/changeStore';
import { SchemaDialog } from './SchemaDialog';
// 建表/删表/索引对话框测试:校验、直接响应与结构变更暂存。
afterEach(cleanup);
beforeEach(() => useChangeStore.getState().clear());
it('stages a create table change with explicit columns and rejects duplicate columns', () => {
const onChanged = vi.fn();
const onClose = vi.fn();
render(<SchemaDialog action={{ kind: 'create' }} onClose={onClose} onChanged={onChanged} />);
fireEvent.change(screen.getByLabelText('表名'), { target: { value: 'items' } });
fireEvent.change(screen.getByLabelText('列名 2'), { target: { value: 'id' } });
fireEvent.click(screen.getByRole('button', { name: '建表' }));
expect(screen.getByRole('alert')).toHaveTextContent('列名重复');
expect(useChangeStore.getState().changes.schemas.size).toBe(0);
fireEvent.change(screen.getByLabelText('列名 2'), { target: { value: 'title' } });
fireEvent.click(screen.getByRole('button', { name: '建表' }));
expect(onChanged).toHaveBeenCalledWith('已暂存:CREATE TABLE items (id int, title str);');
expect(onClose).toHaveBeenCalled();
expect([...useChangeStore.getState().changes.schemas.values()]).toEqual([
{ op: 'createTable', table: 'items', columns: [{ name: 'id', type: 'int' }, { name: 'title', type: 'str' }] },
]);
});
it('stages a drop table change on the first click without asking for the name', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
const table = (await adapter.listTables('chusql'))[0];
const onChanged = vi.fn();
render(<SchemaDialog action={{ kind: 'drop', table }} onClose={vi.fn()} onChanged={onChanged} />);
expect(screen.queryByLabelText(/输入/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '永久删除表' }));
expect(onChanged).toHaveBeenCalledWith('已暂存:DROP TABLE users;');
expect([...useChangeStore.getState().changes.schemas.values()]).toEqual([{ op: 'dropTable', table: 'users' }]);
expect((await adapter.listTables('chusql')).some((item) => item.name === 'users')).toBe(true);
});
it('protects the built-in primary key index and non-integer columns', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
const table = (await adapter.listTables('chusql'))[0];
render(<SchemaDialog action={{ kind: 'indexes', table }} onClose={vi.fn()} onChanged={vi.fn()} />);
const rows = screen.getAllByRole('listitem');
expect(within(rows[0]).getByRole('button')).toBeDisabled();
expect(within(rows[1]).getByRole('button')).toBeDisabled();
expect(within(rows[2]).getByRole('button')).toBeEnabled();
});
it('stages index creation and deletion on the first click and reflects the pending state', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
await adapter.createTable('items', [{ name: 'id', type: 'int' }, { name: 'sku', type: 'int' }]);
const table = (await adapter.listTables('chusql')).find((item) => item.name === 'items')!;
const onChanged = vi.fn();
render(<SchemaDialog action={{ kind: 'indexes', table }} onClose={vi.fn()} onChanged={onChanged} />);
expect(screen.getByText('无索引')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '建索引' }));
expect(screen.getByText('唯一索引')).toBeInTheDocument();
expect(onChanged).toHaveBeenCalledWith('已暂存:CREATE INDEX ON items (sku);');
fireEvent.click(within(screen.getAllByRole('listitem')[1]).getByRole('button', { name: '删索引' }));
expect(screen.getByText('无索引')).toBeInTheDocument();
expect([...useChangeStore.getState().changes.schemas.keys()]).toEqual(['createIndex:items:sku', 'dropIndex:items:sku']);
});
it('leaves nothing staged when the create dialog is cancelled', () => {
const onClose = vi.fn();
render(<SchemaDialog action={{ kind: 'create' }} onClose={onClose} onChanged={vi.fn()} />);
fireEvent.change(screen.getByLabelText('表名'), { target: { value: 'items' } });
fireEvent.click(screen.getByRole('button', { name: '取消' }));
expect(onClose).toHaveBeenCalled();
expect(useChangeStore.getState().changes.schemas.size).toBe(0);
});
@@ -0,0 +1,69 @@
import { useState } from 'react';
import { schemaSql } from '../../core/changeSql';
import { columnTypeLabels, zhCN } from '../../i18n/zh-CN';
import { useChangeStore } from '../../store/changeStore';
import type { ColumnMeta, SchemaChange, TableMeta } from '../../types';
import { Modal } from '../common/Modal';
import styles from '../grid/grid.module.css';
// 建表、删表与索引对话框:结构变更一律暂存,提交时才下发。
export type SchemaAction = { kind: 'create' } | { kind: 'drop' | 'indexes'; table: TableMeta };
const identifier = /^[A-Za-z_][A-Za-z0-9_]*$/;
// 列类型的中文名,未知类型原样显示。
function typeLabel(type: string): string {
return columnTypeLabels[type] ?? type;
}
export function SchemaDialog({ action, onClose, onChanged }: {
action: SchemaAction; onClose: () => void; onChanged: (message: string) => void;
}) {
const [name, setName] = useState('');
const [columns, setColumns] = useState<Array<Pick<ColumnMeta, 'name' | 'type'>>>([{ name: 'id', type: 'int' }, { name: '', type: 'str' }]);
const [error, setError] = useState('');
const stageSchema = useChangeStore((state) => state.stageSchema);
const schemas = useChangeStore((state) => state.changes.schemas);
const target = action.kind === 'create' ? undefined : action.table;
const stagedIndex = new Map<string, boolean>();
for (const change of schemas.values()) {
if (change.table !== target?.name) continue;
if (change.op === 'createIndex') stagedIndex.set(change.column, true);
else if (change.op === 'dropIndex') stagedIndex.set(change.column, false);
}
const stage = (change: SchemaChange) => { stageSchema(change); onChanged(zhCN.stagedMessage(schemaSql(change))); };
const create = () => {
if (!identifier.test(name) || columns.some((column) => !identifier.test(column.name))) return setError(zhCN.invalidIdentifier);
if (new Set(columns.map((column) => column.name)).size !== columns.length) return setError(zhCN.duplicateColumns);
stage({ op: 'createTable', table: name, columns });
onClose();
};
const drop = () => {
if (!target) return;
stage({ op: 'dropTable', table: target.name });
onClose();
};
const changeIndex = (column: string, indexed: boolean) => {
if (!target) return;
stage(indexed
? { op: 'dropIndex', table: target.name, column }
: { op: 'createIndex', table: target.name, column });
};
const title = action.kind === 'create' ? zhCN.newTableTitle : `${action.kind === 'drop' ? zhCN.dropTableTitle : zhCN.indexManagerTitle} · ${target?.name}`;
return <Modal title={title} onClose={onClose} footer={<><button onClick={onClose}>{action.kind === 'indexes' ? zhCN.done : zhCN.cancel}</button>{action.kind === 'create' && <button className={styles.primaryButton} onClick={create}>{zhCN.createTable}</button>}{action.kind === 'drop' && <button className={styles.dangerButton} onClick={drop}>{zhCN.dropTablePermanently}</button>}</>}>
{action.kind === 'create' ? <div className={styles.formFields}>
<label>{zhCN.tableName}<input autoFocus aria-label={zhCN.tableName} value={name} onChange={(event) => setName(event.target.value)} /></label>
<p>{zhCN.createTableHint}</p>
{columns.map((column, index) => <div className={styles.columnDefinition} key={index}><input aria-label={zhCN.columnName(index + 1)} value={column.name} disabled={index === 0} onChange={(event) => setColumns(columns.map((item, i) => i === index ? { ...item, name: event.target.value } : item))} /><select aria-label={zhCN.columnType(index + 1)} value={column.type} disabled={index === 0} onChange={(event) => setColumns(columns.map((item, i) => i === index ? { ...item, type: event.target.value as ColumnMeta['type'] } : item))}><option value="int">{zhCN.typeInt}</option><option value="str">{zhCN.typeStr}</option><option value="bool">{zhCN.typeBool}</option></select><button disabled={index === 0} aria-label={zhCN.removeColumn(index + 1)} onClick={() => setColumns(columns.filter((_, i) => i !== index))}>{zhCN.remove}</button></div>)}
<button onClick={() => setColumns([...columns, { name: '', type: 'str' }])}>{zhCN.addColumn}</button>
</div> : action.kind === 'drop' ? <>
<p>{zhCN.dropTableHint(target?.name ?? '', target?.rowCount ?? 0)}</p>
</> : <>
<p>{zhCN.indexHint}</p>
<ul className={styles.indexList}>{target?.columns.map((column) => {
const indexed = stagedIndex.get(column.name) ?? Boolean(column.indexed);
return <li key={column.name}><span>{column.name} <small>{typeLabel(column.type)}</small></span><span>{column.primaryKey || column.name === 'id' ? zhCN.builtinIndex : indexed ? zhCN.uniqueIndex : zhCN.noIndex}</span><button disabled={column.name === 'id' || (!indexed && column.type !== 'int')} onClick={() => changeIndex(column.name, indexed)}>{indexed ? zhCN.deleteIndex : zhCN.createIndex}</button></li>;
})}</ul>
</>}
{error && <p role="alert">{error}</p>}
</Modal>;
}
@@ -0,0 +1,149 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { zhCN } from '../../i18n/zh-CN';
import { Workbench } from './Workbench';
// 工作台布局测试:拖动、方向键、夹紧范围与本地存储持久化。
// 用最简桩节点渲染工作台
function renderWorkbench() {
render(<Workbench explorer={<div />} editor={<div />} bottom={<div />} status={<div />} changeCount={0} canUndo={false} canRedo={false} onCommit={vi.fn()} onRollback={vi.fn()} onUndo={vi.fn()} onRedo={vi.fn()} onSql={vi.fn()} onSettings={vi.fn()} />);
}
// 取侧边栏与船坞两条拖动条
function splitters() {
return { sidebar: screen.getByRole('separator', { name: zhCN.resizeSidebar }), dock: screen.getByRole('separator', { name: zhCN.resizeDock }) };
}
// 取底部船坞默认高度
function defaultDock() { return Math.round(window.innerHeight * 0.22); }
// 取底部船坞高度上限
function dockCeiling() { return Math.max(96, Math.round(window.innerHeight * 0.7)); }
beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear());
it('resizes the sidebar and the dock by 16px with arrow keys', () => {
renderWorkbench();
const { sidebar, dock } = splitters();
expect(sidebar).toHaveAttribute('aria-valuenow', '250');
fireEvent.keyDown(sidebar, { key: 'ArrowRight' });
expect(sidebar).toHaveAttribute('aria-valuenow', '266');
fireEvent.keyDown(sidebar, { key: 'ArrowLeft' });
expect(sidebar).toHaveAttribute('aria-valuenow', '250');
expect(dock).toHaveAttribute('aria-valuenow', String(defaultDock()));
fireEvent.keyDown(dock, { key: 'ArrowUp' });
expect(dock).toHaveAttribute('aria-valuenow', String(defaultDock() + 16));
fireEvent.keyDown(dock, { key: 'ArrowDown' });
expect(dock).toHaveAttribute('aria-valuenow', String(defaultDock()));
});
it('clamps the sidebar to 160..560 and the dock to 96 and above', () => {
renderWorkbench();
const { sidebar, dock } = splitters();
for (let i = 0; i < 30; i += 1) fireEvent.keyDown(sidebar, { key: 'ArrowLeft' });
expect(sidebar).toHaveAttribute('aria-valuenow', '160');
fireEvent.keyDown(sidebar, { key: 'ArrowLeft' });
expect(sidebar).toHaveAttribute('aria-valuenow', '160');
for (let i = 0; i < 40; i += 1) fireEvent.keyDown(sidebar, { key: 'ArrowRight' });
expect(sidebar).toHaveAttribute('aria-valuenow', '560');
fireEvent.keyDown(sidebar, { key: 'ArrowRight' });
expect(sidebar).toHaveAttribute('aria-valuenow', '560');
for (let i = 0; i < 30; i += 1) fireEvent.keyDown(dock, { key: 'ArrowDown' });
expect(dock).toHaveAttribute('aria-valuenow', '96');
fireEvent.keyDown(dock, { key: 'ArrowDown' });
expect(dock).toHaveAttribute('aria-valuenow', '96');
for (let i = 0; i < 60; i += 1) fireEvent.keyDown(dock, { key: 'ArrowUp' });
expect(dock).toHaveAttribute('aria-valuenow', String(dockCeiling()));
});
it('exposes separator roles with orientation, value and bounds', () => {
renderWorkbench();
const { sidebar, dock } = splitters();
expect(sidebar).toHaveAttribute('aria-orientation', 'vertical');
expect(sidebar).toHaveAttribute('aria-valuemin', '160');
expect(sidebar).toHaveAttribute('aria-valuemax', '560');
expect(sidebar).toHaveAttribute('tabindex', '0');
expect(dock).toHaveAttribute('aria-orientation', 'horizontal');
expect(dock).toHaveAttribute('aria-valuemin', '96');
expect(dock).toHaveAttribute('aria-valuemax', String(dockCeiling()));
expect(dock).toHaveAttribute('tabindex', '0');
});
it('stores changed sizes in localStorage under chusql.layout.v1', () => {
renderWorkbench();
const { sidebar, dock } = splitters();
fireEvent.keyDown(sidebar, { key: 'ArrowRight' });
fireEvent.keyDown(dock, { key: 'ArrowUp' });
expect(JSON.parse(localStorage.getItem('chusql.layout.v1') ?? '{}')).toEqual({ sidebarWidth: 266, dockHeight: defaultDock() + 16 });
});
it('restores saved sizes from localStorage on the first render', () => {
localStorage.setItem('chusql.layout.v1', JSON.stringify({ sidebarWidth: 300, dockHeight: 150 }));
renderWorkbench();
const { sidebar, dock } = splitters();
expect(sidebar).toHaveAttribute('aria-valuenow', '300');
expect(dock).toHaveAttribute('aria-valuenow', '150');
});
it('clamps saved sizes that fall outside the allowed range', () => {
localStorage.setItem('chusql.layout.v1', JSON.stringify({ sidebarWidth: 9999, dockHeight: 1 }));
renderWorkbench();
const { sidebar, dock } = splitters();
expect(sidebar).toHaveAttribute('aria-valuenow', '560');
expect(dock).toHaveAttribute('aria-valuenow', '96');
});
it('falls back to default sizes when the stored layout is unreadable', () => {
localStorage.setItem('chusql.layout.v1', '{not json');
renderWorkbench();
const { sidebar, dock } = splitters();
expect(sidebar).toHaveAttribute('aria-valuenow', '250');
expect(dock).toHaveAttribute('aria-valuenow', String(defaultDock()));
});
it('widens the sidebar by 40px when the vertical splitter is dragged right', () => {
renderWorkbench();
const { sidebar } = splitters();
act(() => {
sidebar.dispatchEvent(new MouseEvent('pointerdown', { clientX: 300, bubbles: true }));
});
act(() => {
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 340, bubbles: true }));
});
act(() => {
window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }));
});
expect(sidebar).toHaveAttribute('aria-valuenow', '290');
});
it('makes the dock taller by 40px when the horizontal splitter is dragged up', () => {
renderWorkbench();
const { dock } = splitters();
act(() => {
dock.dispatchEvent(new MouseEvent('pointerdown', { clientY: 500, bubbles: true }));
});
act(() => {
window.dispatchEvent(new MouseEvent('pointermove', { clientY: 460, bubbles: true }));
});
act(() => {
window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }));
});
expect(dock).toHaveAttribute('aria-valuenow', String(defaultDock() + 40));
});
it('stops resizing after the pointer is released', () => {
renderWorkbench();
const { sidebar } = splitters();
act(() => {
sidebar.dispatchEvent(new MouseEvent('pointerdown', { clientX: 300, bubbles: true }));
});
act(() => {
window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }));
});
act(() => {
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 400, bubbles: true }));
});
expect(sidebar).toHaveAttribute('aria-valuenow', '250');
});
@@ -0,0 +1,121 @@
import { Database, RotateCcw, Settings, TerminalSquare, Undo2 } from 'lucide-react';
import { useEffect, useState, type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react';
import { IconButton } from '../common/IconButton';
import { zhCN } from '../../i18n/zh-CN';
import styles from './layout.module.css';
// 工作台外壳:标题栏、活动栏与编辑器/底栏/状态栏布局,面板边界可拖动。
interface WorkbenchProps {
explorer: ReactNode;
editor: ReactNode;
bottom: ReactNode;
status: ReactNode;
changeCount: number;
canUndo: boolean;
canRedo: boolean;
onCommit: () => void;
onRollback: () => void;
onUndo: () => void;
onRedo: () => void;
onSql: () => void;
onSettings: () => void;
}
type SplitterKind = 'sidebar' | 'dock';
interface LayoutSize { sidebar: number; dock: number }
interface DragState { kind: SplitterKind; start: number; size: number }
const STORAGE_KEY = 'chusql.layout.v1';
const SIDEBAR_MIN = 160;
const SIDEBAR_MAX = 560;
const DOCK_MIN = 96;
const KEY_STEP = 16;
// 底部船坞高度上限取窗口高度的七成
function dockMax() { return Math.max(DOCK_MIN, Math.round(window.innerHeight * 0.7)); }
// 把尺寸夹紧到允许区间并取整
function clampSize(kind: SplitterKind, value: number) {
const rounded = Math.round(value);
return kind === 'sidebar' ? Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, rounded)) : Math.min(dockMax(), Math.max(DOCK_MIN, rounded));
}
// 读取本地存储的面板尺寸,异常时用默认值
function readLayout(): LayoutSize {
const fallback = { sidebar: 250, dock: Math.round(window.innerHeight * 0.22) };
try {
const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '') as { sidebarWidth?: unknown; dockHeight?: unknown };
if (typeof raw.sidebarWidth !== 'number' || typeof raw.dockHeight !== 'number') return fallback;
return { sidebar: clampSize('sidebar', raw.sidebarWidth), dock: clampSize('dock', raw.dockHeight) };
} catch { return fallback; }
}
// 写入面板尺寸到本地存储,失败时静默忽略
function writeLayout(size: LayoutSize) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ sidebarWidth: size.sidebar, dockHeight: size.dock })); } catch { return; }
}
export function Workbench(props: WorkbenchProps) {
const [layout, setLayout] = useState<LayoutSize>(readLayout);
const [drag, setDrag] = useState<DragState>();
const { sidebar, dock } = layout;
useEffect(() => { writeLayout(layout); }, [layout]);
useEffect(() => {
if (!drag) return;
const move = (event: globalThis.PointerEvent) => setLayout((current) => {
const delta = drag.kind === 'sidebar' ? event.clientX - drag.start : drag.start - event.clientY;
const next = drag.size + delta;
return drag.kind === 'sidebar' ? { ...current, sidebar: clampSize('sidebar', next) } : { ...current, dock: clampSize('dock', next) };
});
const stop = () => setDrag(undefined);
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', stop);
return () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', stop); };
}, [drag]);
const resize = (kind: SplitterKind, value: number) => setLayout((current) => (kind === 'sidebar' ? { ...current, sidebar: clampSize('sidebar', value) } : { ...current, dock: clampSize('dock', value) }));
const startDrag = (kind: SplitterKind, event: PointerEvent<HTMLButtonElement>) => {
event.preventDefault();
setDrag({ kind, start: kind === 'sidebar' ? event.clientX : event.clientY, size: kind === 'sidebar' ? sidebar : dock });
};
const onSplitterKeyDown = (kind: SplitterKind, event: KeyboardEvent<HTMLButtonElement>) => {
const step = kind === 'sidebar'
? (event.key === 'ArrowRight' ? KEY_STEP : event.key === 'ArrowLeft' ? -KEY_STEP : 0)
: (event.key === 'ArrowUp' ? KEY_STEP : event.key === 'ArrowDown' ? -KEY_STEP : 0);
if (!step) return;
event.preventDefault();
resize(kind, (kind === 'sidebar' ? sidebar : dock) + step);
};
return (
<div className={styles.workbench} style={{ '--sidebar-width': `${sidebar}px`, '--dock-height': `${dock}px` } as CSSProperties}>
<header className={styles.titlebar}>
<span className={styles.brand}><Database size={15} />{zhCN.connection}</span>
<nav>
<IconButton icon={<Database size={14} />} label={zhCN.commit(props.changeCount)} disabled={!props.changeCount} onClick={props.onCommit} />
<IconButton icon={<RotateCcw size={14} />} label={zhCN.rollback} disabled={!props.changeCount} onClick={props.onRollback} />
<IconButton icon={<Undo2 size={14} />} label={zhCN.undo} disabled={!props.canUndo} onClick={props.onUndo} />
<IconButton icon={<Undo2 size={14} transform="rotate(180)" />} label={zhCN.redo} disabled={!props.canRedo} onClick={props.onRedo} />
<IconButton icon={<Settings size={14} />} label={zhCN.settings} onClick={props.onSettings} />
</nav>
</header>
<div className={styles.activitybar} aria-label={zhCN.activityBar}><button aria-label={zhCN.databaseSection}><Database size={20} /></button><button aria-label={zhCN.sqlConsole} onClick={props.onSql}><TerminalSquare size={20} /></button><button aria-label={zhCN.settings} onClick={props.onSettings}><Settings size={20} /></button><GitMark /></div>
{props.explorer}
<main className={styles.editor}>{props.editor}</main>
<section className={styles.bottom}>{props.bottom}</section>
<footer className={styles.statusbar}>{props.status}</footer>
<button type="button" className={`${styles.splitterV}${drag?.kind === 'sidebar' ? ` ${styles.dragging}` : ''}`} role="separator" aria-orientation="vertical" aria-label={zhCN.resizeSidebar} aria-valuenow={sidebar} aria-valuemin={SIDEBAR_MIN} aria-valuemax={SIDEBAR_MAX} tabIndex={0} onPointerDown={(event) => startDrag('sidebar', event)} onKeyDown={(event) => onSplitterKeyDown('sidebar', event)} />
<button type="button" className={`${styles.splitterH}${drag?.kind === 'dock' ? ` ${styles.dragging}` : ''}`} role="separator" aria-orientation="horizontal" aria-label={zhCN.resizeDock} aria-valuenow={dock} aria-valuemin={DOCK_MIN} aria-valuemax={dockMax()} tabIndex={0} onPointerDown={(event) => startDrag('dock', event)} onKeyDown={(event) => onSplitterKeyDown('dock', event)} />
</div>
);
}
function GitMark() {
return <span aria-hidden="true">⌘</span>;
}
@@ -0,0 +1,15 @@
/* 底部面板:标签页、变更列表与结果表格样式 */
.panel { display: grid; grid-template-rows: 28px minmax(0, 1fr); height: 100%; }
.panel nav { border-bottom: 1px solid var(--border); display: flex; gap: 2px; }
.panel nav button { background: transparent; border: 0; border-bottom: 1px solid transparent; color: var(--fg-primary); font: inherit; padding: 0 9px; }
.panel nav .active { border-bottom-color: var(--accent); }
.body { min-height: 0; overflow: auto; padding: 6px 8px; }
.body p { color: var(--fg-muted); margin: 4px; }
.changes, .messages { list-style: none; margin: 0; padding: 0; }
.changes li { align-items: center; border-bottom: 1px solid var(--border-subtle); display: grid; gap: 10px; grid-template-columns: 200px minmax(0, 1fr) 24px; min-height: 26px; }
.changes code { color: rgb(200 200 200); font-family: var(--ui-font); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.messages li { border-bottom: 1px solid var(--border-subtle); min-height: 22px; }
.body select { background: var(--bg-overlay); border: 1px solid var(--border); color: var(--fg-primary); height: 24px; }
.resultScroll { overflow: auto; }
.resultScroll table { border-collapse: collapse; margin-top: 6px; }
.resultScroll th, .resultScroll td { border: 1px solid var(--border); min-width: 100px; padding: 3px 6px; text-align: left; }
@@ -0,0 +1,43 @@
/* 工作台整体网格布局与资源管理器样式 */
.workbench { --sidebar-width: 250px; --dock-height: 22vh; display: grid; grid-template: 34px minmax(0, 1fr) var(--dock-height) 22px / 48px var(--sidebar-width) minmax(0, 1fr); height: 100vh; min-height: 360px; position: relative; }
.titlebar { align-items: center; background: var(--bg-titlebar); display: flex; grid-column: 1 / -1; justify-content: space-between; padding: 0 6px 0 12px; }
.titlebar nav { display: flex; gap: 2px; }
.brand { align-items: center; display: inline-flex; gap: 7px; }
.activitybar { align-items: center; background: #333; color: var(--fg-muted); display: flex; flex-direction: column; gap: 18px; grid-column: 1; grid-row: 2 / 4; padding-top: 13px; }
.activitybar button { align-items: center; background: transparent; border: 0; color: inherit; display: flex; height: 28px; justify-content: center; padding: 0; width: 100%; }
.activitybar button:hover, .activitybar button:focus-visible { color: var(--fg-primary); }
.sidebar { background: var(--bg-elevated); border-right: 1px solid var(--border); grid-column: 2; grid-row: 2 / 4; min-width: 0; overflow: auto; }
.sidebar h2 { font-size: 12px; letter-spacing: .12em; margin: 0; padding: 11px 12px; text-transform: uppercase; }
.sectionTitle { align-items: center; display: flex; font-size: 11px; font-weight: 700; gap: 4px; letter-spacing: .08em; padding: 6px 8px; text-transform: uppercase; }
.treeRoot { align-items: center; display: flex; gap: 5px; height: 22px; padding-left: 19px; }
.tableList { list-style: none; margin: 0 0 8px; padding: 0; }
.treeItem, .activeTreeItem { align-items: center; background: transparent; border: 0; border-left: 2px solid transparent; color: var(--fg-primary); cursor: pointer; display: flex; font: inherit; gap: 6px; height: 22px; padding: 0 8px 0 32px; text-align: left; width: 100%; }
.treeItem:hover { background: var(--bg-active); }
.activeTreeItem { background: var(--bg-active); border-left-color: var(--accent); }
.treeItem small, .activeTreeItem small { color: var(--fg-muted); margin-left: auto; }
.historyList { list-style: none; margin: 0; padding: 0 8px 8px 32px; }
.historyList button { background: transparent; border: 0; color: var(--fg-muted); font: inherit; height: 22px; max-width: 100%; overflow: hidden; padding: 0; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.editor { background: var(--bg-base); grid-column: 3; grid-row: 2; min-height: 0; min-width: 0; overflow: hidden; }
.bottom { background: var(--bg-elevated); border-top: 1px solid var(--border); grid-column: 3; grid-row: 3; min-height: 0; overflow: auto; }
.statusbar { align-items: center; background: var(--bg-statusbar); color: #fff; display: flex; gap: 16px; grid-column: 1 / -1; padding: 0 8px; }
.splitterV { position: absolute; top: 34px; bottom: 22px; left: calc(48px + var(--sidebar-width) - 2px); width: 5px; cursor: col-resize; background: transparent; border: 0; padding: 0; z-index: 6; }
.splitterH { position: absolute; left: calc(48px + var(--sidebar-width)); right: 0; bottom: calc(22px + var(--dock-height) - 2px); height: 5px; cursor: row-resize; background: transparent; border: 0; padding: 0; z-index: 6; }
.splitterV:hover, .splitterV:focus-visible, .splitterV.dragging { background: var(--accent); }
.splitterH:hover, .splitterH:focus-visible, .splitterH.dragging { background: var(--accent); }
@media (max-width: 720px) {
.workbench { grid-template-columns: 40px minmax(0, 1fr); }
.sidebar { display: none; }
.splitterV { display: none; }
.splitterH { left: 40px; }
.editor, .bottom { grid-column: 2; }
.titlebar nav .iconButton span { display: none; }
}
.tableEntry { display: flex; align-items: center; padding-left: 16px; }
.tableEntry .treeItem, .tableEntry .activeTreeItem { padding-left: 4px; min-width: 0; }
.expandButton { display: flex; flex-shrink: 0; padding: 3px; background: transparent; border: 0; color: var(--fg-muted); cursor: pointer; }
.columnList { list-style: none; padding: 0 10px 4px 42px; }
.columnList li { display: flex; align-items: center; gap: 6px; min-height: 24px; }
.columnList svg { flex-shrink: 0; color: var(--info); }
.columnList svg[aria-label="Primary key"] { color: var(--modified); }
.columnList small { margin-left: auto; color: var(--fg-muted); }
@@ -0,0 +1,24 @@
import { GripVertical, Plus, Trash2 } from 'lucide-react';
import { useState, type CSSProperties, type DragEvent, type ReactNode } from 'react';
import { zhCN } from '../../i18n/zh-CN';
import { IconButton } from '../common/IconButton';
import styles from './settings.module.css';
// 字体链编辑器:增删、拖动排序并实时预览。
interface FontChainEditorProps { label: string; value: string[]; onChange: (fonts: string[]) => void; previewStyle: CSSProperties; children?: ReactNode }
export function FontChainEditor({ label, value, onChange, previewStyle, children }: FontChainEditorProps) {
const [dragged, setDragged] = useState<number>();
const move = (event: DragEvent, target: number) => {
event.preventDefault();
if (dragged === undefined || dragged === target) return;
const next = [...value]; const [font] = next.splice(dragged, 1); next.splice(target, 0, font); onChange(next); setDragged(target);
};
return <fieldset className={styles.fontGroup}><legend>{label}</legend>
<div className={styles.fontList}>{value.map((font, index) => <div className={styles.fontRow} key={index} onDragOver={(event) => move(event, index)}><span draggable onDragStart={() => setDragged(index)} onDragEnd={() => setDragged(undefined)} title={zhCN.moveFont}><GripVertical size={13} /></span><input aria-label={`${label} ${zhCN.fontName} ${index + 1}`} value={font} onChange={(event) => onChange(value.map((item, itemIndex) => itemIndex === index ? event.target.value : item))} /><IconButton icon={<Trash2 size={13} />} compact label={zhCN.removeFont(font)} disabled={value.length === 1} onClick={() => onChange(value.filter((_, itemIndex) => itemIndex !== index))} /></div>)}</div>
<IconButton icon={<Plus size={13} />} label={zhCN.addFallback} onClick={() => onChange([...value, ''])} />
{children}
<div className={styles.preview} style={previewStyle}><strong>{zhCN.preview}</strong><span>{zhCN.previewText}</span></div>
</fieldset>;
}
@@ -0,0 +1,35 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, expect, it } from 'vitest';
import { SettingsPanel } from './SettingsPanel';
import { useSettingsStore } from '../../store/settingsStore';
// 覆盖设置面板的输入聚焦与数值提交。
beforeEach(() => useSettingsStore.getState().reset());
it('preserves the font input and focus while typing and deleting', () => {
render(<SettingsPanel />);
const input = screen.getAllByRole('textbox')[0];
input.focus();
fireEvent.change(input, { target: { value: 'Consola' } });
expect(screen.getAllByRole('textbox')[0]).toBe(input);
expect(input).toHaveFocus();
fireEvent.change(input, { target: { value: '' } });
expect(input).toHaveValue('');
expect(input).toHaveFocus();
});
it('allows an empty numeric draft and commits a bounded value on blur', () => {
render(<SettingsPanel />);
const input = screen.getAllByRole('spinbutton')[0];
fireEvent.change(input, { target: { value: '' } });
expect(input).toHaveValue(null);
expect(useSettingsStore.getState().settings.uiFontSize).toBe(13);
fireEvent.change(input, { target: { value: '15' } });
fireEvent.blur(input);
expect(useSettingsStore.getState().settings.uiFontSize).toBe(15);
fireEvent.change(input, { target: { value: '99' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(input).toHaveValue(16);
fireEvent.change(input, { target: { value: '' } });
fireEvent.blur(input);
expect(input).toHaveValue(16);
});
@@ -0,0 +1,46 @@
import { RotateCcw } from 'lucide-react';
import { useEffect, useState } from 'react';
import { zhCN } from '../../i18n/zh-CN';
import { fontFamilyValue, useSettingsStore } from '../../store/settingsStore';
import { IconButton } from '../common/IconButton';
import { FontChainEditor } from './FontChainEditor';
import styles from './settings.module.css';
// 设置面板:外观与行为两组选项及字体预览。
function NumberField({ label, value, min, max, onChange }: { label: string; value: number; min: number; max: number; onChange: (value: number) => void }) {
const [draft, setDraft] = useState(String(value));
useEffect(() => setDraft(String(value)), [value]);
const commit = () => {
if (!draft.trim() || !Number.isFinite(Number(draft))) { setDraft(String(value)); return; }
const next = Math.min(max, Math.max(min, Math.round(Number(draft))));
setDraft(String(next)); onChange(next);
};
return <label className={styles.field}><span>{label}</span><input type="number" min={min} max={max} value={draft} onChange={(event) => setDraft(event.target.value)} onBlur={commit} onKeyDown={(event) => { if (event.key === 'Enter') commit(); if (event.key === 'Escape') setDraft(String(value)); }} /></label>;
}
function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (value: boolean) => void }) {
return <label className={styles.toggle}><input type="checkbox" checked={checked} onChange={(event) => onChange(event.target.checked)} /><span>{label}</span></label>;
}
export function SettingsPanel() {
const [section, setSection] = useState<'appearance' | 'behavior'>('appearance');
const { settings, setNumber, setBoolean, setText, setFonts, reset } = useSettingsStore();
return <div className={styles.settings}>
<aside><h2>{zhCN.settings}</h2><button className={section === 'appearance' ? styles.active : ''} onClick={() => setSection('appearance')}>{zhCN.settingsAppearance}</button><button className={section === 'behavior' ? styles.active : ''} onClick={() => setSection('behavior')}>{zhCN.settingsBehavior}</button></aside>
<section><header><h1>{section === 'appearance' ? zhCN.settingsAppearance : zhCN.settingsBehavior}</h1></header>
{section === 'appearance' ? <>
<FontChainEditor label={zhCN.interfaceFont} value={settings.uiFonts} onChange={(value) => setFonts('uiFonts', value)} previewStyle={{ fontFamily: fontFamilyValue(settings.uiFonts), fontSize: settings.uiFontSize }}><NumberField label={zhCN.fontSize} value={settings.uiFontSize} min={11} max={16} onChange={(value) => setNumber('uiFontSize', value)} /></FontChainEditor>
<FontChainEditor label={zhCN.gridFont} value={settings.gridFonts} onChange={(value) => setFonts('gridFonts', value)} previewStyle={{ fontFamily: fontFamilyValue(settings.gridFonts), fontSize: settings.gridFontSize, lineHeight: `${settings.gridRowHeight}px` }}><NumberField label={zhCN.fontSize} value={settings.gridFontSize} min={10} max={18} onChange={(value) => setNumber('gridFontSize', value)} /><NumberField label={zhCN.rowHeight} value={settings.gridRowHeight} min={18} max={40} onChange={(value) => setNumber('gridRowHeight', value)} /></FontChainEditor>
<FontChainEditor label={zhCN.sqlFont} value={settings.sqlFonts} onChange={(value) => setFonts('sqlFonts', value)} previewStyle={{ fontFamily: fontFamilyValue(settings.sqlFonts), fontSize: settings.sqlFontSize, lineHeight: `${settings.sqlLineHeight}px` }}><NumberField label={zhCN.fontSize} value={settings.sqlFontSize} min={10} max={20} onChange={(value) => setNumber('sqlFontSize', value)} /><NumberField label={zhCN.rowHeight} value={settings.sqlLineHeight} min={16} max={40} onChange={(value) => setNumber('sqlLineHeight', value)} /></FontChainEditor>
</> : <div className={styles.behavior}>
<NumberField label={zhCN.pageSize} value={settings.pageSize} min={10} max={500} onChange={(value) => setNumber('pageSize', value)} />
<label className={styles.field}><span>{zhCN.nullDisplay}</span><input value={settings.nullText} onChange={(event) => setText('nullText', event.target.value)} /></label>
<NumberField label={zhCN.tabSize} value={settings.sqlTabSize} min={1} max={8} onChange={(value) => setNumber('sqlTabSize', value)} />
<Toggle label={zhCN.showLineNumbers} checked={settings.sqlLineNumbers} onChange={(value) => setBoolean('sqlLineNumbers', value)} />
<Toggle label={zhCN.autocomplete} checked={settings.autocomplete} onChange={(value) => setBoolean('autocomplete', value)} />
<Toggle label={zhCN.minimap} checked={settings.minimap} onChange={(value) => setBoolean('minimap', value)} />
</div>}
<footer><IconButton icon={<RotateCcw size={14} />} label={zhCN.restoreDefaults} onClick={reset} /></footer>
</section>
</div>;
}
@@ -0,0 +1,29 @@
/* 设置面板样式:侧边分组、字段与字体预览。 */
.settings { display: grid; grid-template-columns: 210px minmax(0, 1fr); height: 100%; overflow: hidden; }
.settings aside { background: var(--bg-elevated); border-right: 1px solid var(--border); padding-top: 8px; }
.settings aside h2 { font-size: 12px; letter-spacing: .12em; margin: 8px 12px 12px; text-transform: uppercase; }
.settings aside button { background: transparent; border: 0; border-left: 2px solid transparent; color: var(--fg-primary); display: block; font: inherit; height: 28px; padding: 0 12px; text-align: left; width: 100%; }
.settings aside button:hover, .settings aside .active { background: var(--bg-active); }
.settings aside .active { border-left-color: var(--accent); }
.settings > section { overflow: auto; padding: 0 18px 20px; }
.settings header { background: var(--bg-base); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 2; }
.settings h1 { font-size: 18px; font-weight: 500; margin: 0; padding: 16px 0 10px; }
.fontGroup { border: 1px solid var(--border); margin: 14px 0; padding: 9px; }
.fontGroup legend { color: var(--info); font-weight: 700; padding: 0 5px; }
.fontList { display: grid; gap: 3px; margin-bottom: 6px; }
.fontRow { align-items: center; display: grid; gap: 5px; grid-template-columns: 16px minmax(160px, 360px) 24px; }
.fontRow input, .field input { background: var(--bg-base); border: 1px solid var(--border); color: var(--fg-primary); height: 24px; padding: 0 6px; }
.field { align-items: center; display: grid; gap: 10px; grid-template-columns: 150px 120px; margin: 7px 0; }
.toggle { align-items: center; display: flex; gap: 8px; margin: 10px 0; }
.preview { background: var(--bg-base); border: 1px solid var(--border-subtle); display: grid; gap: 4px; margin-top: 9px; min-height: 52px; padding: 7px; }
.preview strong { color: var(--fg-muted); font-family: var(--ui-font); font-size: 11px; }
.behavior { padding: 12px 0; }
.settings footer { border-top: 1px solid var(--border); margin-top: 16px; padding-top: 8px; }
@media (max-width: 720px) { .settings { grid-template-columns: 140px minmax(0, 1fr); } .settings > section { padding-inline: 10px; } }
@media (max-width: 720px) {
.settings { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); }
.settings aside { display: flex; border-right: 0; border-bottom: 1px solid var(--border); padding: 0; }
.settings aside h2 { display: none; }
.fontRow { grid-template-columns: 16px minmax(0, 1fr) 24px; }
.field { grid-template-columns: minmax(0, 1fr) 90px; }
}
@@ -0,0 +1,119 @@
import { Play, PlayCircle, Square, TextSelect } from 'lucide-react';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import 'monaco-editor/min/vs/editor/editor.main.css';
import 'monaco-editor/esm/vs/basic-languages/sql/sql.contribution';
import { useEffect, useMemo, useRef, useState } from 'react';
import { splitSqlStatements, type SqlStatement } from '../../core/sql';
import { SQL_TOKEN_COLORS } from '../../core/sqlHighlight';
import { zhCN } from '../../i18n/zh-CN';
import type { DataSourceAdapter, StatementResult } from '../../types';
import type { IdeSettings } from '../../store/settingsStore';
import { IconButton } from '../common/IconButton';
import styles from './sql.module.css';
import { restoreEditorLayout } from './editorLayout';
import { fontFamilyValue } from '../../store/settingsStore';
// SQL 控制台:Monaco 编辑器、语句切分与逐条执行。
(self as unknown as { MonacoEnvironment: { getWorker: () => Worker } }).MonacoEnvironment = { getWorker: () => new EditorWorker() };
export interface QueryHistoryEntry { sql: string; at: string; ok: boolean; durationMs: number }
interface SqlConsoleProps {
adapter: DataSourceAdapter;
settings: IdeSettings;
initialSql?: string;
onResult: (result: StatementResult) => void;
onMessage: (message: string) => void;
onHistory: (history: QueryHistoryEntry[]) => void;
}
type RunStatus = { state: 'pending' | 'running' | 'success' | 'error'; durationMs?: number };
function readHistory(): QueryHistoryEntry[] {
try {
const parsed = JSON.parse(localStorage.getItem('chusql.sql.history.v2') ?? '[]');
return Array.isArray(parsed) ? parsed.slice(0, 50) : [];
} catch { return []; }
}
export function SqlConsole({ adapter, settings, initialSql = 'SELECT * FROM users;', onResult, onMessage, onHistory }: SqlConsoleProps) {
const host = useRef<HTMLDivElement>(null);
const editor = useRef<monaco.editor.IStandaloneCodeEditor>();
const stopRequested = useRef(false);
const [sql, setSql] = useState(initialSql);
const [running, setRunning] = useState(false);
const [statuses, setStatuses] = useState<Record<string, RunStatus>>({});
const statements = useMemo(() => splitSqlStatements(sql), [sql]);
useEffect(() => {
if (!host.current) return undefined;
const stopLayoutRestore = restoreEditorLayout(host.current);
monaco.editor.defineTheme('chusql-dark', {
base: 'vs-dark', inherit: true,
rules: [
{ token: 'keyword', foreground: SQL_TOKEN_COLORS.keyword.slice(1) },
{ token: 'string', foreground: SQL_TOKEN_COLORS.string.slice(1) },
{ token: 'number', foreground: SQL_TOKEN_COLORS.number.slice(1) },
{ token: 'comment', foreground: SQL_TOKEN_COLORS.comment.slice(1) },
],
colors: { 'editor.background': '#1e1e1e', 'editorLineNumber.foreground': '#858585', 'editor.selectionBackground': '#264f78' },
});
const instance = monaco.editor.create(host.current, {
value: sql, language: 'sql', theme: 'chusql-dark', glyphMargin: true,
automaticLayout: true, minimap: { enabled: settings.minimap }, fontFamily: fontFamilyValue(settings.sqlFonts),
fontSize: settings.sqlFontSize, lineHeight: settings.sqlLineHeight, tabSize: settings.sqlTabSize,
lineNumbers: settings.sqlLineNumbers ? 'on' : 'off', quickSuggestions: settings.autocomplete,
});
editor.current = instance;
const subscription = instance.onDidChangeModelContent(() => setSql(instance.getValue()));
return () => { stopLayoutRestore(); subscription.dispose(); instance.dispose(); editor.current = undefined; };
}, []);
useEffect(() => { editor.current?.updateOptions({ minimap: { enabled: settings.minimap }, fontFamily: fontFamilyValue(settings.sqlFonts), fontSize: settings.sqlFontSize, lineHeight: settings.sqlLineHeight, tabSize: settings.sqlTabSize, lineNumbers: settings.sqlLineNumbers ? 'on' : 'off', quickSuggestions: settings.autocomplete }); }, [settings]);
useEffect(() => { setStatuses(Object.fromEntries(statements.map((statement) => [statement.id, { state: 'pending' }]))); }, [sql]);
useEffect(() => {
const instance = editor.current;
if (!instance) return;
const decorations = statements.map((statement) => {
const status = statuses[statement.id]?.state ?? 'pending';
return { range: new monaco.Range(statement.startLine, 1, statement.endLine, 1), options: { isWholeLine: status === 'running', className: status === 'running' ? styles.runningLine : undefined, linesDecorationsClassName: status === 'running' ? styles.runningEdge : undefined, glyphMarginClassName: styles[`glyph${status[0].toUpperCase()}${status.slice(1)}`] } };
});
const ids = instance.deltaDecorations([], decorations);
return () => { instance.deltaDecorations(ids, []); };
}, [statements, statuses]);
const recordHistory = (statement: SqlStatement, result: StatementResult) => {
const next = [{ sql: statement.sql, at: new Date().toISOString(), ok: result.type !== 'error', durationMs: result.durationMs }, ...readHistory()].slice(0, 50);
localStorage.setItem('chusql.sql.history.v2', JSON.stringify(next)); onHistory(next);
};
const run = async (items: SqlStatement[]) => {
if (!items.length) return onMessage(zhCN.noStatement);
stopRequested.current = false; setRunning(true);
for (const statement of items) {
if (stopRequested.current) break;
setStatuses((current) => ({ ...current, [statement.id]: { state: 'running' } }));
const result = await adapter.execStatement({ db: zhCN.database, sql: statement.sql });
setStatuses((current) => ({ ...current, [statement.id]: { state: result.type === 'error' ? 'error' : 'success', durationMs: result.durationMs } }));
recordHistory(statement, result); onResult(result);
if (result.type === 'error') onMessage(result.message ?? zhCN.statementFailed);
else if (result.type === 'rows') onMessage(zhCN.executionSuccess(result.rows?.length ?? 0, result.durationMs));
else onMessage(zhCN.executionAffected(result.affected ?? 0, result.durationMs));
}
if (stopRequested.current) onMessage(zhCN.executionStopped);
setRunning(false);
};
const pending = () => statements.find((statement) => statuses[statement.id]?.state !== 'success');
const selection = () => {
const instance = editor.current; const range = instance?.getSelection();
if (!instance || !range || range.isEmpty()) return [];
return splitSqlStatements(instance.getModel()?.getValueInRange(range) ?? '');
};
return <section className={styles.console}>
<div className={styles.toolbar}><IconButton icon={<PlayCircle size={14} />} label={zhCN.runNext} disabled={running} onClick={() => { const next = pending(); void run(next ? [next] : []); }} /><IconButton icon={<Play size={14} />} label={zhCN.runAll} disabled={running} onClick={() => void run(statements)} /><IconButton icon={<TextSelect size={14} />} label={zhCN.runSelection} disabled={running} onClick={() => void run(selection())} /><IconButton icon={<Square size={13} />} label={zhCN.stop} disabled={!running} onClick={() => { stopRequested.current = true; }} /></div>
<div className={styles.editor} ref={host} aria-label={zhCN.sqlConsole} />
<ol className={styles.statementList}>{statements.map((statement) => { const status = statuses[statement.id] ?? { state: 'pending' }; return <li key={statement.id}><span>{status.state === 'pending' ? '○' : status.state === 'running' ? '▶' : status.state === 'success' ? '✓' : '✗'}</span><span>{status.state === 'pending' ? zhCN.statementPending : status.state === 'running' ? zhCN.statementRunning : status.state === 'success' ? zhCN.statementSuccess : zhCN.statementFailed}</span>{status.durationMs !== undefined && <small>{status.durationMs.toFixed(1)} ms</small>}</li>; })}</ol>
</section>;
}
@@ -0,0 +1,25 @@
import { expect, it, vi } from 'vitest';
import { restoreEditorLayout } from './editorLayout';
// editorLayout 测试:只恢复布局声明,不碰已生效样式。
it('restores only layout declarations from CSP-blocked editor markup', () => {
const host = document.createElement('div');
const line = document.createElement('div');
line.setAttribute('style', 'left:20px;width:36px;background-image:url(https://example.invalid/x);');
const setProperty = vi.fn();
Object.defineProperty(line, 'style', { value: { length: 0, setProperty } });
host.append(line);
const stop = restoreEditorLayout(host);
expect(setProperty.mock.calls).toEqual([['left', '20px'], ['width', '36px']]);
stop();
});
it('leaves already active CSSOM styles untouched', () => {
const host = document.createElement('div');
host.style.width = '200px';
const setProperty = vi.spyOn(host.style, 'setProperty');
const stop = restoreEditorLayout(host);
expect(setProperty).not.toHaveBeenCalled();
stop();
});
@@ -0,0 +1,36 @@
// 修复被 CSP 拦截的 Monaco 内联样式:用 CSSOM 重放布局属性。
const layoutProperties = new Set([
'left', 'right', 'top', 'bottom', 'width', 'height', 'min-width', 'max-width',
'position', 'display', 'line-height', 'font-size', 'font-family', 'font-weight',
'font-style', 'letter-spacing', 'vertical-align', 'white-space', 'transform',
'color', 'background-color', 'border-color', 'border-width', 'border-style',
'padding-left', 'padding-right', 'margin-left', 'margin-right', 'opacity',
]);
export function restoreEditorLayout(host: HTMLElement): () => void {
const parsed = document.createElement('span').style;
const restore = (element: Element) => {
if (!(element instanceof HTMLElement) || element.style.length) return;
const raw = element.getAttribute('style');
if (!raw) return;
parsed.cssText = raw;
for (const property of Array.from(parsed)) {
const value = parsed.getPropertyValue(property);
if (layoutProperties.has(property) && !/url\s*\(|expression\s*\(/i.test(value)) element.style.setProperty(property, value);
}
};
const restoreTree = (element: Element) => {
restore(element);
element.querySelectorAll('[style]').forEach(restore);
};
const observer = new MutationObserver((records) => {
for (const record of records) {
if (record.type === 'attributes' && record.target instanceof Element) restore(record.target);
else record.addedNodes.forEach((node) => { if (node instanceof Element) restoreTree(node); });
}
});
observer.observe(host, { subtree: true, childList: true, attributes: true, attributeFilter: ['style'] });
restoreTree(host);
return () => observer.disconnect();
}
@@ -0,0 +1,15 @@
/* SQL 控制台样式:工具栏、编辑器与语句状态条。 */
.console { display: grid; grid-template: 30px minmax(0, 1fr) auto / minmax(0, 1fr); height: 100%; min-height: 0; min-width: 0; overflow: hidden; }
.toolbar { align-items: center; background: var(--bg-elevated); border-bottom: 1px solid var(--border); display: flex; gap: 2px; padding: 2px 5px; }
.editor { min-height: 0; min-width: 0; overflow: hidden; position: relative; }
.statementList { background: var(--bg-elevated); border-top: 1px solid var(--border); display: flex; gap: 12px; list-style: none; margin: 0; min-height: 28px; overflow-x: auto; padding: 4px 8px; }
.statementList li { align-items: center; color: var(--fg-muted); display: flex; gap: 5px; white-space: nowrap; }
.statementList small { color: var(--success); }
.runningLine { background: rgb(0 122 204 / 14%); }
.runningEdge { border-left: 2px solid var(--accent); margin-left: 3px; }
.glyphPending::before { color: var(--fg-muted); content: '○'; }
.glyphRunning::before { color: var(--info); content: '▶'; }
.glyphSuccess::before { color: var(--success); content: '✓'; }
.glyphError::before { color: var(--danger); content: '✗'; }
.editor > :global(.monaco-editor) { position: absolute; inset: 0; }
.statementList { max-height: 64px; }
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { createEmptyChangeSet } from './changes';
import { changeSql, changesSql, deleteSql, insertSql, schemaSql, sqlIdentifier, sqlLiteral, updateSql } from './changeSql';
// 覆盖展示用 SQL 的字面量、标识符与结构语句。
describe('sql literals and identifiers', () => {
it('doubles single quotes and keeps null, numbers and booleans bare', () => {
expect(sqlLiteral("O'Brien")).toBe("'O''Brien'");
expect(sqlLiteral(7)).toBe('7');
expect(sqlLiteral(false)).toBe('false');
expect(sqlLiteral(null)).toBe('NULL');
expect(sqlLiteral(undefined)).toBe('NULL');
});
it('quotes identifiers only when they leave the whitelist', () => {
expect(sqlIdentifier('users')).toBe('users');
expect(sqlIdentifier('we"ird')).toBe('"we""ird"');
});
});
describe('row statements', () => {
it('renders update, insert and delete', () => {
expect(updateSql('users', 1, 'name', "A'B")).toBe("UPDATE users SET name = 'A''B' WHERE id = 1;");
expect(insertSql('users', { id: 9, name: 'Ada' })).toBe("INSERT INTO users (id, name) VALUES (9, 'Ada');");
expect(deleteSql('users', 2)).toBe('DELETE FROM users WHERE id = 2;');
});
});
describe('schema statements', () => {
it('matches the statements the server Actions build', () => {
expect(schemaSql({ op: 'createTable', table: 'items', columns: [{ name: 'id', type: 'int' }, { name: 'title', type: 'str' }] })).toBe('CREATE TABLE items (id int, title str);');
expect(schemaSql({ op: 'dropTable', table: 'items' })).toBe('DROP TABLE items;');
expect(schemaSql({ op: 'createIndex', table: 'items', column: 'sku' })).toBe('CREATE INDEX ON items (sku);');
expect(schemaSql({ op: 'dropIndex', table: 'items', column: 'sku' })).toBe('DROP INDEX ON items (sku);');
});
});
describe('changesSql', () => {
it('lists creates first, then row changes, then drops', () => {
const changes = createEmptyChangeSet();
changes.schemas.set('dropTable:items', { op: 'dropTable', table: 'items' });
changes.schemas.set('createTable:items', { op: 'createTable', table: 'items', columns: [{ name: 'id', type: 'int' }] });
changes.updates.set('items:1:name', { table: 'items', pk: 1, column: 'name', oldValue: 'A', newValue: 'B' });
expect(changesSql(changes)).toEqual([
'CREATE TABLE items (id int);',
"UPDATE items SET name = 'B' WHERE id = 1;",
'DROP TABLE items;',
]);
});
it('renders a single pending change through changeSql', () => {
expect(changeSql({ op: 'insert', table: 'users', values: { id: 3 } })).toBe('INSERT INTO users (id) VALUES (3);');
});
});
+61
View File
@@ -0,0 +1,61 @@
import type { ChangeSet, PendingChange, SchemaChange } from '../types';
import { pendingChanges } from './changes';
// 展示用 SQL 生成:标识符走白名单,字符串单引号翻倍。
const identifier = /^[A-Za-z_][A-Za-z0-9_]*$/;
// 字面量:字符串加引号并翻倍单引号。
export function sqlLiteral(value: unknown): string {
if (value === null || value === undefined) return 'NULL';
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
return `'${String(value).replaceAll("'", "''")}'`;
}
// 标识符:白名单外才加双引号。
export function sqlIdentifier(name: string): string {
return identifier.test(name) ? name : `"${name.replaceAll('"', '""')}"`;
}
// 单元格修改语句。
export function updateSql(table: string, pk: unknown, column: string, value: unknown): string {
return `UPDATE ${sqlIdentifier(table)} SET ${sqlIdentifier(column)} = ${sqlLiteral(value)} WHERE id = ${sqlLiteral(pk)};`;
}
// 插入语句。
export function insertSql(table: string, values: Record<string, unknown>): string {
const columns = Object.keys(values);
return `INSERT INTO ${sqlIdentifier(table)} (${columns.map(sqlIdentifier).join(', ')}) VALUES (${columns.map((column) => sqlLiteral(values[column])).join(', ')});`;
}
// 删除语句。
export function deleteSql(table: string, pk: unknown): string {
return `DELETE FROM ${sqlIdentifier(table)} WHERE id = ${sqlLiteral(pk)};`;
}
// 结构变更语句,与 Actions 拼法一致。
export function schemaSql(change: SchemaChange): string {
if (change.op === 'createTable') {
const columns = change.columns.map((column) => `${sqlIdentifier(column.name)} ${column.type}`).join(', ');
return `CREATE TABLE ${sqlIdentifier(change.table)} (${columns});`;
}
if (change.op === 'dropTable') return `DROP TABLE ${sqlIdentifier(change.table)};`;
if (change.op === 'createIndex') return `CREATE INDEX ON ${sqlIdentifier(change.table)} (${sqlIdentifier(change.column)});`;
return `DROP INDEX ON ${sqlIdentifier(change.table)} (${sqlIdentifier(change.column)});`;
}
// 渲染一条提交项为展示用 SQL。
export function changeSql(change: PendingChange): string {
if (change.op === 'insert') return insertSql(change.table, change.values);
if (change.op === 'delete') return deleteSql(change.table, change.pk);
if (change.op === 'update') {
const assignments = Object.entries(change.set).map(([column, value]) => `${sqlIdentifier(column)} = ${sqlLiteral(value)}`).join(', ');
return `UPDATE ${sqlIdentifier(change.table)} SET ${assignments} WHERE id = ${sqlLiteral(change.pk)};`;
}
return schemaSql(change);
}
// 按提交顺序渲染整套变更。
export function changesSql(changes: ChangeSet): string[] {
return pendingChanges(changes).map(changeSql);
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { changeCount, createEmptyChangeSet, pendingChanges, toCommitPayload } from './changes';
// 覆盖提交载荷的合并、顺序与结构变更。
describe('toCommitPayload', () => {
it('coalesces cell updates into one row update', () => {
const changes = createEmptyChangeSet();
changes.updates.set('users:1:name', { table: 'users', pk: 1, column: 'name', oldValue: 'A', newValue: 'B' });
changes.updates.set('users:1:age', { table: 'users', pk: 1, column: 'age', oldValue: 20, newValue: 21 });
expect(toCommitPayload(changes)).toEqual([
{ op: 'update', table: 'users', pk: 1, set: { name: 'B', age: 21 } },
]);
});
it('keeps inserts and deletes in a stable order', () => {
const changes = createEmptyChangeSet();
changes.inserts.set('tmp-1', { table: 'users', tempId: 'tmp-1', values: { id: 9, name: 'N' } });
changes.deletes.set('users:2', { table: 'users', pk: 2, row: { id: 2, name: 'D' } });
expect(toCommitPayload(changes)).toEqual([
{ op: 'insert', table: 'users', values: { id: 9, name: 'N' } },
{ op: 'delete', table: 'users', pk: 2 },
]);
});
it('leaves schema changes out of the row payload', () => {
const changes = createEmptyChangeSet();
changes.schemas.set('createIndex:users:age', { op: 'createIndex', table: 'users', column: 'age' });
expect(toCommitPayload(changes)).toEqual([]);
});
});
describe('pendingChanges', () => {
it('puts creates first, then row changes, then drops', () => {
const changes = createEmptyChangeSet();
changes.schemas.set('dropTable:items', { op: 'dropTable', table: 'items' });
changes.schemas.set('createTable:items', { op: 'createTable', table: 'items', columns: [{ name: 'id', type: 'int' }] });
changes.updates.set('items:1:name', { table: 'items', pk: 1, column: 'name', oldValue: 'A', newValue: 'B' });
expect(pendingChanges(changes)).toEqual([
{ op: 'createTable', table: 'items', columns: [{ name: 'id', type: 'int' }] },
{ op: 'update', table: 'items', pk: 1, set: { name: 'B' } },
{ op: 'dropTable', table: 'items' },
]);
});
it('counts structural changes together with row changes', () => {
const changes = createEmptyChangeSet();
changes.schemas.set('dropTable:items', { op: 'dropTable', table: 'items' });
changes.schemas.set('createIndex:users:age', { op: 'createIndex', table: 'users', column: 'age' });
changes.updates.set('users:1:name', { table: 'users', pk: 1, column: 'name', oldValue: 'A', newValue: 'B' });
expect(changeCount(changes)).toBe(3);
});
});
+72
View File
@@ -0,0 +1,72 @@
import type { ChangeSet, CommitChange, PendingChange, SchemaChange } from '../types';
// 变更集工具:创建、克隆、计数与提交载荷组装。
// 建一个空的变更集。
export function createEmptyChangeSet(): ChangeSet {
return { updates: new Map(), inserts: new Map(), deletes: new Map(), schemas: new Map() };
}
// 深拷贝变更集,供撤销栈留底。
export function cloneChangeSet(changes: ChangeSet): ChangeSet {
return {
updates: new Map(changes.updates),
inserts: new Map(changes.inserts),
deletes: new Map(changes.deletes),
schemas: new Map(changes.schemas),
};
}
// 统计待提交项,含结构变更。
export function changeCount(changes: ChangeSet): number {
return changes.updates.size + changes.inserts.size + changes.deletes.size + changes.schemas.size;
}
// 结构变更的稳定标识,撤销与确认共用。
export function schemaKey(change: SchemaChange): string {
return change.op === 'createIndex' || change.op === 'dropIndex'
? `${change.op}:${change.table}:${change.column}`
: `${change.op}:${change.table}`;
}
// 组装 DML 提交载荷:同行的多列修改并成一条。
export function toCommitPayload(changes: ChangeSet): CommitChange[] {
const updates = new Map<string, Extract<CommitChange, { op: 'update' }>>();
for (const update of changes.updates.values()) {
const key = `${update.table}:${JSON.stringify(update.pk)}`;
const existing = updates.get(key);
if (existing) {
existing.set[update.column] = update.newValue;
} else {
updates.set(key, {
op: 'update',
table: update.table,
pk: update.pk,
set: { [update.column]: update.newValue },
});
}
}
return [
...updates.values(),
...Array.from(changes.inserts.values(), (insert): CommitChange => ({
op: 'insert', table: insert.table, values: { ...insert.values },
})),
...Array.from(changes.deletes.values(), (deleted): CommitChange => ({
op: 'delete', table: deleted.table, pk: deleted.pk,
})),
];
}
// 按执行顺序合并结构变更与 DML 变更。
export function pendingChanges(changes: ChangeSet): PendingChange[] {
const schemas = Array.from(changes.schemas.values());
const creates = schemas.filter((change) => change.op === 'createTable' || change.op === 'createIndex');
const drops = schemas.filter((change) => change.op === 'dropIndex' || change.op === 'dropTable');
return [...creates, ...toCommitPayload(changes), ...drops];
}
// 单元格变更的键,表 + 主键 + 列。
export function changeKey(table: string, pk: unknown, column: string): string {
return `${table}:${JSON.stringify(pk)}:${column}`;
}
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { matchesFilter } from './filters';
// 覆盖筛选表达式的数值、通配与布尔匹配。
describe('matchesFilter', () => {
it('supports numeric comparisons', () => {
expect(matchesFilter(101, '>100')).toBe(true);
expect(matchesFilter(100, '>100')).toBe(false);
});
it('supports SQL-like wildcard matching without regular-expression injection', () => {
expect(matchesFilter('Alphabet', 'like %pha%')).toBe(true);
expect(matchesFilter('[abc]', 'like %[abc]%')).toBe(true);
expect(matchesFilter('aXXb', 'like a.b')).toBe(false);
});
it('matches booleans, null and plain text', () => {
expect(matchesFilter(true, 'true')).toBe(true);
expect(matchesFilter(null, 'null')).toBe(true);
expect(matchesFilter('Alice', 'alice')).toBe(true);
});
});
+39
View File
@@ -0,0 +1,39 @@
// 网格筛选表达式解析:比较、like 通配与纯文本匹配。
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function likePattern(raw: string): RegExp {
const escaped = escapeRegExp(raw).replaceAll('%', '.*').replaceAll('_', '.');
return new RegExp(`^${escaped}$`, 'i');
}
export function matchesFilter(value: unknown, rawExpression: string): boolean {
const expression = rawExpression.trim();
if (!expression) return true;
const normalized = expression.toLowerCase();
if (normalized === 'null') return value === null || value === undefined;
if (normalized === 'true' || normalized === 'false') return value === (normalized === 'true');
const like = expression.match(/^like\s+(.+)$/i);
if (like) return likePattern(like[1]).test(String(value ?? ''));
const comparison = expression.match(/^(>=|<=|>|<|=|!=)\s*(-?\d+(?:\.\d+)?)$/);
if (comparison) {
const left = Number(value);
const right = Number(comparison[2]);
if (!Number.isFinite(left)) return false;
switch (comparison[1]) {
case '>': return left > right;
case '<': return left < right;
case '>=': return left >= right;
case '<=': return left <= right;
case '!=': return left !== right;
default: return left === right;
}
}
return String(value ?? '').toLocaleLowerCase().includes(normalized);
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { splitSqlStatements } from './sql';
// 覆盖语句切分的分号、注释与行号计算。
describe('splitSqlStatements', () => {
it('splits a script into executable statements', () => {
expect(splitSqlStatements('SELECT 1; SELECT 2;').map((item) => item.sql)).toEqual([
'SELECT 1',
'SELECT 2',
]);
});
it('keeps semicolons inside strings and comments', () => {
const sql = "SELECT 'a;b'; -- keep ; here\nSELECT 2 /* and ; here */;";
expect(splitSqlStatements(sql).map((item) => item.sql)).toEqual([
"SELECT 'a;b'",
'-- keep ; here\nSELECT 2 /* and ; here */',
]);
});
it('reports one-based source lines for gutter decorations', () => {
const statements = splitSqlStatements('\nSELECT 1;\n\nSELECT 2');
expect(statements.map(({ startLine, endLine }) => [startLine, endLine])).toEqual([
[2, 2],
[4, 4],
]);
});
});
+76
View File
@@ -0,0 +1,76 @@
// SQL 脚本切分:识别字符串、注释并按分号拆分语句。
export interface SqlStatement {
id: string;
sql: string;
startLine: number;
endLine: number;
}
function lineAt(source: string, index: number): number {
let line = 1;
for (let cursor = 0; cursor < index; cursor += 1) {
if (source[cursor] === '\n') line += 1;
}
return line;
}
export function splitSqlStatements(source: string): SqlStatement[] {
const spans: Array<[number, number]> = [];
let start = 0;
let mode: 'normal' | 'single' | 'double' | 'line-comment' | 'block-comment' = 'normal';
for (let index = 0; index < source.length; index += 1) {
const char = source[index];
const next = source[index + 1];
if (mode === 'single') {
if (char === "'" && next === "'") index += 1;
else if (char === "'") mode = 'normal';
continue;
}
if (mode === 'double') {
if (char === '"' && next === '"') index += 1;
else if (char === '"') mode = 'normal';
continue;
}
if (mode === 'line-comment') {
if (char === '\n') mode = 'normal';
continue;
}
if (mode === 'block-comment') {
if (char === '*' && next === '/') {
mode = 'normal';
index += 1;
}
continue;
}
if (char === "'") mode = 'single';
else if (char === '"') mode = 'double';
else if (char === '-' && next === '-') {
mode = 'line-comment';
index += 1;
} else if (char === '/' && next === '*') {
mode = 'block-comment';
index += 1;
} else if (char === ';') {
spans.push([start, index]);
start = index + 1;
}
}
spans.push([start, source.length]);
return spans.flatMap(([from, to], ordinal) => {
const raw = source.slice(from, to);
const leading = raw.search(/\S/);
if (leading < 0) return [];
const sql = raw.trim();
const absoluteStart = from + leading;
const startLine = lineAt(source, absoluteStart);
return [{
id: `statement-${ordinal + 1}`,
sql,
startLine,
endLine: startLine + (sql.match(/\n/g)?.length ?? 0),
}];
});
}
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { SQL_PLAIN_COLOR, SQL_TOKEN_COLORS, tokenizeSql } from './sqlHighlight';
// 覆盖 SQL 高亮切分与执行器配色常量。
describe('tokenizeSql', () => {
it('marks keywords, strings and numbers while keeping the text intact', () => {
const sql = "UPDATE users SET name = 'A''B' WHERE id = 12;";
const tokens = tokenizeSql(sql);
expect(tokens.map((token) => token.text).join('')).toBe(sql);
expect(tokens.filter((token) => token.kind === 'keyword').map((token) => token.text)).toEqual(['UPDATE', 'SET', 'WHERE']);
expect(tokens.filter((token) => token.kind === 'string').map((token) => token.text)).toEqual(["'A''B'"]);
expect(tokens.filter((token) => token.kind === 'number').map((token) => token.text)).toEqual(['12']);
});
it('recognises line comments and leaves unknown words plain', () => {
expect(tokenizeSql('SELECT * FROM users -- note').at(-1)).toEqual({ text: '-- note', kind: 'comment' });
expect(tokenizeSql('select users').map((token) => token.kind)).toEqual(['keyword', 'plain']);
});
it('splits schema statements used by the pending list', () => {
const tokens = tokenizeSql('CREATE TABLE items (id int, title str);');
expect(tokens.filter((token) => token.kind === 'keyword').map((token) => token.text)).toEqual(['CREATE', 'TABLE', 'int', 'str']);
expect(tokens.map((token) => token.text).join('')).toBe('CREATE TABLE items (id int, title str);');
});
});
describe('SQL_TOKEN_COLORS', () => {
it('matches the colours the SQL console theme uses', () => {
expect(SQL_TOKEN_COLORS).toEqual({ keyword: '#569CD6', string: '#CE9178', number: '#B5CEA8', comment: '#6A9955' });
});
it('paints everything that is not a keyword in the plain grey', () => {
expect(SQL_PLAIN_COLOR).toBe('rgb(200, 200, 200)');
});
});
@@ -0,0 +1,76 @@
export const SQL_TOKEN_COLORS = {
keyword: '#569CD6',
string: '#CE9178',
number: '#B5CEA8',
comment: '#6A9955',
} as const;
export const SQL_PLAIN_COLOR = 'rgb(200, 200, 200)';
export type SqlTokenKind = 'plain' | keyof typeof SQL_TOKEN_COLORS;
export interface SqlToken { text: string; kind: SqlTokenKind }
// SQL 词法高亮:与执行器共用同一套 token 配色。
const keywords = new Set([
'select', 'from', 'where', 'insert', 'into', 'values', 'update', 'set', 'delete',
'create', 'table', 'index', 'on', 'drop', 'alter', 'and', 'or', 'not', 'null',
'order', 'by', 'group', 'having', 'limit', 'offset', 'join', 'left', 'right',
'inner', 'outer', 'as', 'distinct', 'primary', 'key', 'int', 'integer', 'str',
'text', 'bool', 'boolean', 'true', 'false',
]);
const wordChar = /[A-Za-z0-9_]/;
// 切分 SQL 为关键字、字符串、数字、注释与普通片段。
export function tokenizeSql(sql: string): SqlToken[] {
const tokens: SqlToken[] = [];
let plain = '';
let index = 0;
const flush = () => { if (plain) { tokens.push({ text: plain, kind: 'plain' }); plain = ''; } };
while (index < sql.length) {
const char = sql[index];
if (char === "'") {
flush();
let end = index + 1;
while (end < sql.length) {
if (sql[end] !== "'") { end += 1; continue; }
if (sql[end + 1] === "'") { end += 2; continue; }
end += 1;
break;
}
tokens.push({ text: sql.slice(index, end), kind: 'string' });
index = end;
continue;
}
if (char === '-' && sql[index + 1] === '-') {
flush();
const end = sql.indexOf('\n', index);
tokens.push({ text: end < 0 ? sql.slice(index) : sql.slice(index, end), kind: 'comment' });
index = end < 0 ? sql.length : end;
continue;
}
if (char >= '0' && char <= '9') {
flush();
let end = index;
while (end < sql.length && (wordChar.test(sql[end]) || sql[end] === '.')) end += 1;
tokens.push({ text: sql.slice(index, end), kind: 'number' });
index = end;
continue;
}
if (wordChar.test(char)) {
let end = index;
while (end < sql.length && wordChar.test(sql[end])) end += 1;
const word = sql.slice(index, end);
if (keywords.has(word.toLowerCase())) { flush(); tokens.push({ text: word, kind: 'keyword' }); }
else plain += word;
index = end;
continue;
}
plain += char;
index += 1;
}
flush();
return tokens;
}
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import { closeTab, openQueryTab, openSettingsTab, openTableTab, type Tab } from './tabs';
// 覆盖标签页打开与关闭的状态迁移。
const base: Tab[] = [
{ id: 'tab-1', kind: 'table', title: 'users', table: 'users' },
{ id: 'tab-2', kind: 'table', title: 'orders', table: 'orders' },
{ id: 'tab-3', kind: 'sql', title: '查询 1' },
];
describe('openTableTab', () => {
it('activates an existing tab instead of appending a duplicate', () => {
const state = openTableTab(base, 'users', 'tab-9');
expect(state.tabs).toHaveLength(3);
expect(state.activeId).toBe('tab-1');
expect(state.tabs).toBe(base);
});
it('appends a new tab for a table that is not open yet', () => {
const state = openTableTab(base, 'products', 'tab-9');
expect(state.tabs).toHaveLength(4);
expect(state.tabs[3]).toEqual({ id: 'tab-9', kind: 'table', title: 'products', table: 'products' });
expect(state.activeId).toBe('tab-9');
});
});
describe('openQueryTab', () => {
it('always appends so queries can be opened multiple times', () => {
const first = openQueryTab(base, '查询 1', 'tab-9');
const second = openQueryTab(first.tabs, '查询 2', 'tab-10', 'SELECT 1');
expect(second.tabs).toHaveLength(5);
expect(second.activeId).toBe('tab-10');
expect(second.tabs[4]).toEqual({ id: 'tab-10', kind: 'sql', title: '查询 2', sql: 'SELECT 1' });
});
});
describe('openSettingsTab', () => {
it('keeps a single settings tab and activates it', () => {
const first = openSettingsTab(base, '设置', 'tab-9');
const second = openSettingsTab(first.tabs, '设置', 'tab-10');
expect(second.tabs.filter((tab) => tab.kind === 'settings')).toHaveLength(1);
expect(second.tabs).toHaveLength(4);
expect(second.activeId).toBe('tab-9');
});
});
describe('closeTab', () => {
it('activates the tab that shifts in from the right', () => {
const state = closeTab(base, 'tab-1', 'tab-1');
expect(state.tabs.map((tab) => tab.id)).toEqual(['tab-2', 'tab-3']);
expect(state.activeId).toBe('tab-2');
});
it('falls back to the left neighbour when the last tab closes', () => {
const state = closeTab(base, 'tab-3', 'tab-3');
expect(state.activeId).toBe('tab-2');
});
it('keeps the active tab when a different one closes', () => {
const state = closeTab(base, 'tab-2', 'tab-3');
expect(state.tabs).toHaveLength(2);
expect(state.activeId).toBe('tab-3');
});
it('clears the active id when the only tab closes', () => {
const state = closeTab([base[0]], 'tab-1', 'tab-1');
expect(state.tabs).toHaveLength(0);
expect(state.activeId).toBeUndefined();
});
it('does not mutate the input array', () => {
const snapshot = JSON.stringify(base);
closeTab(base, 'tab-2', 'tab-2');
openTableTab(base, 'products', 'tab-9');
openQueryTab(base, '查询 2', 'tab-10');
openSettingsTab(base, '设置', 'tab-11');
expect(JSON.stringify(base)).toBe(snapshot);
});
});
+35
View File
@@ -0,0 +1,35 @@
// 标签页状态迁移:打开与关闭均返回新状态,不改入参数组。
export type TabKind = 'table' | 'sql' | 'settings';
export interface Tab { id: string; kind: TabKind; title: string; table?: string; sql?: string }
export interface TabState { tabs: Tab[]; activeId: string | undefined }
// 打开表标签:同表已开则激活,否则追加。
export function openTableTab(tabs: Tab[], name: string, id: string): TabState {
const existing = tabs.find((tab) => tab.kind === 'table' && tab.table === name);
if (existing) return { tabs, activeId: existing.id };
return { tabs: [...tabs, { id, kind: 'table', title: name, table: name }], activeId: id };
}
// 打开查询标签:每次追加,可选初始 SQL。
export function openQueryTab(tabs: Tab[], title: string, id: string, sql?: string): TabState {
return { tabs: [...tabs, { id, kind: 'sql', title, sql }], activeId: id };
}
// 打开设置标签:只保留一个,已存在则激活。
export function openSettingsTab(tabs: Tab[], title: string, id: string): TabState {
const existing = tabs.find((tab) => tab.kind === 'settings');
if (existing) return { tabs, activeId: existing.id };
return { tabs: [...tabs, { id, kind: 'settings', title }], activeId: id };
}
// 关闭标签:关当前标签时激活右邻,无则左邻。
export function closeTab(tabs: Tab[], id: string, activeId: string | undefined): TabState {
const index = tabs.findIndex((tab) => tab.id === id);
if (index < 0) return { tabs, activeId };
const remaining = tabs.filter((tab) => tab.id !== id);
if (activeId !== id) return { tabs: remaining, activeId };
return { tabs: remaining, activeId: (remaining[index] ?? remaining[index - 1])?.id };
}
@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_SETTINGS, useSettingsStore } from '../store/settingsStore';
import { loadUiSettings, saveUiSettings, syncUiSettings } from './uiSettings';
// 覆盖远端设置的校验读取、写回与防抖同步。
describe('uiSettings', () => {
let stop: (() => void) | undefined;
beforeEach(() => {
localStorage.clear();
useSettingsStore.getState().reset();
});
afterEach(() => {
stop?.();
stop = undefined;
vi.unstubAllGlobals();
localStorage.clear();
});
it('keeps known keys only and clamps the remote payload', async () => {
const request = vi.fn().mockResolvedValue(new Response(JSON.stringify({
uiFonts: ['', 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', ' '],
gridFonts: 'Consolas',
sqlFonts: ['Consolas'],
uiFontSize: 99,
gridFontSize: 9,
gridRowHeight: 17.4,
sqlFontSize: 13.5,
sqlLineHeight: 60,
sqlTabSize: 0,
pageSize: 2,
nullText: 'NULL',
sqlLineNumbers: false,
autocomplete: 'yes',
minimap: true,
confirmBeforeCommit: 1,
unknownKey: 5,
}), { status: 200 }));
vi.stubGlobal('fetch', request);
await expect(loadUiSettings()).resolves.toEqual({
uiFonts: ['f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10'],
sqlFonts: ['Consolas'],
uiFontSize: 16,
gridFontSize: 10,
gridRowHeight: 18,
sqlFontSize: 14,
sqlLineHeight: 40,
sqlTabSize: 1,
pageSize: 10,
nullText: 'NULL',
sqlLineNumbers: false,
minimap: true,
});
expect(request).toHaveBeenCalledWith('/api/ui-settings', { credentials: 'same-origin' });
});
it('returns undefined instead of throwing when the read fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 401 })));
await expect(loadUiSettings()).resolves.toBeUndefined();
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
await expect(loadUiSettings()).resolves.toBeUndefined();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('not json', { status: 200 })));
await expect(loadUiSettings()).resolves.toBeUndefined();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('[]', { status: 200 })));
await expect(loadUiSettings()).resolves.toBeUndefined();
});
it('puts the whole settings object back to the endpoint', async () => {
const request = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', request);
await expect(saveUiSettings(DEFAULT_SETTINGS)).resolves.toBe(true);
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledWith('/api/ui-settings', {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(DEFAULT_SETTINGS),
});
});
it('reports a failed write as false', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 500 })));
await expect(saveUiSettings(DEFAULT_SETTINGS)).resolves.toBe(false);
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
await expect(saveUiSettings(DEFAULT_SETTINGS)).resolves.toBe(false);
});
it('merges remote settings first and then debounces one write', async () => {
const request = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ uiFonts: ['Consolas'], uiFontSize: 15, autocomplete: false }), { status: 200 }))
.mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', request);
stop = syncUiSettings();
await vi.waitFor(() => expect(useSettingsStore.getState().settings.autocomplete).toBe(false));
expect(useSettingsStore.getState().settings.uiFonts).toEqual(['Consolas']);
expect(useSettingsStore.getState().settings.uiFontSize).toBe(15);
expect(request).toHaveBeenCalledTimes(1);
useSettingsStore.getState().setNumber('pageSize', 300);
useSettingsStore.getState().setNumber('pageSize', 320);
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2), { timeout: 2000 });
const [url, init] = request.mock.calls[1] as [string, RequestInit];
expect(url).toBe('/api/ui-settings');
expect(init.method).toBe('PUT');
expect(JSON.parse(String(init.body)).pageSize).toBe(320);
});
});
+104
View File
@@ -0,0 +1,104 @@
import { useSettingsStore, type IdeSettings } from '../store/settingsStore';
// 远端 IDE 设置的读取、校验与防抖回写。
const endpoint = '/api/ui-settings';
const debounceMs = 400;
const fontKeys = ['uiFonts', 'gridFonts', 'sqlFonts'] as const;
const booleanKeys = ['sqlLineNumbers', 'autocomplete', 'minimap'] as const;
const numberKeys = ['uiFontSize', 'gridFontSize', 'gridRowHeight', 'sqlFontSize', 'sqlLineHeight', 'sqlTabSize', 'pageSize'] as const;
type NumberKey = (typeof numberKeys)[number];
const numberRanges: Record<NumberKey, [number, number]> = {
uiFontSize: [11, 16],
gridFontSize: [10, 18],
gridRowHeight: [18, 40],
sqlFontSize: [10, 20],
sqlLineHeight: [16, 40],
sqlTabSize: [1, 8],
pageSize: [10, 500],
};
// 收敛字体链:去空串并截断到 10 项。
function readFonts(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
return value.filter((font): font is string => typeof font === 'string' && font.trim().length > 0).slice(0, 10);
}
// 只取已知键,数值夹紧,类型不符即丢弃。
function parseUiSettings(body: unknown): Partial<IdeSettings> {
const parsed: Partial<IdeSettings> = {};
if (!body || typeof body !== 'object' || Array.isArray(body)) return parsed;
const source = body as Record<string, unknown>;
for (const key of fontKeys) {
const fonts = readFonts(source[key]);
if (fonts) parsed[key] = fonts;
}
for (const key of numberKeys) {
const value = source[key];
if (typeof value !== 'number' || !Number.isFinite(value)) continue;
const [min, max] = numberRanges[key];
parsed[key] = Math.min(max, Math.max(min, Math.round(value)));
}
for (const key of booleanKeys) {
const value = source[key];
if (typeof value === 'boolean') parsed[key] = value;
}
if (typeof source.nullText === 'string') parsed.nullText = source.nullText;
return parsed;
}
// 读取远端设置,失败时返回 undefined。
export async function loadUiSettings(): Promise<Partial<IdeSettings> | undefined> {
try {
const response = await fetch(endpoint, { credentials: 'same-origin' });
if (!response.ok) return undefined;
const body: unknown = await response.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) return undefined;
return parseUiSettings(body);
} catch {
return undefined;
}
}
// 全量写回远端设置,失败时返回 false。
export async function saveUiSettings(settings: IdeSettings): Promise<boolean> {
try {
const response = await fetch(endpoint, {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
return response.ok;
} catch {
return false;
}
}
// 先并入远端设置,再防抖回写后续变更。
export function syncUiSettings(): () => void {
let stopped = false;
let unsubscribe: (() => void) | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
const cancel = () => { if (timer !== undefined) { clearTimeout(timer); timer = undefined; } };
void (async () => {
const remote = await loadUiSettings();
if (stopped) return;
if (remote) useSettingsStore.setState((state) => ({ settings: { ...state.settings, ...remote } }));
unsubscribe = useSettingsStore.subscribe((state, previous) => {
if (state.settings === previous.settings) return;
cancel();
timer = setTimeout(() => {
timer = undefined;
void saveUiSettings(useSettingsStore.getState().settings);
}, debounceMs);
});
})();
return () => {
stopped = true;
unsubscribe?.();
cancel();
};
}
+161
View File
@@ -0,0 +1,161 @@
// 中文界面文案表:全站 UI 文本的唯一来源。
export const zhCN = {
appName: 'ChuSQL 数据库终端',
signingIn: '正在检查登录状态…',
signInTitle: '登录 ChuSQL',
userName: '用户名',
password: '密码',
signIn: '登录',
signInFailed: '登录失败,请检查用户名和密码',
connection: '本地连接',
database: 'chusql',
explorer: '资源管理器',
databaseSection: '数据库',
activityBar: '活动栏',
selectionColumn: '选择',
changesSection: '变更集',
historySection: '历史',
sqlConsole: 'SQL 控制台',
newQuery: '新建查询',
runNext: '单步执行',
runAll: '全部执行',
runSelection: '执行选中',
stop: '停止',
statementPending: '待执行',
statementRunning: '执行中',
statementSuccess: '成功',
statementFailed: '失败',
executionStopped: '已停止;已发出的语句可能仍在服务端运行',
executionSuccess: (rows: number, duration: number) => `执行成功,返回 ${rows} 行 (${duration.toFixed(1)} ms)`,
executionAffected: (rows: number, duration: number) => `执行成功,影响 ${rows} 行 (${duration.toFixed(1)} ms)`,
resultLabel: (index: number, rows: number, duration: number) => `结果 ${index} (${rows} 行, ${duration.toFixed(1)} ms)`,
noStatement: '没有可执行的 SQL 语句',
queryHistory: '查询历史',
settings: '设置',
settingsAppearance: '外观与字体',
settingsBehavior: '数据与编辑器',
interfaceFont: '界面字体',
gridFont: '表格字体',
sqlFont: 'SQL 编辑器字体',
fontName: '字体名称',
addFallback: '添加备用字体',
removeFont: (font: string) => `删除字体 ${font}`,
moveFont: '拖动调整字体顺序',
fontSize: '字号',
rowHeight: '行高',
tabSize: 'Tab 宽度',
showLineNumbers: '显示行号',
pageSize: '分页大小',
nullDisplay: 'NULL 显示文本',
autocomplete: '自动补全',
minimap: 'Minimap',
restoreDefaults: '恢复默认',
preview: '实时预览',
previewText: 'ChuSQL 数据库终端 SELECT * FROM users;',
commit: (count: number) => `提交 (${count})`,
rollback: '回滚',
undo: '撤销',
redo: '重做',
insertRow: '插入行',
deleteRows: '删除选中行',
batchReplace: '批量替换',
findText: '查找内容',
replaceText: '替换为',
replaceSelected: '替换选中区域',
selectedRowsRequired: '请先选择要操作的行',
refresh: '刷新',
filter: (column: string) => `筛选 ${column}`,
openTable: (table: string) => `打开表 ${table}`,
closeTab: (table: string) => `关闭 ${table}`,
editCell: (column: string) => `编辑 ${column}`,
rowSelect: (row: number) => `选择第 ${row} 行`,
noRows: '没有符合条件的数据',
loading: '正在加载…',
loadError: '加载失败',
retry: '重试',
previousPage: '上一页',
nextPage: '下一页',
loadMore: '加载更多',
pendingChanges: '待提交变更',
updateChange: (table: string, column: string) => `修改 ${table}.${column}`,
insertChange: (table: string) => `向 ${table} 插入一行`,
deleteChange: (table: string) => `从 ${table} 删除一行`,
createTableChange: (table: string) => `新建表 ${table}`,
dropTableChange: (table: string) => `删除表 ${table}`,
createIndexChange: (table: string, column: string) => `为 ${table}.${column} 建索引`,
dropIndexChange: (table: string, column: string) => `删除 ${table}.${column} 的索引`,
undoChange: '撤销此变更',
noPendingChanges: '没有待提交变更',
noResults: '尚无查询结果',
results: '结果',
messages: '消息',
cancel: '取消',
committed: (count: number) => `已提交 ${count} 项变更`,
commitFailed: '提交失败,变更已保留',
rowCount: (count: number) => `${count} 行`,
pendingCount: (count: number) => `待提交 ${count} 项`,
nullValue: 'NULL',
emptyWorkspace: '从左侧资源管理器打开一张表,或打开 SQL 控制台。',
queryTab: (index: number) => `查询 ${index}`,
resizeSidebar: '拖动调整侧边栏宽度',
resizeDock: '拖动调整底部面板高度',
close: '关闭',
booleanTrue: '是',
booleanFalse: '否',
done: '完成',
clear: '清空',
remove: '移除',
newTable: '新建表',
expandColumns: (table: string) => `展开 ${table} 的列`,
manageIndexes: (table: string) => `管理 ${table} 的索引`,
dropTableAction: (table: string) => `删除表 ${table}`,
columnPrimaryKey: '主键列',
columnIndexed: '索引列',
columnOrdinary: '普通列',
newTableTitle: '新建表',
dropTableTitle: '删除表',
indexManagerTitle: '索引管理',
tableName: '表名',
columnName: (index: number) => `列名 ${index}`,
columnType: (index: number) => `列类型 ${index}`,
removeColumn: (index: number) => `移除第 ${index} 列`,
addColumn: '添加列',
createTable: '建表',
dropTablePermanently: '永久删除表',
typeInt: '整数',
typeStr: '文本',
typeBool: '布尔',
typeDate: '日期',
stagedMessage: (sql: string) => `已暂存:${sql}`,
builtinIndex: '内建主键索引',
uniqueIndex: '唯一索引',
noIndex: '无索引',
createIndex: '建索引',
deleteIndex: '删索引',
createTableHint: '作为待提交变更暂存,提交时建表。整数 id 固定为内建主键;至少填一列,并删掉多余的空列。',
dropTableHint: (table: string, rows: number) => `作为待提交变更暂存:提交后会删掉 ${table} 的全部 ${rows} 行、列定义与索引,且不可撤销。`,
indexHint: '索引变更先暂存,提交时才下发。目前只支持唯一整数索引,列里有重复值时提交会失败;内建 id 索引不能删除。',
invalidIdentifier: '表名与列名必须以字母或下划线开头,且只能包含字母、数字与下划线。',
duplicateColumns: '列名重复',
insertRowTitle: (table: string) => `插入行 · ${table}`,
addToPending: '加入待提交',
insertRowHint: '每个字段都要填。加入后仍可修改或撤销,点顶部的「提交」才会写入数据库。',
primaryKeyBadge: ' · 主键(必填且唯一)',
invalidInteger: (column: string) => `${column}:请输入合法整数`,
chooseDate: (column: string) => `${column}:请选择日期`,
selectRowsHint: '单击选中一行;Ctrl+单击多选,Shift+单击连选,空格键也可选中',
selectedRows: (count: number) => `已选 ${count} 行`,
rowStaged: '新行已加入待提交',
filterPlaceholder: '筛选表达式',
resizeColumn: (column: string) => `调整 ${column} 列宽`,
} as const;
export const columnTypeLabels: Record<string, string> = {
int: zhCN.typeInt,
str: zhCN.typeStr,
bool: zhCN.typeBool,
date: zhCN.typeDate,
};
export type Messages = typeof zhCN;
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff. Show more