使用React js完成web前端
This commit is contained in:
1 parent
9e8c5f1ef0
commit
7600f0f9ed
130 files changed
+12708
-788
No files matched your search
+16
@@ -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/
|
||||
@@ -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(复合键 + 字节键 → 非唯一索引)。
|
||||
@@ -4,11 +4,13 @@
|
||||
|
||||
## 仓库布局
|
||||
|
||||
| 目录 | 语言 | 组件 |
|
||||
| ----------------- | ------- | ----------------------------------------------------------------- |
|
||||
| `chusql-engine/` | Haskell | 执行引擎:词法分析与AST、表达式求值、关系代数、内存表、服务端接口 |
|
||||
| `chusql-storage/` | Rust | 存储引擎:磁盘IO、B+树、缓冲池、WAL、崩溃恢复、并发控制 |
|
||||
| `chusql-web/` | 前端 | 浏览器端管理客户端 |
|
||||
| 目录 | 语言 | 组件 |
|
||||
| ----------------- | -------------------------- | ----------------------------------------------------------------- |
|
||||
| `chusql-engine/` | Haskell | 执行引擎:词法分析与AST、表达式求值、关系代数、内存表、服务端接口 |
|
||||
| `chusql-storage/` | Rust | 存储引擎:磁盘IO、B+树、缓冲池、WAL、崩溃恢复、并发控制 |
|
||||
| `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,201 +37,36 @@
|
||||
- 事务日志和崩溃恢复
|
||||
- 并发控制
|
||||
|
||||
## 前端部分
|
||||
# 配置
|
||||
|
||||
- 前端部分目前还没想好选型架构,暂且使用简单前端三件套做一个简单的仿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 存储进程一起启动,跑真实情景):
|
||||
| 指标 | 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/行** |
|
||||
| `SELECT * FROM users` | 0.4 ms | 1.0 ms | 1.9 ms |
|
||||
| `SELECT name FROM users WHERE age > 90` | 0.3 ms | 0.8 ms | 2.7 ms |
|
||||
| `SELECT ... JOIN ... WHERE` | 0.9 ms | 2.4 ms | 5.3 ms |
|
||||
| `SELECT name FROM users WHERE id = k` | 0.2 ms | 0.2 ms | 0.2 ms |
|
||||
| `DELETE FROM users WHERE id = k`(删 1 行) | — | — | 4.8 ms |
|
||||
| `DELETE FROM users`(删全部 1000 行) | — | — | 20.2 ms |
|
||||
| `SELECT ... WHERE code = k`(无索引 → 建索引后) | — | — | 1.8 ms → **0.2 ms** |
|
||||
|
||||
| 基准 | 位置 | 测什么 |
|
||||
| ---- | ---- | ------ |
|
||||
| **全链路** | `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/行** |
|
||||
| `SELECT * FROM users` | 0.4 ms | 1.0 ms | 1.9 ms |
|
||||
| `SELECT name FROM users WHERE age > 90` | 0.3 ms | 0.8 ms | 2.7 ms |
|
||||
| `SELECT ... JOIN ... WHERE` | 0.9 ms | 2.4 ms | 5.3 ms |
|
||||
| `SELECT name FROM users WHERE id = k` | 0.2 ms | 0.2 ms | 0.2 ms |
|
||||
| `DELETE FROM users WHERE id = k`(删 1 行) | — | — | 4.8 ms |
|
||||
| `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
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
[收尾] 停存储进程、清理数据目录
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||
@@ -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) ->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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() }
|
||||
/// 命中次数
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Whitespace-only changes.
@@ -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)"
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
// CLI 占位入口:功能尚未实现,仅提示用户。
|
||||
|
||||
console.log('ChuSQL CLI is not implemented yet. Coming soon.');
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
Generated
+2226
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user