diff --git a/.gitignore b/.gitignore index cfb6f09..e0781f4 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..202a4ad --- /dev/null +++ b/AGENTS.md @@ -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(复合键 + 字节键 → 非唯一索引)。 diff --git a/README.md b/README.md index 5f46f83..41606b3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/benchmark/chusql-engine/src/Main.hs b/benchmark/chusql-engine/src/Main.hs index 0448db3..69ce86c 100644 --- a/benchmark/chusql-engine/src/Main.hs +++ b/benchmark/chusql-engine/src/Main.hs @@ -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 diff --git a/benchmark/chusql-storage/benches/storage.rs b/benchmark/chusql-storage/benches/storage.rs index 1aaf8d0..2fa81b7 100644 --- a/benchmark/chusql-storage/benches/storage.rs +++ b/benchmark/chusql-storage/benches/storage.rs @@ -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(label: &str, ops: usize, rounds: usize, mut f: F) { let mut best = Duration::MAX; for _ in 0..rounds { @@ -30,7 +25,7 @@ fn bench(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(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 = (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)); } diff --git a/benchmark/fullchain/run-p2.txt b/benchmark/fullchain/run-p2.txt new file mode 100644 index 0000000..3cd2ff4 --- /dev/null +++ b/benchmark/fullchain/run-p2.txt @@ -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 + +[收尾] 停存储进程、清理数据目录 diff --git a/benchmark/fullchain/src/Main.hs b/benchmark/fullchain/src/Main.hs index b4ae703..4f2f3fd 100644 --- a/benchmark/fullchain/src/Main.hs +++ b/benchmark/fullchain/src/Main.hs @@ -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)) diff --git a/chusql-engine/src/ChuSQL/Algebra/Eval.hs b/chusql-engine/src/ChuSQL/Algebra/Eval.hs index 6221f53..35d9599 100644 --- a/chusql-engine/src/ChuSQL/Algebra/Eval.hs +++ b/chusql-engine/src/ChuSQL/Algebra/Eval.hs @@ -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 diff --git a/chusql-engine/src/ChuSQL/Algebra/Expr.hs b/chusql-engine/src/ChuSQL/Algebra/Expr.hs index c4533aa..1f8295e 100644 --- a/chusql-engine/src/ChuSQL/Algebra/Expr.hs +++ b/chusql-engine/src/ChuSQL/Algebra/Expr.hs @@ -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) -> diff --git a/chusql-engine/src/ChuSQL/Algebra/Op.hs b/chusql-engine/src/ChuSQL/Algebra/Op.hs index 0f1602a..25e1dd8 100644 --- a/chusql-engine/src/ChuSQL/Algebra/Op.hs +++ b/chusql-engine/src/ChuSQL/Algebra/Op.hs @@ -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 diff --git a/chusql-engine/src/ChuSQL/Algebra/Optimize.hs b/chusql-engine/src/ChuSQL/Algebra/Optimize.hs index bd9f1df..c6b822c 100644 --- a/chusql-engine/src/ChuSQL/Algebra/Optimize.hs +++ b/chusql-engine/src/ChuSQL/Algebra/Optimize.hs @@ -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 diff --git a/chusql-engine/src/ChuSQL/Engine.hs b/chusql-engine/src/ChuSQL/Engine.hs index c5c25f1..a57b594 100644 --- a/chusql-engine/src/ChuSQL/Engine.hs +++ b/chusql-engine/src/ChuSQL/Engine.hs @@ -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 diff --git a/chusql-engine/src/ChuSQL/Model.hs b/chusql-engine/src/ChuSQL/Model.hs index e1f7d59..65bf61d 100644 --- a/chusql-engine/src/ChuSQL/Model.hs +++ b/chusql-engine/src/ChuSQL/Model.hs @@ -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) diff --git a/chusql-engine/src/ChuSQL/Semantic.hs b/chusql-engine/src/ChuSQL/Semantic.hs index bc2b097..782aefe 100644 --- a/chusql-engine/src/ChuSQL/Semantic.hs +++ b/chusql-engine/src/ChuSQL/Semantic.hs @@ -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" diff --git a/chusql-engine/src/ChuSQL/Storage.hs b/chusql-engine/src/ChuSQL/Storage.hs index 0530e7d..d07e8c9 100644 --- a/chusql-engine/src/ChuSQL/Storage.hs +++ b/chusql-engine/src/ChuSQL/Storage.hs @@ -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 diff --git a/chusql-engine/src/ChuSQL/Storage/IPC.hs b/chusql-engine/src/ChuSQL/Storage/IPC.hs index b5574ba..2ae3bf5 100644 --- a/chusql-engine/src/ChuSQL/Storage/IPC.hs +++ b/chusql-engine/src/ChuSQL/Storage/IPC.hs @@ -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 diff --git a/chusql-engine/src/ChuSQL/Storage/Memory.hs b/chusql-engine/src/ChuSQL/Storage/Memory.hs index ca838ee..d83ee39 100644 --- a/chusql-engine/src/ChuSQL/Storage/Memory.hs +++ b/chusql-engine/src/ChuSQL/Storage/Memory.hs @@ -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 = diff --git a/chusql-engine/src/ChuSQL/Syntax/AST.hs b/chusql-engine/src/ChuSQL/Syntax/AST.hs index a5c02c0..69de683 100644 --- a/chusql-engine/src/ChuSQL/Syntax/AST.hs +++ b/chusql-engine/src/ChuSQL/Syntax/AST.hs @@ -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 = diff --git a/chusql-engine/src/ChuSQL/Syntax/Parser.hs b/chusql-engine/src/ChuSQL/Syntax/Parser.hs index 34102f0..35f0a32 100644 --- a/chusql-engine/src/ChuSQL/Syntax/Parser.hs +++ b/chusql-engine/src/ChuSQL/Syntax/Parser.hs @@ -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 diff --git a/chusql-engine/test.ghci b/chusql-engine/test.ghci new file mode 100644 index 0000000..a010257 --- /dev/null +++ b/chusql-engine/test.ghci @@ -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" diff --git a/chusql-engine/test/Spec.hs b/chusql-engine/test/Spec.hs index efe726f..ad2cc62 100644 --- a/chusql-engine/test/Spec.hs +++ b/chusql-engine/test/Spec.hs @@ -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 diff --git a/chusql-storage/chusql-storage.toml.example b/chusql-storage/chusql-storage.toml.example index 63c2879..80df6ab 100644 --- a/chusql-storage/chusql-storage.toml.example +++ b/chusql-storage/chusql-storage.toml.example @@ -22,7 +22,7 @@ order = 4 [storage] # 数据目录:一张表一个 <表名>.db,一个索引一个 <表名>.idx。 # 环境变量:CHUSQL_DATA_DIR -data_dir = "data" +data_dir = "../localdata" [server] # 命名管道名(Windows 上实际是 \\.\pipe\<这个名字>)。 diff --git a/chusql-storage/src/bin/server.rs b/chusql-storage/src/bin/chusql-storage.rs similarity index 89% rename from chusql-storage/src/bin/server.rs rename to chusql-storage/src/bin/chusql-storage.rs index ba7985b..81cbbeb 100644 --- a/chusql-storage/src/bin/server.rs +++ b/chusql-storage/src/bin/chusql-storage.rs @@ -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\。 // 启动时读配置并把每项来源打进日志;表句柄按名字缓存复用。 -// 服务器 -/// 服务器状态:配置 + 表句柄缓存 + 写锁 + 数据字典 struct Server { cfg: Config, tables: Mutex>>>, @@ -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 { 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, ) -> std::io::Result { - // 已经在缓存里的表不用再查磁盘(省一次 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 = 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 = self + .with_existing_table(table, |t| t.scan())? + .into_iter() + .map(|row| { + row.into_iter() + .filter(|(k, _)| k != column) + .collect::() + }) + .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> { 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 { 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); } diff --git a/chusql-storage/src/btree.rs b/chusql-storage/src/btree.rs index 995e953..b1c07e1 100644 --- a/chusql-storage/src/btree.rs +++ b/chusql-storage/src/btree.rs @@ -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, @@ -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 { match self.root()? { None => Ok(false), diff --git a/chusql-storage/src/catalog.rs b/chusql-storage/src/catalog.rs index 250393d..c87852c 100644 --- a/chusql-storage/src/catalog.rs +++ b/chusql-storage/src/catalog.rs @@ -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, #[serde(default)] @@ -47,16 +36,13 @@ pub struct TableSchema { } #[derive(Debug, Default, Serialize, Deserialize)] -/// 数据字典:表名到 schema pub struct Catalog { tables: BTreeMap, - /// 每列"见过的值"的哈希集合(只在内存里,不落盘;有上限) #[serde(skip)] seen: BTreeMap>, } impl Catalog { - // 读取与保存 /// 从文件读;不存在给空字典 pub fn load>(path: P) -> io::Result { 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 { + 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 { } } -/// 一个值的哈希(按它的 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); diff --git a/chusql-storage/src/config.rs b/chusql-storage/src/config.rs index 5da95fb..9018765 100644 --- a/chusql-storage/src/config.rs +++ b/chusql-storage/src/config.rs @@ -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, @@ -84,8 +69,6 @@ pub struct Loaded { #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] -// 文件形状 -/// TOML 的形状(字段都可选) struct FileConfig { page: Option, btree: Option, @@ -97,47 +80,40 @@ struct FileConfig { #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] -/// [buffer] 段 struct FileBuffer { pool_size: Option, } #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] -/// [page] 段 struct FilePage { size: Option, } #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] -/// [btree] 段 struct FileBtree { order: Option, } #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] -/// [storage] 段 struct FileStorage { data_dir: Option, } #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] -/// [server] 段 struct FileServer { pipe_name: Option, } #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] -/// [log] 段 struct FileLog { level: Option, } -// 加载 /// 按真实环境加载 pub fn load() -> Result { 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( 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() { diff --git a/chusql-storage/src/heap.rs b/chusql-storage/src/heap.rs index c19e599..a2491ca 100644 --- a/chusql-storage/src/heap.rs +++ b/chusql-storage/src/heap.rs @@ -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 { } } -// 页内操作 -/// 只管一页里的字节 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, @@ -173,7 +159,7 @@ impl HeapTable { }) } - /// 打开表 + `id` 列索引(`id` 沿用老文件名 `表名.idx`) + /// 打开表并挂上 id 列索引 pub fn open_indexed, Q: AsRef>( path: P, index_path: Q, @@ -189,7 +175,7 @@ impl HeapTable { Ok(t) } - /// 打开表 + 任意组索引:(列名, 索引文件路径) + /// 打开表并挂上多组索引 pub fn open_with_indexes>( path: P, indexes: &[(String, std::path::PathBuf)], @@ -207,7 +193,7 @@ impl HeapTable { Ok(t) } - /// 挂上一棵索引树(只打开/创建文件,不填数据) + /// 挂上一棵索引树,不填数据 fn attach_index>( &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 = 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> { - // 先读出来:删完就不知道这行各列是什么,索引也就无从摘起 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> { 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() } /// 命中次数 diff --git a/chusql-storage/src/log.rs b/chusql-storage/src/log.rs index e360443..5f8e1ac 100644 --- a/chusql-storage/src/log.rs +++ b/chusql-storage/src/log.rs @@ -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 = 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 { diff --git a/chusql-storage/src/page.rs b/chusql-storage/src/page.rs index f2f51f7..72316ad 100644 --- a/chusql-storage/src/page.rs +++ b/chusql-storage/src/page.rs @@ -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, @@ -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(); diff --git a/chusql-storage/src/protocol.rs b/chusql-storage/src/protocol.rs index d1163f8..187bcea 100644 --- a/chusql-storage/src/protocol.rs +++ b/chusql-storage/src/protocol.rs @@ -3,12 +3,10 @@ use std::collections::HashMap; // 协议:Haskell 与 Rust 之间的请求 / 响应类型。 -/// 一行:列名到 JSON 值 pub type Row = HashMap; #[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, }, - /// 按 id 批量删行 DeleteKeys { table: String, keys: Vec, }, - /// 按某一列的索引取一行(列名默认 "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 }, @@ -118,7 +108,6 @@ pub enum Response { #[serde(default)] stats: Vec, }, - /// 这一列没有索引 —— 调用方应该退回全表扫描 NoIndex, Ok, Error { message: String }, diff --git a/chusql-storage/src/wal.rs b/chusql-storage/src/wal.rs index b0dadb7..429d8be 100644 --- a/chusql-storage/src/wal.rs +++ b/chusql-storage/src/wal.rs @@ -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, }, - /// 按 id 批量删行(重放是幂等的:再删一次等于什么都没删) DeleteKeys { table: String, keys: Vec, @@ -36,20 +33,25 @@ pub enum WalOp { table: String, rows: Vec, }, + DropColumn { + table: String, + column: String, + rows: Vec, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +struct DropColumnPayload { + column: String, + rows: Vec, } -/// WAL 文件 -/// -/// 句柄只开一次并一直留着:写入路径是每条操作一次 -/// `truncate -> append -> truncate`,每次都重新 open/close 文件要多花 -/// 一倍的时间(见 benchmark/chusql-storage)。 pub struct Wal { path: PathBuf, handle: Mutex>, } impl Wal { - // 读写 /// 绑定 WAL 文件路径 pub fn new>(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> { let (op_type, table, payload) = match op { @@ -150,6 +150,14 @@ fn encode(op: &WalOp) -> io::Result> { 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, 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)) diff --git a/chusql-storage/tests/btree_test.rs b/chusql-storage/tests/btree_test.rs index 5dcfddc..7eac2ab 100644 --- a/chusql-storage/tests/btree_test.rs +++ b/chusql-storage/tests/btree_test.rs @@ -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(); } diff --git a/chusql-storage/tests/catalog_test.rs b/chusql-storage/tests/catalog_test.rs index b6fc394..662af90 100644 --- a/chusql-storage/tests/catalog_test.rs +++ b/chusql-storage/tests/catalog_test.rs @@ -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()); +} diff --git a/chusql-storage/tests/heap_test.rs b/chusql-storage/tests/heap_test.rs index 6c8eb3c..1787c97 100644 --- a/chusql-storage/tests/heap_test.rs +++ b/chusql-storage/tests/heap_test.rs @@ -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 = 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"); } diff --git a/chusql-storage/tests/pipe_test.rs b/chusql-storage/tests/pipe_test.rs index 431c276..abf94ce 100644 --- a/chusql-storage/tests/pipe_test.rs +++ b/chusql-storage/tests/pipe_test.rs @@ -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()) diff --git a/chusql-storage/tests/wal_test.rs b/chusql-storage/tests/wal_test.rs index eccdb22..1fceef1 100644 --- a/chusql-storage/tests/wal_test.rs +++ b/chusql-storage/tests/wal_test.rs @@ -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() { diff --git a/chusql-web/.gitkeep b/chusql-web/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/chusql-web/app/Main.hs b/chusql-web/app/Main.hs new file mode 100644 index 0000000..1391353 --- /dev/null +++ b/chusql-web/app/Main.hs @@ -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)" diff --git a/chusql-web/chusql-web.cabal b/chusql-web/chusql-web.cabal new file mode 100644 index 0000000..917d88d --- /dev/null +++ b/chusql-web/chusql-web.cabal @@ -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 diff --git a/chusql-web/frontend/bin/chusql-cli.js b/chusql-web/frontend/bin/chusql-cli.js new file mode 100644 index 0000000..f7a139d --- /dev/null +++ b/chusql-web/frontend/bin/chusql-cli.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node +// CLI 占位入口:功能尚未实现,仅提示用户。 + +console.log('ChuSQL CLI is not implemented yet. Coming soon.'); diff --git a/chusql-web/frontend/bin/chusql.js b/chusql-web/frontend/bin/chusql.js new file mode 100644 index 0000000..44270cb --- /dev/null +++ b/chusql-web/frontend/bin/chusql.js @@ -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 [options] + +Commands: + web Start the web server + config list List all configuration + config get Get a configuration value + config set Set a configuration value + config unset Remove a configuration value + config path Print the configuration file path + help Show this help message + +Global options: + --port Specify port + --host Specify host + --no-open Do not open the browser automatically + --config Use a custom configuration file + --adapter Specify the data source adapter + --help, -h Show help + --version, -v Show version + +Run "chusql --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 '); + 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 '); + 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 '); + 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; +} diff --git a/chusql-web/frontend/bin/lib/args.js b/chusql-web/frontend/bin/lib/args.js new file mode 100644 index 0000000..aaab954 --- /dev/null +++ b/chusql-web/frontend/bin/lib/args.js @@ -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; +} diff --git a/chusql-web/frontend/bin/lib/args.test.js b/chusql-web/frontend/bin/lib/args.test.js new file mode 100644 index 0000000..a22e91d --- /dev/null +++ b/chusql-web/frontend/bin/lib/args.test.js @@ -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'); + }); +}); diff --git a/chusql-web/frontend/bin/lib/config.js b/chusql-web/frontend/bin/lib/config.js new file mode 100644 index 0000000..14f7a39 --- /dev/null +++ b/chusql-web/frontend/bin/lib/config.js @@ -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; +} diff --git a/chusql-web/frontend/bin/lib/config.test.js b/chusql-web/frontend/bin/lib/config.test.js new file mode 100644 index 0000000..9084d91 --- /dev/null +++ b/chusql-web/frontend/bin/lib/config.test.js @@ -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(); + }); +}); diff --git a/chusql-web/frontend/bin/lib/open.js b/chusql-web/frontend/bin/lib/open.js new file mode 100644 index 0000000..2193497 --- /dev/null +++ b/chusql-web/frontend/bin/lib/open.js @@ -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(); +} diff --git a/chusql-web/frontend/index.html b/chusql-web/frontend/index.html new file mode 100644 index 0000000..b59c5c1 --- /dev/null +++ b/chusql-web/frontend/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + ChuSQL 数据库终端 + + + +
+ + + diff --git a/chusql-web/frontend/package-lock.json b/chusql-web/frontend/package-lock.json new file mode 100644 index 0000000..aed5115 --- /dev/null +++ b/chusql-web/frontend/package-lock.json @@ -0,0 +1,2226 @@ +{ + "name": "chusql-web-ide", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "chusql-web-ide", + "version": "1.0.0", + "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" + }, + "bin": { + "chusql": "bin/chusql.js", + "chusql-cli": "bin/chusql-cli.js" + }, + "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" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.151.0", + "resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.6.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.4", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.20.4.tgz", + "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.18", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.5", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-5.0.2.tgz", + "integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.2", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-5.0.2.tgz", + "integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmmirror.com/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "devOptional": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "25.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/monaco-editor": { + "version": "0.52.2", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", + "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nwsapi": { + "version": "2.2.28", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "18.3.1", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/redent": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/rolldown": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.151.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.7.2", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.1", + "resolved": "https://registry.npmmirror.com/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.9", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/vitest/-/vitest-5.0.2.tgz", + "integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.2", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.2", + "@vitest/browser-preview": "5.0.2", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.2", + "@vitest/coverage-v8": "5.0.2", + "@vitest/ui": "5.0.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-3.2.2.tgz", + "integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/zustand": { + "version": "5.0.3", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/chusql-web/frontend/package.json b/chusql-web/frontend/package.json new file mode 100644 index 0000000..33ae8e2 --- /dev/null +++ b/chusql-web/frontend/package.json @@ -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" + } +} diff --git a/chusql-web/frontend/src/App.module.css b/chusql-web/frontend/src/App.module.css new file mode 100644 index 0000000..94b23f9 --- /dev/null +++ b/chusql-web/frontend/src/App.module.css @@ -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; } diff --git a/chusql-web/frontend/src/App.test.tsx b/chusql-web/frontend/src/App.test.tsx new file mode 100644 index 0000000..77d44f9 --- /dev/null +++ b/chusql-web/frontend/src/App.test.tsx @@ -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: () =>
})); + +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(); + 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(); + + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/chusql-web/frontend/src/App.tsx b/chusql-web/frontend/src/App.tsx new file mode 100644 index 0000000..c52be28 --- /dev/null +++ b/chusql-web/frontend/src/App.tsx @@ -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([]); + const [tabs, setTabs] = useState([]); + const [activeId, setActiveId] = useState(); + const [totals, setTotals] = useState>({}); + const [messages, setMessages] = useState([]); + const [results, setResults] = useState([]); + const [history, setHistory] = useState(initialHistory); + const [revision, setRevision] = useState(0); + const [committing, setCommitting] = useState(false); + const [schemaAction, setSchemaAction] = useState(); + 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 =
{zhCN.emptyWorkspace}
; + if (tabs.length) { + let body =
{zhCN.emptyWorkspace}
; + if (activeTab?.kind === 'sql') body = {zhCN.loading}
}> setResults((current) => [...current, result])} onMessage={pushMessage} onHistory={setHistory} />; + else if (activeTab?.kind === 'settings') body = ; + else if (activeTableTab && activeMeta) body = setTotals((current) => ({ ...current, [activeTableTab.id]: value }))} onMessage={pushMessage} />; + editor =
{tabs.map((tab) =>
)}
{body}
; + } + + return <> setSchemaAction({ kind: 'create' })} onIndexes={(table) => setSchemaAction({ kind: 'indexes', table })} onDropTable={(table) => setSchemaAction({ kind: 'drop', table })} />} editor={editor} bottom={} status={<>{zhCN.connection}{zhCN.database}{activeMeta && {activeMeta.name}}{activeTableTab && activeMeta && {zhCN.rowCount(totals[activeTableTab.id] ?? 0)}}{zhCN.pendingCount(count)}} changeCount={count} canUndo={undoStack.length > 0} canRedo={redoStack.length > 0} onCommit={requestCommit} onRollback={rollback} onUndo={undo} onRedo={redo} onSql={() => newQuery()} onSettings={openSettings} />{schemaAction && setSchemaAction(undefined)} onChanged={pushMessage} />}; +} diff --git a/chusql-web/frontend/src/adapters/MockAdapter.test.ts b/chusql-web/frontend/src/adapters/MockAdapter.test.ts new file mode 100644 index 0000000..473227c --- /dev/null +++ b/chusql-web/frontend/src/adapters/MockAdapter.test.ts @@ -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(); + }); +}); diff --git a/chusql-web/frontend/src/adapters/MockAdapter.ts b/chusql-web/frontend/src/adapters/MockAdapter.ts new file mode 100644 index 0000000..40d9e8f --- /dev/null +++ b/chusql-web/frontend/src/adapters/MockAdapter.ts @@ -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> }; + +function makeData(): Map { + 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>): Promise { + 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 { + await this.delay(); + if (!this.tables.delete(table)) throw new Error('Table does not exist'); + } + async createIndex(table: string, column: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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[]; 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 { + 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' }; + } +} diff --git a/chusql-web/frontend/src/adapters/RestAdapter.test.ts b/chusql-web/frontend/src/adapters/RestAdapter.test.ts new file mode 100644 index 0000000..89e6b23 --- /dev/null +++ b/chusql-web/frontend/src/adapters/RestAdapter.test.ts @@ -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('', { status: 500 }))); + await expect(new RestAdapter().listTables('chusql')).rejects.toThrow('Request failed with status 500'); + }); +}); diff --git a/chusql-web/frontend/src/adapters/RestAdapter.ts b/chusql-web/frontend/src/adapters/RestAdapter.ts new file mode 100644 index 0000000..6e11307 --- /dev/null +++ b/chusql-web/frontend/src/adapters/RestAdapter.ts @@ -0,0 +1,153 @@ +import type { + ColumnMeta, CommitChange, DataSourceAdapter, FetchRowsParams, StatementResult, TableMeta, +} from '../types'; + +// REST 数据源:调用后端 JSON 接口并校验返回结构。 + +type JsonObject = Record; + +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 { + 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>): Promise { + await requestJson('/api/tables', jsonInit('POST', { name, columns })); + } + async dropTable(table: string): Promise { + await requestJson(`/api/tables/${encodeURIComponent(table)}`, jsonInit('DELETE')); + } + async createIndex(table: string, column: string): Promise { + await requestJson(`/api/tables/${encodeURIComponent(table)}/indexes`, jsonInit('POST', { column })); + } + async dropIndex(table: string, column: string): Promise { + await requestJson(`/api/tables/${encodeURIComponent(table)}/indexes/${encodeURIComponent(column)}`, jsonInit('DELETE')); + } + async listTables(_db: string): Promise { + 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 { + return asTable(await requestJson(`/api/tables/${encodeURIComponent(table)}`)).columns; + } + + async fetchRows(params: FetchRowsParams): Promise<{ rows: Record[]; 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 { + 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) }; + } + } +} diff --git a/chusql-web/frontend/src/components/common/AuthGate.test.tsx b/chusql-web/frontend/src/components/common/AuthGate.test.tsx new file mode 100644 index 0000000..592c396 --- /dev/null +++ b/chusql-web/frontend/src/components/common/AuthGate.test.tsx @@ -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(

protected content

); + + 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' })); + }); +}); diff --git a/chusql-web/frontend/src/components/common/AuthGate.tsx b/chusql-web/frontend/src/components/common/AuthGate.tsx new file mode 100644 index 0000000..60f7e2d --- /dev/null +++ b/chusql-web/frontend/src/components/common/AuthGate.tsx @@ -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('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

{zhCN.signingIn}

; + return
+
void submit(event)}> +

{zhCN.signInTitle}

+ + + {failed &&

{zhCN.signInFailed}

} + +
+
; +} diff --git a/chusql-web/frontend/src/components/common/IconButton.tsx b/chusql-web/frontend/src/components/common/IconButton.tsx new file mode 100644 index 0000000..50e9cb5 --- /dev/null +++ b/chusql-web/frontend/src/components/common/IconButton.tsx @@ -0,0 +1,19 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; +import styles from './common.module.css'; + +// 通用图标按钮:支持紧凑模式,标签兼作无障碍名。 + +interface IconButtonProps extends ButtonHTMLAttributes { + icon: ReactNode; + label: string; + compact?: boolean; +} + +export function IconButton({ icon, label, compact = false, ...props }: IconButtonProps) { + return ( + + ); +} diff --git a/chusql-web/frontend/src/components/common/Modal.tsx b/chusql-web/frontend/src/components/common/Modal.tsx new file mode 100644 index 0000000..18be62d --- /dev/null +++ b/chusql-web/frontend/src/components/common/Modal.tsx @@ -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(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 ( + { event.preventDefault(); onClose(); }}> +
+

{title}

+ } label={zhCN.close} compact onClick={onClose} /> +
+
{children}
+ {footer &&
{footer}
} +
+ ); +} diff --git a/chusql-web/frontend/src/components/common/auth.module.css b/chusql-web/frontend/src/components/common/auth.module.css new file mode 100644 index 0000000..9961b1f --- /dev/null +++ b/chusql-web/frontend/src/components/common/auth.module.css @@ -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; } diff --git a/chusql-web/frontend/src/components/common/common.module.css b/chusql-web/frontend/src/components/common/common.module.css new file mode 100644 index 0000000..3d7b4d5 --- /dev/null +++ b/chusql-web/frontend/src/components/common/common.module.css @@ -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); } diff --git a/chusql-web/frontend/src/components/grid/DataGrid.test.tsx b/chusql-web/frontend/src/components/grid/DataGrid.test.tsx new file mode 100644 index 0000000..df6ab7a --- /dev/null +++ b/chusql-web/frontend/src/components/grid/DataGrid.test.tsx @@ -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(); + + 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(); + }); +}); diff --git a/chusql-web/frontend/src/components/grid/DataGrid.tsx b/chusql-web/frontend/src/components/grid/DataGrid.tsx new file mode 100644 index 0000000..12b2e3b --- /dev/null +++ b/chusql-web/frontend/src/components/grid/DataGrid.tsx @@ -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 { _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; + widths: Record; + selectedRows: Set; + 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) => void; onBlur: () => void }) { + if (column.type === 'bool') return ; + return onChange(event.target.value)} onKeyDown={onKeyDown} onBlur={onBlur} />; +} + +export function DataGrid(props: DataGridProps) { + const [editing, setEditing] = useState(); + const [filterColumn, setFilterColumn] = useState(); + 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) => { + if (event.key === 'Escape') setEditing(undefined); + if (event.key === 'Enter' || event.key === 'Tab') { event.preventDefault(); commitEdit(); } + }; + const paste = (event: ClipboardEvent, 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, 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 ( + sum + (props.widths[column.name] ?? 140), 0) }}> + {props.columns.map((column) => )} + + {props.columns.map((column) => )} + + + {props.rows.map((row, rowIndex) => { + const key = rowKey(row, rowIndex); + const isDeleted = !row._tempId && deleted.has(String(row.id)); + return { 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 ; + })} + ; + })} + +
+
+
+ {filterColumn === column.name &&
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(); } }} />
} + { 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)} /> +
{ 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 ? setEditing({ ...editing, value })} onKeyDown={onEditorKeyDown} onBlur={commitEdit} /> : value === null || value === undefined ? {props.nullText} : String(value)} +
+ ); +} diff --git a/chusql-web/frontend/src/components/grid/RowDialog.test.tsx b/chusql-web/frontend/src/components/grid/RowDialog.test.tsx new file mode 100644 index 0000000..2975715 --- /dev/null +++ b/chusql-web/frontend/src/components/grid/RowDialog.test.tsx @@ -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(); + 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 }); +}); diff --git a/chusql-web/frontend/src/components/grid/RowDialog.tsx b/chusql-web/frontend/src/components/grid/RowDialog.tsx new file mode 100644 index 0000000..ace2878 --- /dev/null +++ b/chusql-web/frontend/src/components/grid/RowDialog.tsx @@ -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) => void }) { + const [draft, setDraft] = useState>(() => Object.fromEntries(table.columns.map((column) => [column.name, column.type === 'bool' ? 'false' : '']))); + const [error, setError] = useState(''); + const submit = () => { + const values: Record = {}; + 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 }> +

{zhCN.insertRowHint}

+
{table.columns.map((column) => )}
+ {error &&

{error}

} +
; +} diff --git a/chusql-web/frontend/src/components/grid/TableEditor.tsx b/chusql-web/frontend/src/components/grid/TableEditor.tsx new file mode 100644 index 0000000..0ca4345 --- /dev/null +++ b/chusql-web/frontend/src/components/grid/TableEditor.tsx @@ -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([]); + const [page, setPage] = useState(0); + const [total, setTotal] = useState(0); + const [sort, setSort] = useState<{ column: string; dir: 'asc' | 'desc' }>(); + const [filters, setFilters] = useState>({}); + const [widths, setWidths] = useState>(() => JSON.parse(localStorage.getItem(`chusql.widths.${table.name}`) ?? '{}')); + const [selectedRows, setSelectedRows] = useState(new Set()); + const [selectionAnchor, setSelectionAnchor] = useState(); + 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) => { + 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
+
+ } label={zhCN.refresh} onClick={() => void load(0, false)} /> + } label={zhCN.insertRow} onClick={() => setInsertOpen(true)} /> + } label={zhCN.deleteRows} disabled={!selectedRows.size} onClick={removeSelected} /> + } label={zhCN.batchReplace} onClick={() => selectedRows.size ? setReplaceOpen(true) : onMessage(zhCN.selectedRowsRequired)} /> + {zhCN.selectedRows(selectedRows.size)}{zhCN.rowCount(total)} +
+ {replaceOpen &&
} +
{ const target = event.currentTarget; if (!loading && rows.length < total && target.scrollTop + target.clientHeight >= target.scrollHeight - 32) void load(page + 1, true); }}> + {error ?
{zhCN.loadError}: {error}
: 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 &&
{zhCN.loading}
} + {!loading && !visibleRows.length && !error &&
{zhCN.noRows}
} +
+ {insertOpen && setInsertOpen(false)} onInsert={addRow} />} +
; +} diff --git a/chusql-web/frontend/src/components/grid/grid.module.css b/chusql-web/frontend/src/components/grid/grid.module.css new file mode 100644 index 0000000..98e6541 --- /dev/null +++ b/chusql-web/frontend/src/components/grid/grid.module.css @@ -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; } diff --git a/chusql-web/frontend/src/components/layout/BottomPanel.tsx b/chusql-web/frontend/src/components/layout/BottomPanel.tsx new file mode 100644 index 0000000..003a5a4 --- /dev/null +++ b/chusql-web/frontend/src/components/layout/BottomPanel.tsx @@ -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) => {token.text})}; +} + +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
+ +
+ {tab === 'changes' && (entries.length ?
    {entries.map((entry) =>
  • {entry.label}} label={zhCN.undoChange} onClick={() => onUndoOne(entry.kind, entry.key)} />
  • )}
:

{zhCN.noPendingChanges}

)} + {tab === 'results' && (result ? <>{result.type === 'rows' ?
{result.columns?.map((column) => )}{result.rows?.map((row, rowIndex) => {row.map((value, columnIndex) => )})}
{column}
{value === null ? zhCN.nullValue : String(value)}
:

{result.message ?? zhCN.executionAffected(result.affected ?? 0, result.durationMs)}

} :

{zhCN.noResults}

)} + {tab === 'messages' &&
    {messages.map((message, index) =>
  1. {message}
  2. )}
} +
+
; +} diff --git a/chusql-web/frontend/src/components/layout/Explorer.test.tsx b/chusql-web/frontend/src/components/layout/Explorer.test.tsx new file mode 100644 index 0000000..8eec040 --- /dev/null +++ b/chusql-web/frontend/src/components/layout/Explorer.test.tsx @@ -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(); + 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(); +}); diff --git a/chusql-web/frontend/src/components/layout/Explorer.tsx b/chusql-web/frontend/src/components/layout/Explorer.tsx new file mode 100644 index 0000000..0ed8866 --- /dev/null +++ b/chusql-web/frontend/src/components/layout/Explorer.tsx @@ -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()); + return ( + + ); +} diff --git a/chusql-web/frontend/src/components/layout/SchemaDialog.test.tsx b/chusql-web/frontend/src/components/layout/SchemaDialog.test.tsx new file mode 100644 index 0000000..d55037c --- /dev/null +++ b/chusql-web/frontend/src/components/layout/SchemaDialog.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + fireEvent.change(screen.getByLabelText('表名'), { target: { value: 'items' } }); + fireEvent.click(screen.getByRole('button', { name: '取消' })); + expect(onClose).toHaveBeenCalled(); + expect(useChangeStore.getState().changes.schemas.size).toBe(0); +}); diff --git a/chusql-web/frontend/src/components/layout/SchemaDialog.tsx b/chusql-web/frontend/src/components/layout/SchemaDialog.tsx new file mode 100644 index 0000000..5749b54 --- /dev/null +++ b/chusql-web/frontend/src/components/layout/SchemaDialog.tsx @@ -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>>([{ 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(); + 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 {action.kind === 'create' && }{action.kind === 'drop' && }}> + {action.kind === 'create' ?
+ +

{zhCN.createTableHint}

+ {columns.map((column, index) =>
setColumns(columns.map((item, i) => i === index ? { ...item, name: event.target.value } : item))} />
)} + +
: action.kind === 'drop' ? <> +

{zhCN.dropTableHint(target?.name ?? '', target?.rowCount ?? 0)}

+ : <> +

{zhCN.indexHint}

+
    {target?.columns.map((column) => { + const indexed = stagedIndex.get(column.name) ?? Boolean(column.indexed); + return
  • {column.name} {typeLabel(column.type)}{column.primaryKey || column.name === 'id' ? zhCN.builtinIndex : indexed ? zhCN.uniqueIndex : zhCN.noIndex}
  • ; + })}
+ } + {error &&

{error}

} +
; +} diff --git a/chusql-web/frontend/src/components/layout/Workbench.test.tsx b/chusql-web/frontend/src/components/layout/Workbench.test.tsx new file mode 100644 index 0000000..e1fa997 --- /dev/null +++ b/chusql-web/frontend/src/components/layout/Workbench.test.tsx @@ -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(} editor={
} bottom={
} status={
} 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'); +}); diff --git a/chusql-web/frontend/src/components/layout/Workbench.tsx b/chusql-web/frontend/src/components/layout/Workbench.tsx new file mode 100644 index 0000000..93eedb9 --- /dev/null +++ b/chusql-web/frontend/src/components/layout/Workbench.tsx @@ -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(readLayout); + const [drag, setDrag] = useState(); + 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) => { + event.preventDefault(); + setDrag({ kind, start: kind === 'sidebar' ? event.clientX : event.clientY, size: kind === 'sidebar' ? sidebar : dock }); + }; + + const onSplitterKeyDown = (kind: SplitterKind, event: KeyboardEvent) => { + 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 ( +
+
+ {zhCN.connection} + +
+
+ {props.explorer} +
{props.editor}
+
{props.bottom}
+
{props.status}
+
+ ); +} + +function GitMark() { + return ; +} diff --git a/chusql-web/frontend/src/components/layout/bottom.module.css b/chusql-web/frontend/src/components/layout/bottom.module.css new file mode 100644 index 0000000..32d6694 --- /dev/null +++ b/chusql-web/frontend/src/components/layout/bottom.module.css @@ -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; } diff --git a/chusql-web/frontend/src/components/layout/layout.module.css b/chusql-web/frontend/src/components/layout/layout.module.css new file mode 100644 index 0000000..4b1ecdc --- /dev/null +++ b/chusql-web/frontend/src/components/layout/layout.module.css @@ -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); } diff --git a/chusql-web/frontend/src/components/settings/FontChainEditor.tsx b/chusql-web/frontend/src/components/settings/FontChainEditor.tsx new file mode 100644 index 0000000..880242e --- /dev/null +++ b/chusql-web/frontend/src/components/settings/FontChainEditor.tsx @@ -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(); + 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
{label} +
{value.map((font, index) =>
move(event, index)}> setDragged(index)} onDragEnd={() => setDragged(undefined)} title={zhCN.moveFont}> onChange(value.map((item, itemIndex) => itemIndex === index ? event.target.value : item))} />} compact label={zhCN.removeFont(font)} disabled={value.length === 1} onClick={() => onChange(value.filter((_, itemIndex) => itemIndex !== index))} />
)}
+ } label={zhCN.addFallback} onClick={() => onChange([...value, ''])} /> + {children} +
{zhCN.preview}{zhCN.previewText}
+
; +} diff --git a/chusql-web/frontend/src/components/settings/SettingsPanel.test.tsx b/chusql-web/frontend/src/components/settings/SettingsPanel.test.tsx new file mode 100644 index 0000000..03e3dcd --- /dev/null +++ b/chusql-web/frontend/src/components/settings/SettingsPanel.test.tsx @@ -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(); + 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(); + 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); +}); diff --git a/chusql-web/frontend/src/components/settings/SettingsPanel.tsx b/chusql-web/frontend/src/components/settings/SettingsPanel.tsx new file mode 100644 index 0000000..a88a2b4 --- /dev/null +++ b/chusql-web/frontend/src/components/settings/SettingsPanel.tsx @@ -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 ; +} +function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (value: boolean) => void }) { + return ; +} + +export function SettingsPanel() { + const [section, setSection] = useState<'appearance' | 'behavior'>('appearance'); + const { settings, setNumber, setBoolean, setText, setFonts, reset } = useSettingsStore(); + return
+ +

{section === 'appearance' ? zhCN.settingsAppearance : zhCN.settingsBehavior}

+ {section === 'appearance' ? <> + setFonts('uiFonts', value)} previewStyle={{ fontFamily: fontFamilyValue(settings.uiFonts), fontSize: settings.uiFontSize }}> setNumber('uiFontSize', value)} /> + setFonts('gridFonts', value)} previewStyle={{ fontFamily: fontFamilyValue(settings.gridFonts), fontSize: settings.gridFontSize, lineHeight: `${settings.gridRowHeight}px` }}> setNumber('gridFontSize', value)} /> setNumber('gridRowHeight', value)} /> + setFonts('sqlFonts', value)} previewStyle={{ fontFamily: fontFamilyValue(settings.sqlFonts), fontSize: settings.sqlFontSize, lineHeight: `${settings.sqlLineHeight}px` }}> setNumber('sqlFontSize', value)} /> setNumber('sqlLineHeight', value)} /> + :
+ setNumber('pageSize', value)} /> + + setNumber('sqlTabSize', value)} /> + setBoolean('sqlLineNumbers', value)} /> + setBoolean('autocomplete', value)} /> + setBoolean('minimap', value)} /> +
} +
} label={zhCN.restoreDefaults} onClick={reset} />
+
+
; +} diff --git a/chusql-web/frontend/src/components/settings/settings.module.css b/chusql-web/frontend/src/components/settings/settings.module.css new file mode 100644 index 0000000..1eb0fa3 --- /dev/null +++ b/chusql-web/frontend/src/components/settings/settings.module.css @@ -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; } +} diff --git a/chusql-web/frontend/src/components/sql/SqlConsole.tsx b/chusql-web/frontend/src/components/sql/SqlConsole.tsx new file mode 100644 index 0000000..56c9426 --- /dev/null +++ b/chusql-web/frontend/src/components/sql/SqlConsole.tsx @@ -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(null); + const editor = useRef(); + const stopRequested = useRef(false); + const [sql, setSql] = useState(initialSql); + const [running, setRunning] = useState(false); + const [statuses, setStatuses] = useState>({}); + 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
+
} label={zhCN.runNext} disabled={running} onClick={() => { const next = pending(); void run(next ? [next] : []); }} />} label={zhCN.runAll} disabled={running} onClick={() => void run(statements)} />} label={zhCN.runSelection} disabled={running} onClick={() => void run(selection())} />} label={zhCN.stop} disabled={!running} onClick={() => { stopRequested.current = true; }} />
+
+
    {statements.map((statement) => { const status = statuses[statement.id] ?? { state: 'pending' }; return
  1. {status.state === 'pending' ? '○' : status.state === 'running' ? '▶' : status.state === 'success' ? '✓' : '✗'}{status.state === 'pending' ? zhCN.statementPending : status.state === 'running' ? zhCN.statementRunning : status.state === 'success' ? zhCN.statementSuccess : zhCN.statementFailed}{status.durationMs !== undefined && {status.durationMs.toFixed(1)} ms}
  2. ; })}
+
; +} diff --git a/chusql-web/frontend/src/components/sql/editorLayout.test.ts b/chusql-web/frontend/src/components/sql/editorLayout.test.ts new file mode 100644 index 0000000..23917f5 --- /dev/null +++ b/chusql-web/frontend/src/components/sql/editorLayout.test.ts @@ -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(); +}); diff --git a/chusql-web/frontend/src/components/sql/editorLayout.ts b/chusql-web/frontend/src/components/sql/editorLayout.ts new file mode 100644 index 0000000..2feaa44 --- /dev/null +++ b/chusql-web/frontend/src/components/sql/editorLayout.ts @@ -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(); +} diff --git a/chusql-web/frontend/src/components/sql/sql.module.css b/chusql-web/frontend/src/components/sql/sql.module.css new file mode 100644 index 0000000..6a654b5 --- /dev/null +++ b/chusql-web/frontend/src/components/sql/sql.module.css @@ -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; } diff --git a/chusql-web/frontend/src/core/changeSql.test.ts b/chusql-web/frontend/src/core/changeSql.test.ts new file mode 100644 index 0000000..8879a3a --- /dev/null +++ b/chusql-web/frontend/src/core/changeSql.test.ts @@ -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);'); + }); +}); diff --git a/chusql-web/frontend/src/core/changeSql.ts b/chusql-web/frontend/src/core/changeSql.ts new file mode 100644 index 0000000..7acae35 --- /dev/null +++ b/chusql-web/frontend/src/core/changeSql.ts @@ -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 { + 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); +} diff --git a/chusql-web/frontend/src/core/changes.test.ts b/chusql-web/frontend/src/core/changes.test.ts new file mode 100644 index 0000000..7c15f6a --- /dev/null +++ b/chusql-web/frontend/src/core/changes.test.ts @@ -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); + }); +}); diff --git a/chusql-web/frontend/src/core/changes.ts b/chusql-web/frontend/src/core/changes.ts new file mode 100644 index 0000000..36395a7 --- /dev/null +++ b/chusql-web/frontend/src/core/changes.ts @@ -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>(); + 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}`; +} diff --git a/chusql-web/frontend/src/core/filters.test.ts b/chusql-web/frontend/src/core/filters.test.ts new file mode 100644 index 0000000..70a8fae --- /dev/null +++ b/chusql-web/frontend/src/core/filters.test.ts @@ -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); + }); +}); diff --git a/chusql-web/frontend/src/core/filters.ts b/chusql-web/frontend/src/core/filters.ts new file mode 100644 index 0000000..f2749a4 --- /dev/null +++ b/chusql-web/frontend/src/core/filters.ts @@ -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); +} diff --git a/chusql-web/frontend/src/core/sql.test.ts b/chusql-web/frontend/src/core/sql.test.ts new file mode 100644 index 0000000..340f10c --- /dev/null +++ b/chusql-web/frontend/src/core/sql.test.ts @@ -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], + ]); + }); +}); diff --git a/chusql-web/frontend/src/core/sql.ts b/chusql-web/frontend/src/core/sql.ts new file mode 100644 index 0000000..58035c4 --- /dev/null +++ b/chusql-web/frontend/src/core/sql.ts @@ -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), + }]; + }); +} diff --git a/chusql-web/frontend/src/core/sqlHighlight.test.ts b/chusql-web/frontend/src/core/sqlHighlight.test.ts new file mode 100644 index 0000000..99f7548 --- /dev/null +++ b/chusql-web/frontend/src/core/sqlHighlight.test.ts @@ -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)'); + }); +}); diff --git a/chusql-web/frontend/src/core/sqlHighlight.ts b/chusql-web/frontend/src/core/sqlHighlight.ts new file mode 100644 index 0000000..a28e3e0 --- /dev/null +++ b/chusql-web/frontend/src/core/sqlHighlight.ts @@ -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; +} diff --git a/chusql-web/frontend/src/core/tabs.test.ts b/chusql-web/frontend/src/core/tabs.test.ts new file mode 100644 index 0000000..6c27abe --- /dev/null +++ b/chusql-web/frontend/src/core/tabs.test.ts @@ -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); + }); +}); diff --git a/chusql-web/frontend/src/core/tabs.ts b/chusql-web/frontend/src/core/tabs.ts new file mode 100644 index 0000000..32f4931 --- /dev/null +++ b/chusql-web/frontend/src/core/tabs.ts @@ -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 }; +} diff --git a/chusql-web/frontend/src/core/uiSettings.test.ts b/chusql-web/frontend/src/core/uiSettings.test.ts new file mode 100644 index 0000000..e9473d1 --- /dev/null +++ b/chusql-web/frontend/src/core/uiSettings.test.ts @@ -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); + }); +}); diff --git a/chusql-web/frontend/src/core/uiSettings.ts b/chusql-web/frontend/src/core/uiSettings.ts new file mode 100644 index 0000000..f5ce604 --- /dev/null +++ b/chusql-web/frontend/src/core/uiSettings.ts @@ -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 = { + 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 { + const parsed: Partial = {}; + if (!body || typeof body !== 'object' || Array.isArray(body)) return parsed; + const source = body as Record; + 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 | 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 { + 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 | 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(); + }; +} diff --git a/chusql-web/frontend/src/i18n/zh-CN.ts b/chusql-web/frontend/src/i18n/zh-CN.ts new file mode 100644 index 0000000..dac5a5a --- /dev/null +++ b/chusql-web/frontend/src/i18n/zh-CN.ts @@ -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 = { + int: zhCN.typeInt, + str: zhCN.typeStr, + bool: zhCN.typeBool, + date: zhCN.typeDate, +}; + +export type Messages = typeof zhCN; diff --git a/chusql-web/frontend/src/main.tsx b/chusql-web/frontend/src/main.tsx new file mode 100644 index 0000000..7db7b9e --- /dev/null +++ b/chusql-web/frontend/src/main.tsx @@ -0,0 +1,16 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App'; +import { MockAdapter } from './adapters/MockAdapter'; +import { RestAdapter } from './adapters/RestAdapter'; +import { AuthGate } from './components/common/AuthGate'; +import './styles/global.css'; + +// 前端入口:按环境选择 mock/rest 数据源并挂载根组件。 + +const adapterName = import.meta.env.VITE_CHUSQL_ADAPTER ?? (import.meta.env.DEV ? 'mock' : 'rest'); +const adapter = adapterName === 'rest' ? new RestAdapter() : new MockAdapter(); +const root = document.getElementById('root'); +if (!root) throw new Error('Root element is missing.'); +const application = ; +createRoot(root).render({adapterName === 'rest' ? {application} : application}); diff --git a/chusql-web/frontend/src/store/changeStore.test.ts b/chusql-web/frontend/src/store/changeStore.test.ts new file mode 100644 index 0000000..d0d7c4a --- /dev/null +++ b/chusql-web/frontend/src/store/changeStore.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useChangeStore } from './changeStore'; + +// 覆盖变更记录、结构变更暂存与部分提交确认。 + +describe('changeStore', () => { + beforeEach(() => useChangeStore.getState().clear()); + it('removes successful inserts after a partial commit so retries cannot duplicate them', () => { + const store = useChangeStore.getState(); + store.insertRow('users', 'temp-a', { id: 901 }); + store.insertRow('users', 'temp-b', { id: 902 }); + const submitted = useChangeStore.getState().changes; + store.acknowledge(submitted, 1); + expect([...useChangeStore.getState().changes.inserts.keys()]).toEqual(['temp-b']); + store.undo(); + expect([...useChangeStore.getState().changes.inserts.keys()]).toEqual(['temp-b']); + }); + + it('records edits without writing through and supports undo/redo', () => { + const store = useChangeStore.getState(); + store.updateCell('users', 1, 'name', 'A', 'B'); + expect(useChangeStore.getState().changes.updates.size).toBe(1); + useChangeStore.getState().undo(); + expect(useChangeStore.getState().changes.updates.size).toBe(0); + useChangeStore.getState().redo(); + expect(useChangeStore.getState().changes.updates.get('users:1:name')?.newValue).toBe('B'); + }); + + it('removes a new row instead of staging a delete for it', () => { + const store = useChangeStore.getState(); + store.insertRow('users', 'temp-1', { name: 'N' }); + useChangeStore.getState().deleteRow('users', 'temp-1', { name: 'N' }, true); + expect(useChangeStore.getState().changes.inserts.size).toBe(0); + expect(useChangeStore.getState().changes.deletes.size).toBe(0); + }); + + it('stages structural changes and can undo a single one', () => { + const store = useChangeStore.getState(); + store.stageSchema({ op: 'createIndex', table: 'users', column: 'age' }); + store.stageSchema({ op: 'dropTable', table: 'items' }); + expect([...useChangeStore.getState().changes.schemas.keys()]).toEqual(['createIndex:users:age', 'dropTable:items']); + useChangeStore.getState().undoOne('schemas', 'createIndex:users:age'); + expect([...useChangeStore.getState().changes.schemas.keys()]).toEqual(['dropTable:items']); + useChangeStore.getState().undo(); + expect([...useChangeStore.getState().changes.schemas.keys()]).toEqual(['createIndex:users:age', 'dropTable:items']); + }); + + it('keeps unapplied changes when the commit stops after a structural change', () => { + const store = useChangeStore.getState(); + store.stageSchema({ op: 'createTable', table: 'items', columns: [{ name: 'id', type: 'int' }] }); + store.insertRow('users', 'temp-a', { id: 901 }); + store.acknowledge(useChangeStore.getState().changes, 1); + expect(useChangeStore.getState().changes.schemas.size).toBe(0); + expect([...useChangeStore.getState().changes.inserts.keys()]).toEqual(['temp-a']); + }); + + it('drops a structural change once the whole queue is acknowledged', () => { + const store = useChangeStore.getState(); + store.insertRow('users', 'temp-a', { id: 901 }); + store.stageSchema({ op: 'dropTable', table: 'items' }); + store.acknowledge(useChangeStore.getState().changes, 2); + expect(useChangeStore.getState().changes.schemas.size).toBe(0); + expect(useChangeStore.getState().changes.inserts.size).toBe(0); + }); +}); diff --git a/chusql-web/frontend/src/store/changeStore.ts b/chusql-web/frontend/src/store/changeStore.ts new file mode 100644 index 0000000..1cd3349 --- /dev/null +++ b/chusql-web/frontend/src/store/changeStore.ts @@ -0,0 +1,108 @@ +import { create } from 'zustand'; +import { changeKey, cloneChangeSet, createEmptyChangeSet, pendingChanges, schemaKey } from '../core/changes'; +import type { ChangeSet, SchemaChange } from '../types'; + +// 待提交变更状态:增删改记录、撤销重做与提交确认。 + +interface ChangeState { + changes: ChangeSet; + undoStack: ChangeSet[]; + redoStack: ChangeSet[]; + updateCell: (table: string, pk: unknown, column: string, oldValue: unknown, newValue: unknown) => void; + insertRow: (table: string, tempId: string, values: Record) => void; + updateInsert: (tempId: string, values: Record) => void; + deleteRow: (table: string, pk: unknown, row: Record, isInserted?: boolean) => void; + stageSchema: (change: SchemaChange) => void; + undoOne: (kind: keyof ChangeSet, key: string) => void; + undo: () => void; + redo: () => void; + clear: () => void; + acknowledge: (submitted: ChangeSet, applied: number) => void; +} + +function withHistory(state: ChangeState, mutate: (next: ChangeSet) => void): Partial { + const next = cloneChangeSet(state.changes); + mutate(next); + return { + changes: next, + undoStack: [...state.undoStack, cloneChangeSet(state.changes)].slice(-100), + redoStack: [], + }; +} + +export const useChangeStore = create((set) => ({ + changes: createEmptyChangeSet(), + undoStack: [], + redoStack: [], + updateCell: (table, pk, column, oldValue, newValue) => set((state) => withHistory(state, (next) => { + const key = changeKey(table, pk, column); + if (Object.is(oldValue, newValue)) next.updates.delete(key); + else next.updates.set(key, { table, pk, column, oldValue, newValue }); + })), + insertRow: (table, tempId, values) => set((state) => withHistory(state, (next) => { + next.inserts.set(tempId, { table, tempId, values: { ...values } }); + })), + updateInsert: (tempId, values) => set((state) => withHistory(state, (next) => { + const insert = next.inserts.get(tempId); + if (insert) next.inserts.set(tempId, { ...insert, values: { ...values } }); + })), + deleteRow: (table, pk, row, isInserted = false) => set((state) => withHistory(state, (next) => { + if (isInserted) { + next.inserts.delete(String(pk)); + return; + } + for (const [key, update] of next.updates) { + if (update.table === table && Object.is(update.pk, pk)) next.updates.delete(key); + } + next.deletes.set(`${table}:${JSON.stringify(pk)}`, { table, pk, row: { ...row } }); + })), + stageSchema: (change) => set((state) => withHistory(state, (next) => { + next.schemas.set(schemaKey(change), change); + })), + undoOne: (kind, key) => set((state) => withHistory(state, (next) => { + next[kind].delete(key); + })), + undo: () => set((state) => { + const previous = state.undoStack.at(-1); + if (!previous) return state; + return { + changes: cloneChangeSet(previous), + undoStack: state.undoStack.slice(0, -1), + redoStack: [...state.redoStack, cloneChangeSet(state.changes)].slice(-100), + }; + }), + redo: () => set((state) => { + const next = state.redoStack.at(-1); + if (!next) return state; + return { + changes: cloneChangeSet(next), + undoStack: [...state.undoStack, cloneChangeSet(state.changes)].slice(-100), + redoStack: state.redoStack.slice(0, -1), + }; + }), + clear: () => set({ changes: createEmptyChangeSet(), undoStack: [], redoStack: [] }), + acknowledge: (submitted, applied) => set((state) => { + const next = cloneChangeSet(state.changes); + const completed = pendingChanges(submitted).slice(0, applied); + const inserted = Array.from(submitted.inserts.entries()); + let insertIndex = 0; + for (const operation of completed) { + if (operation.op === 'insert') { + const [key, value] = inserted[insertIndex++]; + if (next.inserts.get(key) === value) next.inserts.delete(key); + } else if (operation.op === 'delete') { + const key = `${operation.table}:${JSON.stringify(operation.pk)}`; + if (next.deletes.get(key) === submitted.deletes.get(key)) next.deletes.delete(key); + } else if (operation.op === 'update') { + for (const column of Object.keys(operation.set)) { + const key = changeKey(operation.table, operation.pk, column); + if (next.updates.get(key) === submitted.updates.get(key)) next.updates.delete(key); + } + } else { + const key = schemaKey(operation); + if (next.schemas.get(key) === submitted.schemas.get(key)) next.schemas.delete(key); + } + } + return { changes: next, undoStack: [], redoStack: [] }; + }), +})); diff --git a/chusql-web/frontend/src/store/settingsStore.test.ts b/chusql-web/frontend/src/store/settingsStore.test.ts new file mode 100644 index 0000000..f1f0130 --- /dev/null +++ b/chusql-web/frontend/src/store/settingsStore.test.ts @@ -0,0 +1,24 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { DEFAULT_SETTINGS, fontFamilyValue, useSettingsStore } from './settingsStore'; + +// 覆盖字体链转义与数值范围收敛。 + +describe('settingsStore', () => { + beforeEach(() => { + localStorage.clear(); + useSettingsStore.getState().reset(); + }); + + it('quotes a user-defined fallback chain safely', () => { + expect(fontFamilyValue(['JetBrains Mono', 'Consolas'])).toBe('"JetBrains Mono", "Consolas", monospace'); + expect(fontFamilyValue(['A"B', ' '])).toBe('"A\\"B", monospace'); + }); + + it('clamps numeric settings to the supported range', () => { + useSettingsStore.getState().setNumber('gridRowHeight', 200); + expect(useSettingsStore.getState().settings.gridRowHeight).toBe(40); + useSettingsStore.getState().setNumber('pageSize', 1); + expect(useSettingsStore.getState().settings.pageSize).toBe(10); + expect(DEFAULT_SETTINGS.uiFontSize).toBe(13); + }); +}); diff --git a/chusql-web/frontend/src/store/settingsStore.ts b/chusql-web/frontend/src/store/settingsStore.ts new file mode 100644 index 0000000..f98596b --- /dev/null +++ b/chusql-web/frontend/src/store/settingsStore.ts @@ -0,0 +1,78 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +// IDE 设置状态:默认值、范围收敛与本地持久化。 + +export interface IdeSettings { + uiFonts: string[]; + uiFontSize: number; + gridFonts: string[]; + gridFontSize: number; + gridRowHeight: number; + sqlFonts: string[]; + sqlFontSize: number; + sqlLineHeight: number; + sqlTabSize: number; + sqlLineNumbers: boolean; + pageSize: number; + nullText: string; + autocomplete: boolean; + minimap: boolean; +} + +export const DEFAULT_SETTINGS: IdeSettings = Object.freeze({ + uiFonts: ['JetBrains Mono', 'Consolas', 'Cascadia Code'], + uiFontSize: 13, + gridFonts: ['JetBrains Mono', 'Consolas'], + gridFontSize: 12, + gridRowHeight: 24, + sqlFonts: ['JetBrains Mono', 'Consolas'], + sqlFontSize: 13, + sqlLineHeight: 20, + sqlTabSize: 2, + sqlLineNumbers: true, + pageSize: 200, + nullText: 'NULL', + autocomplete: true, + minimap: false, +}); + +const ranges: Record = { + uiFontSize: [11, 16], + gridFontSize: [10, 18], + gridRowHeight: [18, 40], + sqlFontSize: [10, 20], + sqlLineHeight: [16, 40], + sqlTabSize: [1, 8], + pageSize: [10, 500], +}; + +export function fontFamilyValue(chain: string[]): string { + const fonts = chain.map((font) => font.trim()).filter(Boolean).map((font) => `"${font.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`); + return [...fonts, 'monospace'].join(', '); +} + +interface SettingsState { + settings: IdeSettings; + setNumber: (key: keyof IdeSettings, value: number) => void; + setBoolean: (key: keyof IdeSettings, value: boolean) => void; + setText: (key: keyof IdeSettings, value: string) => void; + setFonts: (key: 'uiFonts' | 'gridFonts' | 'sqlFonts', value: string[]) => void; + reset: () => void; +} + +export const useSettingsStore = create()(persist((set) => ({ + settings: { ...DEFAULT_SETTINGS }, + setNumber: (key, value) => set((state) => { + const range = ranges[key]; + const next = range ? Math.min(range[1], Math.max(range[0], Math.round(value))) : value; + return { settings: { ...state.settings, [key]: next } }; + }), + setBoolean: (key, value) => set((state) => ({ settings: { ...state.settings, [key]: value } })), + setText: (key, value) => set((state) => ({ settings: { ...state.settings, [key]: value } })), + setFonts: (key, value) => set((state) => ({ settings: { ...state.settings, [key]: value.slice(0, 10) } })), + reset: () => set({ settings: { ...DEFAULT_SETTINGS } }), +}), { + name: 'chusql.ide.settings.v1', + partialize: (state) => ({ settings: state.settings }), +})); diff --git a/chusql-web/frontend/src/styles/global.css b/chusql-web/frontend/src/styles/global.css new file mode 100644 index 0000000..42eb450 --- /dev/null +++ b/chusql-web/frontend/src/styles/global.css @@ -0,0 +1,48 @@ +/* 全局样式:主题变量、基础重置与滚动条。 */ +:root { + --bg-base: #1e1e1e; + --bg-elevated: #252526; + --bg-overlay: #2d2d30; + --bg-active: #37373d; + --bg-titlebar: #3c3c3c; + --bg-statusbar: #007acc; + --border: #3c3c3c; + --border-subtle: #2d2d2d; + --fg-primary: #cccccc; + --fg-muted: #858585; + --fg-disabled: #5a5a5a; + --accent: #007acc; + --accent-2: #0e639c; + --success: #4ec9b0; + --warning: #cca700; + --danger: #f14c4c; + --modified: #e2c08d; + --info: #9cdcfe; + --syn-keyword: #569cd6; + --syn-control: #c586c0; + --syn-string: #ce9178; + --syn-number: #b5cea8; + --syn-comment: #6a9955; + --syn-function: #dcdcaa; + --syn-type: #4ec9b0; + --syn-variable: #9cdcfe; + --ui-font: "JetBrains Mono", Consolas, "Cascadia Code", monospace; + --grid-font: "JetBrains Mono", Consolas, monospace; + --grid-font-size: 12px; + --grid-row-height: 24px; + color: var(--fg-primary); + font-family: var(--ui-font); + font-size: 13px; + font-synthesis: none; +} + +* { box-sizing: border-box; } +html, body, #root { height: 100%; margin: 0; overflow: hidden; } +body { background: var(--bg-base); } +button, input, select, textarea { font-family: inherit; } +button { border-radius: 2px; } +button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; } +::-webkit-scrollbar { height: 10px; width: 10px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--bg-active); } +@media (prefers-reduced-motion: no-preference) { button, input, select { transition: background-color 100ms ease-out, border-color 100ms ease-out; } } diff --git a/chusql-web/frontend/src/test/setup.ts b/chusql-web/frontend/src/test/setup.ts new file mode 100644 index 0000000..0af6ee6 --- /dev/null +++ b/chusql-web/frontend/src/test/setup.ts @@ -0,0 +1,7 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/react'; +import { afterEach } from 'vitest'; + +// 测试环境初始化:注册 jest-dom 断言与自动清理。 + +afterEach(cleanup); diff --git a/chusql-web/frontend/src/types.ts b/chusql-web/frontend/src/types.ts new file mode 100644 index 0000000..fcc57b2 --- /dev/null +++ b/chusql-web/frontend/src/types.ts @@ -0,0 +1,92 @@ +// 共享类型定义:数据源适配器接口、元数据与查询结果结构。 + +export type DataValue = string | number | boolean | null; + +export interface ColumnMeta { + name: string; + type: 'int' | 'str' | 'bool' | 'date'; + primaryKey?: boolean; + indexed?: boolean; + prime?: boolean; + distinct?: number; + statsCapped?: boolean; +} + +export interface TableMeta { + name: string; + rowCount: number; + columns: ColumnMeta[]; +} + +export interface FilterExpr { + column: string; + expression: string; +} + +export interface FetchRowsParams { + db: string; + table: string; + page: number; + pageSize: number; + sort: Array<{ column: string; dir: 'asc' | 'desc' }>; + filters: FilterExpr[]; + search?: string; +} + +export type SchemaChange = + | { op: 'createTable'; table: string; columns: Array> } + | { op: 'dropTable'; table: string } + | { op: 'createIndex'; table: string; column: string } + | { op: 'dropIndex'; table: string; column: string }; + +export type CommitChange = + | { op: 'update'; table: string; pk: unknown; set: Record } + | { op: 'insert'; table: string; values: Record } + | { op: 'delete'; table: string; pk: unknown }; + +export type PendingChange = CommitChange | SchemaChange; + +export interface StatementResult { + type: 'rows' | 'affected' | 'error'; + columns?: string[]; + rows?: unknown[][]; + affected?: number; + durationMs: number; + message?: string; +} + +export interface DataSourceAdapter { + createTable(name: string, columns: Array>): Promise; + dropTable(table: string): Promise; + createIndex(table: string, column: string): Promise; + dropIndex(table: string, column: string): Promise; + listTables(db: string): Promise; + getColumns(db: string, table: string): Promise; + fetchRows(params: FetchRowsParams): Promise<{ rows: Record[]; total: number }>; + commitChanges(payload: { + db: string; + changes: CommitChange[]; + }): Promise<{ ok: boolean; applied: number; errors?: Array<{ index: number; message: string }> }>; + execStatement(params: { db: string; sql: string }): Promise; +} + +export interface ChangeSet { + updates: Map; + inserts: Map; + }>; + deletes: Map; + }>; + schemas: Map; +} diff --git a/chusql-web/frontend/src/vite-env.d.ts b/chusql-web/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..f9ae3ea --- /dev/null +++ b/chusql-web/frontend/src/vite-env.d.ts @@ -0,0 +1,8 @@ +/// + +// Vite 环境类型声明:客户端环境变量与 CSS Modules 模块。 + +declare module '*.module.css' { + const classes: Record; + export default classes; +} diff --git a/chusql-web/frontend/tsconfig.app.json b/chusql-web/frontend/tsconfig.app.json new file mode 100644 index 0000000..613844c --- /dev/null +++ b/chusql-web/frontend/tsconfig.app.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "types": ["vitest/globals", "@testing-library/jest-dom"] + }, + "include": ["src"] +} diff --git a/chusql-web/frontend/tsconfig.json b/chusql-web/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/chusql-web/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/chusql-web/frontend/tsconfig.node.json b/chusql-web/frontend/tsconfig.node.json new file mode 100644 index 0000000..60e386a --- /dev/null +++ b/chusql-web/frontend/tsconfig.node.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "strict": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["vite.config.ts", "vitest.config.ts"] +} diff --git a/chusql-web/frontend/vite.config.ts b/chusql-web/frontend/vite.config.ts new file mode 100644 index 0000000..281e162 --- /dev/null +++ b/chusql-web/frontend/vite.config.ts @@ -0,0 +1,34 @@ +import { fileURLToPath, URL } from 'node:url'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +// Vite 配置:React 插件、Monaco nonce 注入与构建输出。 + +export default defineConfig({ + plugins: [react(), { + name: 'monaco-style-nonce', + enforce: 'pre', + transform(code, id) { + if (!id.split('\\').join('/').includes('/monaco-editor/esm/')) return; + const source = "document.createElement('style')"; + if (!code.includes(source)) return; + return code.split(source).join(`Object.assign(document.createElement('style'), { nonce: document.querySelector('meta[name="chusql-style-nonce"]')?.content || '' })`); + }, + }], + base: '/static/', + resolve: { + alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, + }, + build: { + outDir: '../static', + emptyOutDir: true, + assetsDir: '', + rollupOptions: { + output: { + entryFileNames: 'app.js', + chunkFileNames: '[name].js', + assetFileNames: (asset) => asset.names?.some((name) => name.endsWith('.css')) ? 'style.css' : '[name][extname]', + }, + }, + }, +}); diff --git a/chusql-web/frontend/vitest.config.ts b/chusql-web/frontend/vitest.config.ts new file mode 100644 index 0000000..950d66d --- /dev/null +++ b/chusql-web/frontend/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +// Vitest 配置:jsdom 环境与测试初始化脚本。 + +export default defineConfig({ + test: { + environment: 'jsdom', + setupFiles: './src/test/setup.ts', + restoreMocks: true, + }, +}); diff --git a/chusql-web/src/ChuSQL/Web/Actions.hs b/chusql-web/src/ChuSQL/Web/Actions.hs new file mode 100644 index 0000000..335dcd4 --- /dev/null +++ b/chusql-web/src/ChuSQL/Web/Actions.hs @@ -0,0 +1,237 @@ +{-# LANGUAGE OverloadedStrings #-} + +module ChuSQL.Web.Actions ( + ColumnSpec (..), + CreateTableSpec (..), + isIdentifier, + columnTypeOf, + columnTypeName, + coerceValue, + sqlLiteral, + escapeStringLiteral, + createTableSql, + dropTableSql, + createIndexSql, + dropIndexSql, + dropColumnSql, + insertRowSql, + insertRowsSql, + updateRowSql, + deleteRowSql, + selectRowsSql, +) where + +import ChuSQL.Model (Column (..), Value (..)) +import qualified Data.Aeson as A +import Data.Char (isAsciiLower, isAsciiUpper, isDigit) +import Data.List (intercalate) +import Data.Scientific (floatingOrInteger) +import Data.Text (Text) +import qualified Data.Text as T +import Text.Read (readMaybe) + +-- 把界面上的一键操作翻译成安全 SQL:标识符过白名单,值一律转义。 + +data ColumnSpec = ColumnSpec + { csName :: Text + , csType :: Text + } + deriving (Show, Eq) + +data CreateTableSpec = CreateTableSpec + { ctsTable :: Text + , ctsColumns :: [ColumnSpec] + } + deriving (Show, Eq) + +-- | 表名/列名的白名单 +isIdentifier :: Text -> Bool +isIdentifier t = + not (T.null t) + && T.length t <= 64 + && case T.unpack t of + (c : cs) -> (isAsciiLower c || isAsciiUpper c || c == '_') && all restChar cs + [] -> False + where + restChar c = isAsciiLower c || isAsciiUpper c || isDigit c || c == '_' + +-- | 检查一个标识符,不合法就报错(带上"是表名还是列名") +checkIdent :: Text -> Text -> Either String Text +checkIdent what name + | isIdentifier name = Right name + | otherwise = Left (T.unpack what ++ " name is not a plain identifier: " ++ T.unpack name) + +-- | 列表不能空 +requireNonEmpty :: String -> [a] -> Either String [a] +requireNonEmpty what xs + | null xs = Left (what ++ " must not be empty") + | otherwise = Right xs + +-- | 线上列类型名(和数据字典里的写法一致)转本地类型 +columnTypeOf :: Text -> Maybe Column +columnTypeOf t = case T.toLower (T.strip t) of + "int" -> Just TInt + "integer" -> Just TInt + "str" -> Just TStr + "text" -> Just TStr + "varchar" -> Just TStr + "bool" -> Just TBool + "boolean" -> Just TBool + _ -> Nothing + +-- | 本地类型转线上名字 +columnTypeName :: Column -> Text +columnTypeName TInt = "int" +columnTypeName TStr = "str" +columnTypeName TBool = "bool" + +-- | 字符串字面量转义:单引号翻倍(引擎的词法器就是这么折回去的) +escapeStringLiteral :: String -> String +escapeStringLiteral = concatMap (\c -> if c == '\'' then "''" else [c]) + +-- | 值转 SQL 字面量 +sqlLiteral :: Value -> String +sqlLiteral (VInt n) = show n +sqlLiteral (VStr s) = "'" ++ escapeStringLiteral s ++ "'" +sqlLiteral (VBool True) = "TRUE" +sqlLiteral (VBool False) = "FALSE" + +-- | JSON 值按列类型转成引擎的值;类型对不上就报错 +coerceValue :: Column -> A.Value -> Either String Value +coerceValue TInt v = case v of + A.Number n -> case floatingOrInteger n :: Either Double Integer of + Right i -> Right (VInt (fromIntegral i)) + Left _ -> Left "expected an integer" + A.String s -> case readMaybe (T.unpack (T.strip s)) :: Maybe Int of + Just i -> Right (VInt i) + Nothing -> Left ("expected an integer, got: " ++ T.unpack s) + _ -> Left "expected an integer" +coerceValue TStr v = case v of + A.String s -> Right (VStr (T.unpack s)) + _ -> Left "expected a string" +coerceValue TBool v = case v of + A.Bool b -> Right (VBool b) + A.String s -> case T.toLower (T.strip s) of + "true" -> Right (VBool True) + "false" -> Right (VBool False) + _ -> Left ("expected true/false, got: " ++ T.unpack s) + _ -> Left "expected true/false" + +-- | 构造 CREATE TABLE 语句 +createTableSql :: CreateTableSpec -> Either String String +createTableSql spec = do + tbl <- checkIdent "table" (ctsTable spec) + cols <- requireNonEmpty "columns" (ctsColumns spec) + defs <- mapM columnDef cols + pure ("CREATE TABLE " ++ T.unpack tbl ++ " (" ++ intercalate ", " defs ++ ")") + where + columnDef (ColumnSpec name ty) = do + col <- checkIdent "column" name + ctype <- maybe (Left ("unknown column type: " ++ T.unpack ty)) Right (columnTypeOf ty) + pure (T.unpack col ++ " " ++ T.unpack (columnTypeName ctype)) + +-- | 构造 DROP TABLE 语句 +dropTableSql :: Text -> Either String String +dropTableSql table = do + tbl <- checkIdent "table" table + pure ("DROP TABLE " ++ T.unpack tbl) + +-- | 构造 CREATE INDEX 语句 +createIndexSql :: Text -> Text -> Either String String +createIndexSql table column = do + tbl <- checkIdent "table" table + col <- checkIdent "column" column + pure ("CREATE INDEX ON " ++ T.unpack tbl ++ " (" ++ T.unpack col ++ ")") + +-- | 构造 DROP INDEX 语句 +dropIndexSql :: Text -> Text -> Either String String +dropIndexSql table column = do + tbl <- checkIdent "table" table + col <- checkIdent "column" column + pure ("DROP INDEX ON " ++ T.unpack tbl ++ " (" ++ T.unpack col ++ ")") + +-- | 删列:列定义、索引与数据一起没 +dropColumnSql :: Text -> Text -> Either String String +dropColumnSql table column = do + tbl <- checkIdent "table" table + col <- checkIdent "column" column + pure ("ALTER TABLE " ++ T.unpack tbl ++ " DROP COLUMN " ++ T.unpack col) + +-- | 构造单行 INSERT 语句 +insertRowSql :: Text -> [(String, Value)] -> Either String String +insertRowSql table row = do + tbl <- checkIdent "table" table + cols <- requireNonEmpty "values" row + names <- mapM (checkIdent "column" . T.pack . fst) cols + pure + ( "INSERT INTO " + ++ T.unpack tbl + ++ " (" + ++ intercalate ", " (map T.unpack names) + ++ ") VALUES (" + ++ intercalate ", " (map (sqlLiteral . snd) cols) + ++ ")" + ) + +-- | 一次插多行,N 行只落一次盘 +insertRowsSql :: Text -> [[(String, Value)]] -> Either String String +insertRowsSql table rows = do + tbl <- checkIdent "table" table + rs <- requireNonEmpty "rows" rows + case rs of + [] -> Left "rows must not be empty" + (first : _) -> do + names <- mapM (checkIdent "column" . T.pack . fst) first + let widths = map length rs + if any (/= length first) widths + then Left "all rows must have the same columns" + else + pure + ( "INSERT INTO " + ++ T.unpack tbl + ++ " (" + ++ intercalate ", " (map T.unpack names) + ++ ") VALUES " + ++ intercalate ", " ["(" ++ intercalate ", " (map (sqlLiteral . snd) r) ++ ")" | r <- rs] + ) + +-- | 构造按 id 改行的 UPDATE 语句 +updateRowSql :: Text -> Int -> [(String, Value)] -> Either String String +updateRowSql table key assigns = do + tbl <- checkIdent "table" table + cols <- requireNonEmpty "values" assigns + sets <- mapM (\(n, v) -> (\c -> T.unpack c ++ " = " ++ sqlLiteral v) <$> checkIdent "column" (T.pack n)) cols + pure ("UPDATE " ++ T.unpack tbl ++ " SET " ++ intercalate ", " sets ++ " WHERE id = " ++ show key) + +-- | 按 id 删一行,存储层原地删 +deleteRowSql :: Text -> Int -> Either String String +deleteRowSql table key = do + tbl <- checkIdent "table" table + pure ("DELETE FROM " ++ T.unpack tbl ++ " WHERE id = " ++ show key) + +-- | 浏览一张表的 SELECT,带排序与等值过滤 +selectRowsSql :: Text -> Maybe (Text, Bool) -> [(Text, Value)] -> Either String String +selectRowsSql table order filters = do + tbl <- checkIdent "table" table + whereSql <- whereClause filters + orderSql <- orderClause order + pure ("SELECT * FROM " ++ T.unpack tbl ++ whereSql ++ orderSql) + +-- | 拼 WHERE 子句,无条件给空串 +whereClause :: [(Text, Value)] -> Either String String +whereClause filters = case filters of + [] -> pure "" + _ -> do + terms <- mapM term filters + pure (" WHERE " ++ intercalate " AND " terms) + where + term (column, value) = do + col <- checkIdent "column" column + pure (T.unpack col ++ " = " ++ sqlLiteral value) + +-- | 拼 ORDER BY 子句,无排序给空串 +orderClause :: Maybe (Text, Bool) -> Either String String +orderClause Nothing = pure "" +orderClause (Just (column, ascending)) = do + col <- checkIdent "column" column + pure (" ORDER BY " ++ T.unpack col ++ (if ascending then " ASC" else " DESC")) diff --git a/chusql-web/src/ChuSQL/Web/Api.hs b/chusql-web/src/ChuSQL/Web/Api.hs new file mode 100644 index 0000000..de38174 --- /dev/null +++ b/chusql-web/src/ChuSQL/Web/Api.hs @@ -0,0 +1,1087 @@ +{-# LANGUAGE OverloadedStrings #-} + +module ChuSQL.Web.Api ( + AppEnv (..), + Live (..), + defaultLive, + setLive, + readLive, + applyLiveKeys, + webApp, + sessionCookieName, + parseCookieHeader, + isIdentifier, + renderRow, + tableInfoJson, + apiErrorJson, + newAppEnv, +) where + +import ChuSQL.Model (Column (..), Row, Value (..)) +import ChuSQL.Storage.IPC (SchemaColumn (..), TableInfo (..)) +import ChuSQL.Web.Actions ( + ColumnSpec (..), + CreateTableSpec (..), + coerceValue, + columnTypeOf, + createIndexSql, + createTableSql, + deleteRowSql, + dropColumnSql, + dropIndexSql, + dropTableSql, + insertRowSql, + isIdentifier, + selectRowsSql, + updateRowSql, + ) +import ChuSQL.Web.Auth ( + Credential (..), + SessionPolicy (..), + SessionStore, + createSession, + deleteOtherSessions, + deleteSession, + hashLooksValid, + hashPassword, + lookupSession, + setSessionPolicy, + sessionToken, + verifyPassword, + ) +import ChuSQL.Web.Backend (Backend, StatementResult (..), beCatalog, bePing, beStatement) +import ChuSQL.Web.Demo (SeedReport (..), seedDemo) +import ChuSQL.Web.RateLimit (RateLimiter, rateLimitBlock, rateLimitClear, rateLimitRecord, setRateLimit) +import ChuSQL.Web.Secure (bodyLimitDynamic, sameOriginOnly, securityHeaders, contentSecurityPolicy) +import ChuSQL.Web.Settings ( + SettingItem (..), + applySettings, + defaultOf, + liveKeys, + readSettingsFile, + resolveSettingsFile, + settingCatalogue, + settingsFileCandidates, + writeSettingsFile, + ) +import ChuSQL.Web.Static (contentTypeOf, readStatic, safeRelative) +import ChuSQL.Web.UiSettings ( + readUiSettings, + resolveUiSettingsFile, + uiSettingsFileCandidates, + validateUiSettings, + writeUiSettings, + ) +import Control.Exception (IOException, try) +import Data.Aeson (FromJSON (..), eitherDecode, object, withObject, (.:), (.=)) +import qualified Data.Aeson as A +import qualified Data.Aeson.Key as K +import qualified Data.Aeson.KeyMap as KM +import qualified Data.ByteString.Lazy as BL +import qualified Data.ByteString as BS +import Data.IORef (IORef, newIORef, readIORef, writeIORef) +import qualified Data.Map.Strict as Map +import Data.Maybe (catMaybes, fromMaybe) +import Data.Text (Text) +import qualified Data.Text as T +import qualified Data.Text.Encoding as TE +import qualified Data.Text.Lazy as TL +import qualified Data.Vector as V +import Network.HTTP.Types (status204, status400, status401, status403, status404, status413, status429, status500) +import Network.Wai (Application) +import System.Environment (lookupEnv) +import Text.Read (readMaybe) +import Web.Scotty + +-- REST API 与页面交付:鉴权、JSON 与引擎结果的互转、错误到状态码的映射。 + +data Live = Live + { lvBodyLimit :: Int + , lvSessionIdle :: Int + , lvSessionMax :: Int + , lvLoginMaxAttempts :: Int + , lvLoginWindow :: Int + , lvPageSize :: Int + , lvMaxPageSize :: Int + , lvMaxRows :: Int + , lvMaxSqlLength :: Int + } + deriving (Show, Eq) + +-- | 热改项的默认值,与配置默认一致 +defaultLive :: Live +defaultLive = + Live + { lvBodyLimit = 65536 + , lvSessionIdle = 8 * 3600 + , lvSessionMax = 24 * 3600 + , lvLoginMaxAttempts = 5 + , lvLoginWindow = 5 * 60 + , lvPageSize = 25 + , lvMaxPageSize = 500 + , lvMaxRows = 1000 + , lvMaxSqlLength = 20000 + } + +data AppEnv = AppEnv + { aeBackend :: Backend + , aeSessions :: SessionStore + , aeCredential :: IORef Credential + , aeLimiter :: RateLimiter + , aeStaticDir :: FilePath + , aeCookieSecure :: Bool + , aeLive :: IORef Live + , aeSettingsFile :: FilePath + , aeUiSettingsFile :: FilePath + , aeEffective :: Map.Map Text Text + , aeLog :: Text -> IO () + } + +-- | 会话 Cookie 名 +sessionCookieName :: Text +sessionCookieName = "chusql_session" + +-- | 参数齐全地造一份环境(各项上限用内置默认,调用方按配置改) +newAppEnv :: Backend -> SessionStore -> Credential -> RateLimiter -> FilePath -> IO AppEnv +newAppEnv backend sessions cred limiter staticDir = do + credRef <- newIORef cred + liveRef <- newIORef defaultLive + settingsFile <- resolveSettingsFileSafe + uiSettingsFile <- resolveUiSettingsFileSafe + pure + AppEnv + { aeBackend = backend + , aeSessions = sessions + , aeCredential = credRef + , aeLimiter = limiter + , aeStaticDir = staticDir + , aeCookieSecure = False + , aeLive = liveRef + , aeSettingsFile = settingsFile + , aeUiSettingsFile = uiSettingsFile + , aeEffective = Map.empty + , aeLog = const (pure ()) + } + +-- | 定位设置文件,找不到就定下首个候选路径 +resolveSettingsFileSafe :: IO FilePath +resolveSettingsFileSafe = do + found <- try resolveSettingsFile :: IO (Either IOException FilePath) + pure (either (const fallbackSettingsFile) id found) + where + -- | 候选清单里的第一个 + fallbackSettingsFile = case settingsFileCandidates of + (p : _) -> p + [] -> "chusql.settings.json" + +-- | 定位 IDE 设置文件,找不到就定下首个候选路径 +resolveUiSettingsFileSafe :: IO FilePath +resolveUiSettingsFileSafe = do + found <- try resolveUiSettingsFile :: IO (Either IOException FilePath) + pure (either (const fallbackUiSettingsFile) id found) + where + -- | 候选清单里的第一个 + fallbackUiSettingsFile = case uiSettingsFileCandidates of + (p : _) -> p + [] -> "chusql.ui.settings.json" + +-- | 换一组热改参数(起服务前按配置文件初始化用) +setLive :: AppEnv -> Live -> IO () +setLive env = writeIORef (aeLive env) + +-- | 现在生效的热改参数 +readLive :: AppEnv -> IO Live +readLive = readIORef . aeLive + +-- | WAI 中间件用的实时请求体上限 +liveBodyLimit :: AppEnv -> IO Int +liveBodyLimit env = lvBodyLimit <$> readLive env + +-- | 组装 WAI 应用:Scotty 路由外面套三层中间件 +webApp :: AppEnv -> IO Application +webApp env = do + inner <- scottyApp (routes env) + pure (securityHeaders (sameOriginOnly (bodyLimitDynamic (liveBodyLimit env) inner))) + +-- | 路由表:界面一键操作全走结构化接口 +routes :: AppEnv -> ScottyM () +routes env = do + get "/" (indexH env) + get "/static/:file" (staticH env) + get "/api/health" healthH + get "/api/status" (statusH env) + post "/api/login" (loginH env) + post "/api/logout" (logoutH env) + get "/api/session" (sessionH env) + get "/api/settings" (settingsH env) + put "/api/settings" (putSettingsH env) + get "/api/ui-settings" (uiSettingsH env) + put "/api/ui-settings" (putUiSettingsH env) + post "/api/account/password" (changePasswordH env) + get "/api/tables" (tablesH env) + post "/api/tables" (createTableH env) + delete "/api/tables/:t" (dropTableH env) + get "/api/tables/:t" (tableH env) + get "/api/tables/:t/rows" (rowsH env) + post "/api/tables/:t/rows" (insertRowH env) + patch "/api/tables/:t/rows/:id" (updateRowH env) + delete "/api/tables/:t/rows/:id" (deleteRowH env) + post "/api/tables/:t/indexes" (createIndexH env) + delete "/api/tables/:t/indexes/:col" (dropIndexH env) + delete "/api/tables/:t/columns/:col" (dropColumnH env) + post "/api/demo-data" (demoDataH env) + post "/api/query" (queryH env) + notFound (notFoundH env) + +-- | 首页:登录与管理界面同一个 HTML +indexH :: AppEnv -> ActionM () +indexH env = do + content <- liftIO (readStatic (aeStaticDir env) "index.html") + case content of + Nothing -> do + status status500 + json (apiErrorJson "missing_static" "index.html not found (check the --static directory)") + Just page -> do + nonce <- liftIO sessionToken + let (beforeHead, afterHead) = BS.breakSubstring "" page + nonceMeta = TE.encodeUtf8 (" nonce <> "\">") + pageHtml = beforeHead <> nonceMeta <> afterHead + setHeader "Content-Security-Policy" (TL.fromStrict (contentSecurityPolicy (Just nonce))) + setHeader "Content-Type" "text/html; charset=utf-8" + raw (BL.fromStrict pageHtml) +-- | 静态文件:白名单之外一律 404 +staticH :: AppEnv -> ActionM () +staticH env = do + rel <- pathParam "file" + if rel == "index.html" then indexH env else case safeRelative rel of + Nothing -> reply404 + Just _ -> do + content <- liftIO (readStatic (aeStaticDir env) (T.unpack rel)) + case content of + Nothing -> reply404 + Just page -> do + setHeader "Content-Type" (TL.fromStrict (contentTypeOf (T.unpack rel))) + raw (BL.fromStrict page) + +-- | 统一的 JSON 404 +reply404 :: ActionM () +reply404 = do + status status404 + json (apiErrorJson "not_found" "no such resource") + +-- | 兜底:任何没匹配上的路径 +notFoundH :: AppEnv -> ActionM () +notFoundH _ = reply404 + +-- | 只回答"进程在",不碰存储 +healthH :: ActionM () +healthH = json (object ["status" .= ("ok" :: Text)]) + +-- | 进程 + 存储:启动脚本靠这个判断存储接上了没 +statusH :: AppEnv -> ActionM () +statusH env = do + up <- liftIO (bePing (aeBackend env)) + json (object ["status" .= ("ok" :: Text), "storage" .= (if up then "up" else "down" :: Text)]) + +-- | 登录:对了发 Cookie,错了 401/429 +loginH :: AppEnv -> ActionM () +loginH env = do + rawBody <- body + limit <- liftIO (liveBodyLimit env) + if BL.length rawBody > fromIntegral limit + then reply413 + else case eitherDecode rawBody of + Left _ -> reply400 "bad_request" "request body is not valid JSON" + Right reqBody -> do + let user = T.strip (lrUser reqBody) + password = lrPassword reqBody + if T.null user || T.length user > 64 || T.null password || T.length password > 256 + then reply400 "bad_request" "user name or password is empty or too long" + else do + blocked <- liftIO (rateLimitBlock (aeLimiter env) user) + case blocked of + Just left -> do + setHeader "Retry-After" (TL.fromStrict (T.pack (show (ceiling left :: Int)))) + status status429 + json (apiErrorJson "too_many_attempts" "too many failed sign-in attempts, try again later") + Nothing -> do + cred <- liftIO (readCredential env) + live <- liftIO (readLive env) + let hashOk = verifyPassword (credEncoded cred) password + userOk = T.toLower user == T.toLower (credUser cred) + if userOk && hashOk + then do + liftIO (rateLimitClear (aeLimiter env) user) + token <- liftIO (createSession (aeSessions env) (credUser cred)) + setHeader "Set-Cookie" (TL.fromStrict (sessionCookie env token (Just (lvSessionMax live)))) + liftIO (aeLog env ("sign-in ok user=" <> credUser cred)) + status status204 + else do + liftIO (rateLimitRecord (aeLimiter env) user) + liftIO (aeLog env ("sign-in failed user=" <> user)) + status status401 + json (apiErrorJson "unauthorized" "invalid user name or password") + +-- | 退出:删服务端会话 + 让浏览器丢 Cookie +logoutH :: AppEnv -> ActionM () +logoutH env = do + token <- requestSessionToken + case token of + Nothing -> pure () + Just t -> liftIO (deleteSession (aeSessions env) t) + setHeader "Set-Cookie" (TL.fromStrict (sessionCookie env "" (Just 0))) + status status204 + +-- | 当前登录的是谁 +sessionH :: AppEnv -> ActionM () +sessionH env = do + user <- requireUser env + json (object ["user" .= user]) + +-- | 读设置:文件值、启动生效值与内置默认一起给出 +settingsH :: AppEnv -> ActionM () +settingsH env = do + user <- requireUser env + cred <- liftIO (readCredential env) + fileValues <- liftIO (readSettingsFile (aeSettingsFile env)) + envSet <- liftIO (mapM (isSetEnv . siEnv) settingCatalogue) + let owner = sameAccount user (credUser cred) + json + ( object + [ "file" .= aeSettingsFile env + , "account" .= credUser cred + , "owner" .= owner + , "items" .= zipWith (itemJson (aeEffective env) fileValues owner) settingCatalogue envSet + ] + ) + +-- | 一个配置项在界面上的样子 +itemJson :: Map.Map Text Text -> Map.Map Text Text -> Bool -> SettingItem -> Bool -> A.Value +itemJson effectiveValues fileValues owner spec envSet = + object + [ "key" .= siKey spec + , "label" .= siLabel spec + , "group" .= siGroup spec + , "kind" .= siKind spec + , "default" .= siDefault spec + , "value" .= shownValue + , "source" .= source + , "rootOnly" .= siRootOnly spec + , "editable" .= (owner || not (siRootOnly spec)) + , "restart" .= siRestart spec + , "live" .= (siKey spec `elem` liveKeys) + , "set" .= hasValue + ] + where + secret = siKind spec == "secret" + fromFile = Map.lookup (siKey spec) fileValues + fromProcess = Map.lookup (siKey spec) effectiveValues + effectiveValue = fromMaybe (defaultOf (siKey spec)) fromProcess + shownValue = if secret then "" else fromMaybe effectiveValue fromFile + hasValue = fromFile /= Nothing || envSet + source :: Text + source + | secret = if hasValue then "configured" else "not set" + | fromFile /= Nothing = "settings file" + | envSet = "environment" + | otherwise = "default" + +-- | 这个环境变量现在是设着的吗 +isSetEnv :: Text -> IO Bool +isSetEnv name = do + found <- lookupEnv (T.unpack name) + pure (maybe False (not . null) found) + +-- | 改设置:写配置文件并让可热改项立即生效 +putSettingsH :: AppEnv -> ActionM () +putSettingsH env = do + user <- requireUser env + withJsonObject env $ \o -> case settingsUpdates o of + Left err -> reply400 "bad_request" err + Right updates -> do + cred <- liftIO (readCredential env) + let forbidden = + [ k + | k <- Map.keys updates + , maybe False siRootOnly (findSetting k) + , not (sameAccount user (credUser cred)) + ] + if not (null forbidden) + then reply403 ("only the configured account (" <> credUser cred <> ") may change: " <> T.intercalate ", " forbidden) + else do + current <- liftIO (readSettingsFile (aeSettingsFile env)) + case applySettings current updates of + Left err -> reply400 "bad_request" (T.pack err) + Right merged -> do + written <- liftIO (writeSettingsFile (aeSettingsFile env) merged) + case written of + Left err -> reply500 (T.pack err) + Right () -> do + applied <- liftIO (applyLiveKeys env updates) + let needsRestart = [k | k <- Map.keys updates, not (k `elem` applied), not (T.null (Map.findWithDefault "" k updates))] + liftIO (aeLog env ("settings updated by " <> user <> ": " <> T.intercalate ", " (Map.keys updates))) + json + ( object + [ "ok" .= True + , "file" .= aeSettingsFile env + , "applied" .= applied + , "restartRequired" .= needsRestart + ] + ) + +-- | 读前端 IDE 设置(放在自己的文件里,与服务配置无关) +uiSettingsH :: AppEnv -> ActionM () +uiSettingsH env = do + _ <- requireUser env + value <- liftIO (readUiSettings (aeUiSettingsFile env)) + json value + +-- | 整份覆盖前端 IDE 设置,校验不过一律 400 +putUiSettingsH :: AppEnv -> ActionM () +putUiSettingsH env = do + _ <- requireUser env + rawBody <- body + limit <- liftIO (liveBodyLimit env) + if BL.length rawBody > fromIntegral limit + then reply413 + else case eitherDecode rawBody of + Left _ -> reply400 "bad_request" "request body is not valid JSON" + Right payload -> case validateUiSettings payload of + Left err -> reply400 "bad_request" (T.pack err) + Right valid -> do + written <- liftIO (writeUiSettings (aeUiSettingsFile env) valid) + case written of + Left err -> reply500 (T.pack err) + Right () -> status status204 + +-- | 改口令:验原口令,并踢掉其它会话 +changePasswordH :: AppEnv -> ActionM () +changePasswordH env = do + user <- requireUser env + token <- requestSessionToken + withJsonObject env $ \o -> do + cred <- liftIO (readCredential env) + case (textField "current" o, textField "next" o) of + (Left err, _) -> reply400 "bad_request" err + (_, Left err) -> reply400 "bad_request" err + (Right current, Right newPassword) + | not (sameAccount user (credUser cred)) -> reply403 "only the configured account can change its password" + | not (verifyPassword (credEncoded cred) current) -> reply400 "bad_password" "the current password is incorrect" + | T.length newPassword < minPasswordLength -> reply400 "bad_request" (T.pack ("the new password must be at least " ++ show minPasswordLength ++ " characters")) + | T.length newPassword > 256 -> reply400 "bad_request" "the new password is too long" + | newPassword == current -> reply400 "bad_request" "the new password must differ from the current one" + | otherwise -> do + encoded <- liftIO (hashPassword newPassword) + if not (hashLooksValid encoded) + then reply500 "could not hash the new password" + else do + currentFile <- liftIO (readSettingsFile (aeSettingsFile env)) + let merged = Map.insert "password-hash" encoded (Map.insert "password" "" currentFile) + written <- liftIO (writeSettingsFile (aeSettingsFile env) merged) + case written of + Left err -> reply500 (T.pack err) + Right () -> do + liftIO (writeCredential env cred{credEncoded = encoded}) + removed <- case token of + Nothing -> pure 0 + Just t -> liftIO (deleteOtherSessions (aeSessions env) t) + liftIO (aeLog env ("password changed by " <> user <> " (" <> T.pack (show removed) <> " other sessions signed out)")) + status status204 + +-- | 口令最短长度(太短的口令不值得存) +minPasswordLength :: Int +minPasswordLength = 8 + +-- | 两个账号名是不是同一个(大小写不敏感) +sameAccount :: Text -> Text -> Bool +sameAccount a b = T.toLower a == T.toLower b + +-- | 现在用的凭据 +readCredential :: AppEnv -> IO Credential +readCredential = readIORef . aeCredential + +-- | 换掉进程里的凭据(改口令) +writeCredential :: AppEnv -> Credential -> IO () +writeCredential env = writeIORef (aeCredential env) + +-- | 按 key 找目录项 +findSetting :: Text -> Maybe SettingItem +findSetting key = case [i | i <- settingCatalogue, siKey i == key] of + (i : _) -> Just i + [] -> Nothing + +-- | 把能热改的设置应用到当前进程 +applyLiveKeys :: AppEnv -> Map.Map Text Text -> IO [Text] +applyLiveKeys env updates = do + before <- readLive env + let wanted = [k | k <- liveKeys, Map.member k updates] + intOf key = case T.strip (Map.findWithDefault "" key updates) of + "" -> intFromText (effectiveValueOf (aeEffective env) key) + given -> intFromText given + after = + before + { lvBodyLimit = intOf "body-limit" + , lvSessionIdle = intOf "session-idle" + , lvSessionMax = intOf "session-max" + , lvLoginMaxAttempts = intOf "login-max-attempts" + , lvLoginWindow = intOf "login-window" + , lvPageSize = intOf "rows-per-page" + , lvMaxPageSize = intOf "max-page-size" + , lvMaxRows = intOf "max-rows" + , lvMaxSqlLength = intOf "max-sql-length" + } + writeIORef (aeLive env) after + setSessionPolicy + (aeSessions env) + (SessionPolicy (fromIntegral (lvSessionIdle after)) (fromIntegral (lvSessionMax after))) + setRateLimit (aeLimiter env) (lvLoginMaxAttempts after) (fromIntegral (lvLoginWindow after)) + pure wanted + +-- | 某项无配置时的实际生效值 +effectiveValueOf :: Map.Map Text Text -> Text -> Text +effectiveValueOf effectiveValues key = fromMaybe (defaultOf key) (Map.lookup key effectiveValues) + +-- | 字符串当整数看(坏值回落到 0,不让它把进程带崩) +intFromText :: Text -> Int +intFromText given = fromMaybe 0 (readMaybe (T.unpack (T.strip given))) + +-- | 表清单(列 / 行数 / 索引 / 列统计) +tablesH :: AppEnv -> ActionM () +tablesH env = do + _ <- requireUser env + result <- liftIO (beCatalog (aeBackend env)) + case result of + Left e -> engineError e + Right infos -> json (map tableInfoJson infos) + +-- | 单表结构 +tableH :: AppEnv -> ActionM () +tableH env = do + _ <- requireUser env + name <- pathParam "t" + if not (isIdentifier name) + then reply400 "bad_request" "invalid table name" + else do + result <- liftIO (beCatalog (aeBackend env)) + case result of + Left e -> engineError e + Right infos -> case findTable name infos of + Nothing -> reply404 + Just info -> json (tableInfoJson info) + +-- | 分页浏览一张表,支持排序与等值过滤 +rowsH :: AppEnv -> ActionM () +rowsH env = do + _ <- requireUser env + name <- pathParam "t" + if not (isIdentifier name) + then reply400 "bad_request" "invalid table name" + else do + catalogResult <- liftIO (beCatalog (aeBackend env)) + case catalogResult of + Left e -> engineError e + Right infos -> case findTable name infos of + Nothing -> reply404 + Just info -> do + live <- liftIO (readLive env) + limit <- intParam "limit" (lvPageSize live) 1 (lvMaxPageSize live) + offset <- intParam "offset" 0 0 1000000 + rawFilter <- (queryParamMaybe "filter" :: ActionM (Maybe Text)) + order <- sortParam info + case (order, parseFilters info rawFilter) of + (Left err, _) -> reply400 "bad_request" err + (_, Left err) -> reply400 "bad_request" err + (Right sortOrder, Right filters) -> case selectRowsSql name sortOrder filters of + Left err -> reply400 "bad_request" (T.pack err) + Right sql -> do + result <- liftIO (beStatement (aeBackend env) sql) + case result of + Left e -> engineError e + Right res -> do + let cols = map scName (tiColumns info) + page = take limit (drop offset (srRows res)) + (sortColumn, ascending) = splitSort sortOrder + json + ( object + [ "table" .= tiTable info + , "columns" .= cols + , "rows" .= map (renderRow cols) page + , "total" .= length (srRows res) + , "limit" .= limit + , "offset" .= offset + , "sort" .= maybe A.Null A.String sortColumn + , "dir" .= (if ascending then "asc" else "desc" :: Text) + , "filters" .= map fst filters + ] + ) + +data RowFilter = RowFilter Text Text + +instance FromJSON RowFilter where + parseJSON = withObject "RowFilter" $ \o -> RowFilter <$> o .: "column" <*> o .: "value" + +-- | 解析 filter 参数,非法一律 400 +parseFilters :: TableInfo -> Maybe Text -> Either Text [(Text, Value)] +parseFilters _ Nothing = Right [] +parseFilters info (Just rawFilter) + | T.null (T.strip rawFilter) = Right [] + | otherwise = case eitherDecode (BL.fromStrict (TE.encodeUtf8 rawFilter)) of + Left _ -> Left "filter must be a JSON array of {column, value}" + Right specs -> catMaybes <$> mapM toPair specs + where + toPair (RowFilter column rawValue) + | T.null rawValue = Right Nothing + | otherwise = do + schema <- maybe (Left ("unknown column: " <> column)) Right (lookupColumn column) + ty <- maybe (Left ("unsupported column type for: " <> column)) Right (columnTypeOf (T.pack (scType schema))) + value <- either (Left . T.pack) Right (coerceValue ty (A.String rawValue)) + pure (Just (column, value)) + lookupColumn column = case [c | c <- tiColumns info, T.pack (scName c) == column] of + (c : _) -> Just c + [] -> Nothing + +-- | 排序键拆开(没给排序时给 Nothing / asc) +splitSort :: Maybe (Text, Bool) -> (Maybe Text, Bool) +splitSort Nothing = (Nothing, True) +splitSort (Just (col, asc)) = (Just col, asc) + +-- | 读 sort/dir 参数并校验列存在 +sortParam :: TableInfo -> ActionM (Either Text (Maybe (Text, Bool))) +sortParam info = do + rawSort <- (queryParamMaybe "sort" :: ActionM (Maybe Text)) + rawDir <- (queryParamMaybe "dir" :: ActionM (Maybe Text)) + pure $ case rawSort of + Nothing -> Right Nothing + Just column + | not (isIdentifier column) -> Left "sort must be a plain column name" + | column `notElem` map (T.pack . scName) (tiColumns info) -> Left ("unknown column: " <> column) + | otherwise -> Right (Just (column, dirOf rawDir)) + where + -- | 只认 asc/desc,否则算 asc + dirOf given = case fmap (T.toLower . T.strip) given of + Just "desc" -> False + _ -> True + +-- | 建表:按列结构拼 CREATE TABLE +createTableH :: AppEnv -> ActionM () +createTableH env = do + _ <- requireUser env + withJsonObject env $ \o -> case decodeCreateTable o of + Left err -> reply400 "bad_request" err + Right spec -> case createTableSql spec of + Left err -> reply400 "bad_request" (T.pack err) + Right sql -> runWrite env sql + +-- | 删表 +dropTableH :: AppEnv -> ActionM () +dropTableH env = do + _ <- requireUser env + name <- pathParam "t" + case dropTableSql name of + Left err -> reply400 "bad_request" (T.pack err) + Right sql -> runWrite env sql + +-- | 插一行,所有列必须给全 +insertRowH :: AppEnv -> ActionM () +insertRowH env = do + _ <- requireUser env + name <- pathParam "t" + info <- requireTable env name + withJsonObject env $ \o -> case valuesField o >>= readRowValues True (tiColumns info) of + Left err -> reply400 "bad_request" err + Right row -> case insertRowSql name row of + Left err -> reply400 "bad_request" (T.pack err) + Right sql -> runWrite env sql + +-- | 改一行,只改给出来的列 +updateRowH :: AppEnv -> ActionM () +updateRowH env = do + _ <- requireUser env + name <- pathParam "t" + key <- rowIdParam + info <- requireTable env name + withJsonObject env $ \o -> case valuesField o >>= readRowValues False (tiColumns info) of + Left err -> reply400 "bad_request" err + Right assigns -> case updateRowSql name key assigns of + Left err -> reply400 "bad_request" (T.pack err) + Right sql -> runWrite env sql + +-- | 删一行 +deleteRowH :: AppEnv -> ActionM () +deleteRowH env = do + _ <- requireUser env + name <- pathParam "t" + key <- rowIdParam + _ <- requireTable env name + case deleteRowSql name key of + Left err -> reply400 "bad_request" (T.pack err) + Right sql -> runWrite env sql + +-- | 建索引,索引列必须整数且唯一 +createIndexH :: AppEnv -> ActionM () +createIndexH env = do + _ <- requireUser env + name <- pathParam "t" + info <- requireTable env name + withJsonObject env $ \o -> case textField "column" o of + Left err -> reply400 "bad_request" err + Right column -> case columnTypeOf (columnTypeText (tiColumns info) column) of + Nothing -> reply400 "bad_request" ("unknown column: " <> column) + Just ty + | ty /= TInt -> reply400 "bad_request" "only integer columns can be indexed" + | otherwise -> case createIndexSql name column of + Left err -> reply400 "bad_request" (T.pack err) + Right sql -> runWrite env sql + +-- | 删索引(内建索引不放行:存储层也会拒,这里先给一句清楚的话) +dropIndexH :: AppEnv -> ActionM () +dropIndexH env = do + _ <- requireUser env + name <- pathParam "t" + column <- pathParam "col" + _ <- requireTable env name + if column == T.pack builtInIndexColumn + then reply400 "bad_request" "the built-in id index cannot be dropped" + else case dropIndexSql name column of + Left err -> reply400 "bad_request" (T.pack err) + Right sql -> runWrite env sql + +-- | 删列,内建 id 列不放行 +dropColumnH :: AppEnv -> ActionM () +dropColumnH env = do + _ <- requireUser env + name <- pathParam "t" + column <- pathParam "col" + _ <- requireTable env name + if column == T.pack builtInIndexColumn + then reply400 "bad_request" "the built-in id column cannot be dropped" + else case dropColumnSql name column of + Left err -> reply400 "bad_request" (T.pack err) + Right sql -> runWrite env sql + +-- | 一键灌演示数据(缺什么补什么,可以反复点) +demoDataH :: AppEnv -> ActionM () +demoDataH env = do + _ <- requireUser env + result <- liftIO (seedDemo (aeBackend env)) + case result of + Left e -> reply500 (T.pack e) + Right report -> + json + ( object + [ "ok" .= True + , "created" .= srCreated report + , "skipped" .= srSkipped report + , "indexes" .= srIndexes report + ] + ) + +-- | 写操作统一回执 +runWrite :: AppEnv -> String -> ActionM () +runWrite env sql = do + result <- liftIO (beStatement (aeBackend env) sql) + case result of + Left e -> engineError e + Right _ -> json (object ["ok" .= True]) + +-- | 只收 JSON 对象,超限 413,非法 400 +withJsonObject :: AppEnv -> (A.Object -> ActionM ()) -> ActionM () +withJsonObject env k = do + rawBody <- body + limit <- liftIO (liveBodyLimit env) + if BL.length rawBody > fromIntegral limit + then reply413 + else case eitherDecode rawBody of + Left _ -> reply400 "bad_request" "request body is not valid JSON" + Right (A.Object o) -> k o + Right _ -> reply400 "bad_request" "request body must be a JSON object" + +-- | 表必须存在,否则 400/404 短路 +requireTable :: AppEnv -> Text -> ActionM TableInfo +requireTable env name + | not (isIdentifier name) = do + reply400 "bad_request" "invalid table name" + finish + | otherwise = do + result <- liftIO (beCatalog (aeBackend env)) + case result of + Left e -> engineError e + Right infos -> case findTable name infos of + Nothing -> do + reply404 + finish + Just info -> pure info + +-- | 路径里的行 id +rowIdParam :: ActionM Int +rowIdParam = do + rawId <- pathParam "id" + case readMaybe (T.unpack rawId) :: Maybe Int of + Just n -> pure n + Nothing -> do + reply400 "bad_request" "row id must be an integer" + finish + +-- | 取一个字符串字段 +textField :: Text -> A.Object -> Either Text Text +textField key o = case KM.lookup (K.fromText key) o of + Just (A.String s) -> Right s + _ -> Left ("missing or non-string field: " <> key) + +-- | 取 `values` 对象 +valuesField :: A.Object -> Either Text A.Object +valuesField o = case KM.lookup "values" o of + Just (A.Object v) -> Right v + _ -> Left "missing or non-object field: values" + +-- | 取设置请求里的 values,值当字符串 +settingsUpdates :: A.Object -> Either Text (Map.Map Text Text) +settingsUpdates o = do + values <- valuesField o + if KM.null values + then Left "no settings were given" + else Map.fromList <$> mapM convert (KM.toList values) + where + convert (k, v) = case v of + A.String s -> Right (K.toText k, s) + A.Number n -> Right (K.toText k, T.pack (show n)) + A.Bool b -> Right (K.toText k, if b then "1" else "0") + _ -> Left ("setting " <> K.toText k <> " must be a string, number or boolean") + +-- | 建表请求体 +decodeCreateTable :: A.Object -> Either Text CreateTableSpec +decodeCreateTable o = do + name <- textField "name" o + colsValue <- maybe (Left "missing field: columns") Right (KM.lookup "columns" o) + cols <- case colsValue of + A.Array xs -> mapM decodeColumn (V.toList xs) + _ -> Left "columns must be an array" + pure (CreateTableSpec name cols) + where + decodeColumn (A.Object c) = ColumnSpec <$> textField "name" c <*> textField "type" c + decodeColumn _ = Left "each column must be {name, type}" + +-- | 把请求里的列值按表结构转成引擎值 +readRowValues :: Bool -> [SchemaColumn] -> A.Object -> Either Text [(String, Value)] +readRowValues requireAll cols o = do + provided <- mapM convert (KM.toList o) + if requireAll + then do + let missing = [scName c | c <- cols, scName c `notElem` map fst provided] + if null missing + then pure provided + else Left ("missing columns: " <> T.intercalate ", " (map T.pack missing)) + else pure provided + where + convert (k, v) = do + let name = K.toString k + schema <- maybe (Left ("unknown column: " <> T.pack name)) Right (lookupSchema name) + ty <- maybe (Left ("unsupported column type for: " <> T.pack name)) Right (columnTypeOf (T.pack (scType schema))) + value <- either (Left . T.pack) Right (coerceValue ty v) + pure (name, value) + lookupSchema name = case [c | c <- cols, scName c == name] of + (c : _) -> Just c + [] -> Nothing + +-- | 某列在数据字典里的类型字符串(找不到就给空串) +columnTypeText :: [SchemaColumn] -> Text -> Text +columnTypeText cols name = case [c | c <- cols, scName c == T.unpack name] of + (c : _) -> T.pack (scType c) + [] -> "" + +-- | 执行任意一条语句 +queryH :: AppEnv -> ActionM () +queryH env = do + user <- requireUser env + rawBody <- body + live <- liftIO (readLive env) + if BL.length rawBody > fromIntegral (lvBodyLimit live) + then reply413 + else case eitherDecode rawBody of + Left _ -> reply400 "bad_request" "request body is not valid JSON" + Right reqBody -> do + let sql = T.strip (qrSql reqBody) + if T.null sql + then reply400 "bad_request" "sql must not be empty" + else + if T.length sql > lvMaxSqlLength live + then reply413 + else do + liftIO (aeLog env ("query user=" <> user <> " sql=" <> sql)) + result <- liftIO (beStatement (aeBackend env) (T.unpack sql)) + case result of + Left e -> engineError e + Right res -> do + let shown = take (lvMaxRows live) (srRows res) + json + ( object + [ "columns" .= srColumns res + , "rows" .= map (renderRow (srColumns res)) shown + , "rowCount" .= length (srRows res) + , "truncated" .= (length (srRows res) > lvMaxRows live) + ] + ) + +-- | 拿当前 Session 用户;没有就 401 +requireUser :: AppEnv -> ActionM Text +requireUser env = do + token <- requestSessionToken + found <- case token of + Nothing -> pure Nothing + Just t -> liftIO (lookupSession (aeSessions env) t) + case found of + Just user -> pure user + Nothing -> do + status status401 + json (apiErrorJson "unauthorized" "sign in first") + finish + +-- | 请求里的会话令牌 +requestSessionToken :: ActionM (Maybe Text) +requestSessionToken = do + rawHeader <- header "Cookie" + pure (fmap TL.toStrict rawHeader >>= \h -> parseCookieHeader sessionCookieName h) + +-- | 从 Cookie 头里取一个键(没有就给 Nothing) +parseCookieHeader :: Text -> Text -> Maybe Text +parseCookieHeader name headerValue = case [v | part <- T.splitOn ";" headerValue, Just v <- [valueOf name part]] of + (v : _) -> Just v + [] -> Nothing + where + -- | 这一对是不是 name=value,是且值非空就取出值 + valueOf key part = + let (k, rest) = T.breakOn "=" part + value = T.strip (T.drop 1 rest) + in if T.strip k == key && not (T.null value) then Just value else Nothing + +-- | 取一个整数查询参数;非法给 400,越界就夹到区间里 +intParam :: Text -> Int -> Int -> Int -> ActionM Int +intParam name def lo hi = do + rawParam <- (queryParamMaybe (TL.fromStrict name) :: ActionM (Maybe Text)) + case rawParam of + Nothing -> pure def + Just rawText -> case readMaybe (T.unpack (T.strip rawText)) :: Maybe Int of + Nothing -> do + status status400 + json (apiErrorJson "bad_request" (name <> " must be an integer")) + finish + Just n -> pure (max lo (min hi n)) + +-- | 会话 Cookie 的值(maxAge 给 0 就是删掉) +sessionCookie :: AppEnv -> Text -> Maybe Int -> Text +sessionCookie env token maxAge = + T.concat + [ sessionCookieName + , "=" + , token + , "; Path=/; HttpOnly; SameSite=Strict" + , maybe "" (\n -> "; Max-Age=" <> T.pack (show n)) maxAge + , if aeCookieSecure env then "; Secure" else "" + ] + +-- | 一行按给定列名渲染成 JSON 数组(缺列给 null) +renderRow :: [String] -> Row -> [A.Value] +renderRow cols row = [maybe A.Null valueJson (lookup c row) | c <- cols] + +-- | 一个值转 JSON +valueJson :: Value -> A.Value +valueJson (VInt n) = A.Number (fromIntegral n) +valueJson (VStr s) = A.String (T.pack s) +valueJson (VBool b) = A.Bool b + +-- | 一张表的线上信息:列、行数、索引与统计 +tableInfoJson :: TableInfo -> A.Value +tableInfoJson info = + object + [ "table" .= tiTable info + , "rowCount" .= tiRows info + , "indexes" .= [object ["column" .= c, "builtIn" .= (c == builtInIndexColumn)] | c <- tiIndexes info] + , "columns" .= map columnJson (tiColumns info) + ] + where + -- | 一列的完整描述:角色与统计 + columnJson sc = + object + [ "name" .= scName sc + , "type" .= scType sc + , "primaryKey" .= isPrimaryKey + , "indexed" .= isIndexed + , "prime" .= (isPrimaryKey || isIndexed) + , "distinct" .= distinctCount + , "statsCapped" .= statsCapped + ] + where + name = scName sc + isPrimaryKey = name == builtInIndexColumn && scType sc == "int" + isIndexed = name `elem` tiIndexes info + stat = [s | s@(n, _, _) <- tiStats info, n == name] + distinctCount = case stat of + ((_, d, _) : _) -> d + [] -> -1 + statsCapped = case stat of + ((_, _, capped) : _) -> capped + [] -> False + +-- | 存储层的内建索引列名 id,界面与服务端都按这条规则认 +builtInIndexColumn :: String +builtInIndexColumn = "id" + +-- | 统一错误体 +apiErrorJson :: Text -> Text -> A.Value +apiErrorJson code message = object ["error" .= code, "message" .= message] + +-- | 400 +reply400 :: Text -> Text -> ActionM () +reply400 code message = do + status status400 + json (apiErrorJson code message) + +-- | 403(登录了但这件事不归你管) +reply403 :: Text -> ActionM () +reply403 message = do + status status403 + json (apiErrorJson "forbidden" message) + +-- | 413 +reply413 :: ActionM () +reply413 = do + status status413 + json (apiErrorJson "too_large" "request body too large") + +-- | 500(部署/后端问题专用,用户输入错误一律 400) +reply500 :: Text -> ActionM () +reply500 message = do + status status500 + json (apiErrorJson "internal" message) + +-- | 引擎报的错映射成状态码:没有这张表是 404,其余是 400 +engineError :: String -> ActionM a +engineError err = do + let message = T.pack err + missing = "unknown table" `T.isInfixOf` message + status (if missing then status404 else status400) + json (apiErrorJson (if missing then "not_found" else "query_error") message) + finish + +-- | 在字典里按名字找表 +findTable :: Text -> [TableInfo] -> Maybe TableInfo +findTable name = go + where + go [] = Nothing + go (i : is) = if tiTable i == T.unpack name then Just i else go is + +data LoginRequest = LoginRequest + { lrUser :: Text + , lrPassword :: Text + } + +instance FromJSON LoginRequest where + parseJSON = withObject "LoginRequest" $ \o -> + LoginRequest <$> o .: "user" <*> o .: "password" + +data QueryRequest = QueryRequest + { qrSql :: Text + } + +instance FromJSON QueryRequest where + parseJSON = withObject "QueryRequest" $ \o -> QueryRequest <$> o .: "sql" diff --git a/chusql-web/src/ChuSQL/Web/Auth.hs b/chusql-web/src/ChuSQL/Web/Auth.hs new file mode 100644 index 0000000..8ba6d67 --- /dev/null +++ b/chusql-web/src/ChuSQL/Web/Auth.hs @@ -0,0 +1,198 @@ +{-# LANGUAGE OverloadedStrings #-} + +module ChuSQL.Web.Auth ( + Credential (..), + defaultIterations, + hashPassword, + hashPasswordWith, + hashLooksValid, + verifyPassword, + SessionPolicy (..), + defaultSessionPolicy, + SessionStore, + newSessionStore, + sessionPolicy, + setSessionPolicy, + createSession, + lookupSession, + deleteSession, + deleteOtherSessions, + sessionToken, +) where + +import Control.Concurrent.MVar (MVar, modifyMVar, modifyMVar_, newMVar) +import Crypto.Hash (SHA256 (..)) +import qualified Crypto.KDF.PBKDF2 as PBKDF2 +import Crypto.Random (getRandomBytes) +import qualified Data.ByteString as BS +import Data.ByteArray (constEq) +import Data.ByteArray.Encoding (Base (Base16), convertFromBase, convertToBase) +import Data.IORef (IORef, newIORef, readIORef, writeIORef) +import qualified Data.Map.Strict as Map +import Data.Maybe (isJust) +import Data.Text (Text) +import qualified Data.Text as T +import qualified Data.Text.Encoding as TE +import Data.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime) +import Text.Read (readMaybe) + +-- 口令与 Session:口令只存 PBKDF2 编码串,会话是服务端内存状态。 + +data Credential = Credential + { credUser :: Text + , credEncoded :: Text + } + deriving (Show, Eq) + +-- | 默认迭代次数(10 万次) +defaultIterations :: Int +defaultIterations = 100000 + +-- | 派生密钥长度(字节) +keyLength :: Int +keyLength = 32 + +-- | 盐长度(字节) +saltLength :: Int +saltLength = 16 + +-- | 迭代次数的上界:配置串被人改坏时不至于把自己算死 +maxIterations :: Int +maxIterations = 10000000 + +-- | PBKDF2-HMAC-SHA256 派生 +deriveKey :: Int -> BS.ByteString -> BS.ByteString -> BS.ByteString +deriveKey iters salt password = + PBKDF2.generate (PBKDF2.prfHMAC SHA256) (PBKDF2.Parameters iters keyLength) password salt + +-- | 字节转十六进制 +hexEncode :: BS.ByteString -> Text +hexEncode = TE.decodeUtf8 . convertToBase Base16 + +-- | 十六进制转字节(长度不对/有非法字符就给 Nothing) +hexDecode :: Text -> Maybe BS.ByteString +hexDecode t = case convertFromBase Base16 (TE.encodeUtf8 t) of + Left _ -> Nothing + Right bs -> Just bs + +-- | 编码成 `pbkdf2-sha256$次数$盐$哈希` +encodeHash :: Int -> BS.ByteString -> BS.ByteString -> Text +encodeHash iters salt digest = + T.intercalate "$" ["pbkdf2-sha256", T.pack (show iters), hexEncode salt, hexEncode digest] + +-- | 解回 (次数, 盐, 哈希),坏格式给 Nothing +parseHash :: Text -> Maybe (Int, BS.ByteString, BS.ByteString) +parseHash t = case T.splitOn "$" t of + [alg, itersT, saltT, digestT] + | alg == "pbkdf2-sha256" -> do + iters <- readMaybe (T.unpack itersT) :: Maybe Int + salt <- hexDecode saltT + digest <- hexDecode digestT + if iters < 1000 || iters > maxIterations || BS.null salt || BS.length digest /= keyLength + then Nothing + else Just (iters, salt, digest) + _ -> Nothing + +-- | 用给定盐生成编码串(给定盐是为了能写确定性测试) +hashPasswordWith :: Int -> BS.ByteString -> Text -> Text +hashPasswordWith iters salt password = + encodeHash iters salt (deriveKey iters salt (TE.encodeUtf8 password)) + +-- | 给一个口令配随机盐,生成编码串 +hashPassword :: Text -> IO Text +hashPassword password = do + salt <- getRandomBytes saltLength + pure (hashPasswordWith defaultIterations salt password) + +-- | 恒定时间校验口令是否匹配 +verifyPassword :: Text -> Text -> Bool +verifyPassword encoded candidate = case parseHash encoded of + Nothing -> False + Just (iters, salt, digest) -> constEq digest (deriveKey iters salt (TE.encodeUtf8 candidate)) + +-- | 编码串本身是否合法(不看口令) +hashLooksValid :: Text -> Bool +hashLooksValid = isJust . parseHash + +data Session = Session + { sessUser :: Text + , sessCreated :: UTCTime + , sessSeen :: UTCTime + } + +data SessionPolicy = SessionPolicy + { spIdleSeconds :: NominalDiffTime + , spAbsoluteSeconds :: NominalDiffTime + } + +-- | 默认:空闲 8 小时、绝对 24 小时 +defaultSessionPolicy :: SessionPolicy +defaultSessionPolicy = SessionPolicy (8 * 3600) (24 * 3600) + +data SessionStore = SessionStore + { storeMap :: MVar (Map.Map Text Session) + , storeClock :: IO UTCTime + , storePolicy :: IORef SessionPolicy + } + +-- | 造一个空仓库 +newSessionStore :: IO UTCTime -> SessionPolicy -> IO SessionStore +newSessionStore clock policy = do + ref <- newMVar Map.empty + policyRef <- newIORef policy + pure (SessionStore ref clock policyRef) + +-- | 现在用的是哪套过期策略 +sessionPolicy :: SessionStore -> IO SessionPolicy +sessionPolicy = readIORef . storePolicy + +-- | 换一套过期策略(设置页热改) +setSessionPolicy :: SessionStore -> SessionPolicy -> IO () +setSessionPolicy store = writeIORef (storePolicy store) + +-- | 这条会话过期了吗 +expired :: UTCTime -> SessionPolicy -> Session -> Bool +expired now pol s = + diffUTCTime now (sessSeen s) > spIdleSeconds pol + || diffUTCTime now (sessCreated s) > spAbsoluteSeconds pol + +-- | 清掉过期会话 +prune :: UTCTime -> SessionPolicy -> Map.Map Text Session -> Map.Map Text Session +prune now pol = Map.filter (not . expired now pol) + +-- | 随机令牌:32 字节,十六进制 64 个字符 +sessionToken :: IO Text +sessionToken = hexEncode <$> getRandomBytes 32 + +-- | 开一条会话,返回令牌 +createSession :: SessionStore -> Text -> IO Text +createSession store user = do + token <- sessionToken + now <- storeClock store + pol <- sessionPolicy store + modifyMVar_ (storeMap store) $ \m -> + pure (Map.insert token (Session user now now) (prune now pol m)) + pure token + +-- | 查令牌并刷新最后活动时间 +lookupSession :: SessionStore -> Text -> IO (Maybe Text) +lookupSession store token = do + now <- storeClock store + pol <- sessionPolicy store + modifyMVar (storeMap store) $ \m -> do + let kept = prune now pol m + pure $ case Map.lookup token kept of + Nothing -> (kept, Nothing) + Just s -> (Map.insert token s{sessSeen = now} kept, Just (sessUser s)) + +-- | 删掉一条会话(退出登录) +deleteSession :: SessionStore -> Text -> IO () +deleteSession store token = modifyMVar_ (storeMap store) (pure . Map.delete token) + +-- | 删掉除这条外的全部会话,返回条数 +deleteOtherSessions :: SessionStore -> Text -> IO Int +deleteOtherSessions store keep = do + removed <- modifyMVar (storeMap store) $ \m -> do + let kept = Map.filterWithKey (\k _ -> k == keep) m + pure (kept, Map.size m - Map.size kept) + pure removed diff --git a/chusql-web/src/ChuSQL/Web/Backend.hs b/chusql-web/src/ChuSQL/Web/Backend.hs new file mode 100644 index 0000000..cf1ec57 --- /dev/null +++ b/chusql-web/src/ChuSQL/Web/Backend.hs @@ -0,0 +1,125 @@ +{-# LANGUAGE OverloadedStrings #-} + +module ChuSQL.Web.Backend ( + StatementResult (..), + Backend (..), + columnsFromStatement, + ipcBackend, + memoryBackend, + tableInfoOf, +) where + +import ChuSQL.Engine (runStatement, runStatementM) +import ChuSQL.Model +import ChuSQL.Storage (MonadStorage (schema)) +import ChuSQL.Storage.IPC ( + IPCStorage (..), + Request (..), + Response (..), + SchemaColumn (..), + TableInfo (..), + doListCatalog, + sendRequest, + ) +import ChuSQL.Syntax.AST +import ChuSQL.Syntax.Parser (parseStatement) +import Control.Concurrent.MVar (MVar, modifyMVar, newMVar, readMVar, withMVar) +import Data.List (nub) + +-- 后端:跑一条语句与取数据字典,分 IPC(真存储)和内存(测试)两套。 + +data StatementResult = StatementResult + { srColumns :: [String] + , srRows :: [Row] + } + +data Backend = Backend + { beStatement :: String -> IO (Either String StatementResult) + , beCatalog :: IO (Either String [TableInfo]) + , bePing :: IO Bool + } + +-- | 按给定列名取列,固定列顺序 +columnsFromStatement :: Database -> Statement -> [String] +columnsFromStatement db stmt = case stmt of + Select cols from _ _ _ + | cols == [allColumns] -> wildcard from + | otherwise -> cols + _ -> [] + where + -- | 展开 *:单表按列序,JOIN 左表在前 + wildcard (FromTable mAlias tbl) = case lookup tbl db of + Nothing -> [] + Just t -> [qualify mAlias c | c <- colNames t] + wildcard (FromJoin left mAlias tbl _) = wildcard left ++ wildcard (FromTable mAlias tbl) + +-- | 走命名管道的后端(真存储) +ipcBackend :: IO Backend +ipcBackend = do + lock <- newMVar () + pure + Backend + { beStatement = \sql -> withMVar lock $ \_ -> runIpc sql + , beCatalog = withMVar lock $ \_ -> do + result <- doListCatalog + pure (either Left Right result) + , bePing = withMVar lock $ \_ -> do + resp <- sendRequest ReqPing + pure (case resp of RespPong -> True; _ -> False) + } + where + -- | 解析并执行,空结果时补一次表头 + runIpc :: String -> IO (Either String StatementResult) + runIpc sql = case parseStatement sql of + Left e -> pure (Left e) + Right stmt -> do + result <- runIPCStorage (runStatementM stmt) + case result of + Left e -> pure (Left e) + Right rows -> do + cols <- case rows of + [] -> do + db <- runIPCStorage (schema :: IPCStorage Database) + pure (columnsFromStatement db stmt) + (r : _) -> pure (map fst r) + pure (Right (StatementResult cols rows)) + +-- | 内存后端(测试):状态就是一个 `Database` +memoryBackend :: MVar Database -> Backend +memoryBackend ref = + Backend + { beStatement = \sql -> modifyMVar ref $ \db -> case parseStatement sql of + Left e -> pure (db, Left e) + Right stmt -> case runStatement db stmt of + Left e -> pure (db, Left e) + Right (db', rows) -> pure (db', Right (StatementResult (colsOf db stmt rows) rows)) + , beCatalog = do + db <- readMVar ref + pure (Right (map tableInfoOf db)) + , bePing = pure True + } + where + -- | 有行用行上的键,空结果回退语法树 + colsOf db stmt rows = case rows of + [] -> columnsFromStatement db stmt + (r : _) -> map fst r + +-- | 一张表的线上信息(内存实现:没有索引,统计现算) +tableInfoOf :: (String, Table) -> TableInfo +tableInfoOf (name, tbl) = + TableInfo + { tiTable = name + , tiColumns = [SchemaColumn c (wireType ty) | (c, ty) <- tableCols tbl] + , tiRows = length (tableRows tbl) + , tiIndexes = [] + , tiStats = [(c, distinctOf c, False) | (c, _) <- tableCols tbl] + } + where + -- | 某一列有几个不同值 + distinctOf c = length (nub [v | r <- tableRows tbl, Just v <- [lookup c r]]) + +-- | 本地列类型转线上字符串(和 IPC 那边同一套写法) +wireType :: Column -> String +wireType TInt = "int" +wireType TStr = "str" +wireType TBool = "bool" diff --git a/chusql-web/src/ChuSQL/Web/Config.hs b/chusql-web/src/ChuSQL/Web/Config.hs new file mode 100644 index 0000000..f9aeb1a --- /dev/null +++ b/chusql-web/src/ChuSQL/Web/Config.hs @@ -0,0 +1,187 @@ +{-# LANGUAGE OverloadedStrings #-} + +module ChuSQL.Web.Config ( + WebConfig (..), + defaultWebConfig, + defaultUser, + defaultPassword, + usingDefaultCredentials, + loadWebConfig, + resolveCredential, + resolveStaticDir, + staticDirCandidates, +) where + +import ChuSQL.Web.Auth (Credential (..), hashPassword) +import Data.Char (toLower) +import Data.Maybe (fromMaybe) +import Data.Text (Text) +import qualified Data.Text as T +import System.Directory (doesDirectoryExist) +import System.Environment (lookupEnv) +import Text.Read (readMaybe) + +-- 运行参数:命令行 > 环境变量 > 内置默认;默认只听本机 127.0.0.1:7777。 + +-- | 默认账号名 +defaultUser :: Text +defaultUser = "root" + +-- | 默认口令(只为方便本地测试;生产/对外必须覆盖) +defaultPassword :: Text +defaultPassword = "chusql" + +data WebConfig = WebConfig + { wcHost :: String + , wcPort :: Int + , wcStaticDir :: FilePath + , wcUser :: Text + , wcPasswordHash :: Maybe Text + , wcPassword :: Maybe Text + , wcStorageServer :: Maybe FilePath + , wcPipeName :: Maybe String + , wcDataDir :: Maybe FilePath + , wcCookieSecure :: Bool + , wcBodyLimit :: Int + , wcSessionIdle :: Int + , wcSessionMax :: Int + , wcLoginMaxAttempts :: Int + , wcLoginWindow :: Int + , wcPageSize :: Int + , wcMaxPageSize :: Int + , wcMaxRows :: Int + , wcMaxSqlLength :: Int + , wcSeedDemo :: Bool + } + deriving (Show, Eq) + +-- | 内置默认 +defaultWebConfig :: WebConfig +defaultWebConfig = + WebConfig + { wcHost = "127.0.0.1" + , wcPort = 7777 + , wcStaticDir = "static" + , wcUser = defaultUser + , wcPasswordHash = Nothing + , wcPassword = Nothing + , wcStorageServer = Nothing + , wcPipeName = Nothing + , wcDataDir = Nothing + , wcCookieSecure = False + , wcBodyLimit = 65536 + , wcSessionIdle = 8 * 3600 + , wcSessionMax = 24 * 3600 + , wcLoginMaxAttempts = 5 + , wcLoginWindow = 5 * 60 + , wcPageSize = 25 + , wcMaxPageSize = 500 + , wcMaxRows = 1000 + , wcMaxSqlLength = 20000 + , wcSeedDemo = False + } + +-- | 这份配置用的还是内置默认口令吗 +usingDefaultCredentials :: WebConfig -> Bool +usingDefaultCredentials cfg = wcPasswordHash cfg == Nothing && wcPassword cfg == Nothing + +-- | 备一份凭据:哈希 > 明文 > 内置默认 +resolveCredential :: WebConfig -> IO Credential +resolveCredential cfg = case (wcPasswordHash cfg, wcPassword cfg) of + (Just encoded, _) -> pure (Credential (wcUser cfg) encoded) + (Nothing, Just plain) -> do + encoded <- hashPassword plain + pure (Credential (wcUser cfg) encoded) + (Nothing, Nothing) -> do + encoded <- hashPassword defaultPassword + pure (Credential (wcUser cfg) encoded) + +-- | 从环境变量读配置,缺省用内置默认 +loadWebConfig :: IO WebConfig +loadWebConfig = do + let d = defaultWebConfig + host <- envOr "CHUSQL_WEB_HOST" (wcHost d) + port <- envInt "CHUSQL_WEB_PORT" (wcPort d) + staticDir <- envOr "CHUSQL_WEB_STATIC" (wcStaticDir d) + user <- T.pack <$> envOr "CHUSQL_WEB_USER" (T.unpack (wcUser d)) + passwordHash <- envText "CHUSQL_WEB_PASSWORD_HASH" + password <- envText "CHUSQL_WEB_PASSWORD" + server <- envMaybe "CHUSQL_STORAGE_SERVER" + pipe <- envMaybe "CHUSQL_PIPE" + dataDir <- envMaybe "CHUSQL_DATA_DIR" + secure <- envBool "CHUSQL_WEB_COOKIE_SECURE" (wcCookieSecure d) + limit <- envInt "CHUSQL_WEB_BODY_LIMIT" (wcBodyLimit d) + idle <- envInt "CHUSQL_WEB_SESSION_IDLE" (wcSessionIdle d) + sessionMax <- envInt "CHUSQL_WEB_SESSION_MAX" (wcSessionMax d) + loginMax <- envInt "CHUSQL_WEB_LOGIN_MAX_ATTEMPTS" (wcLoginMaxAttempts d) + loginWindow <- envInt "CHUSQL_WEB_LOGIN_WINDOW" (wcLoginWindow d) + pageSize <- envInt "CHUSQL_WEB_PAGE_SIZE" (wcPageSize d) + maxPageSize <- envInt "CHUSQL_WEB_MAX_PAGE_SIZE" (wcMaxPageSize d) + maxRows <- envInt "CHUSQL_WEB_MAX_ROWS" (wcMaxRows d) + maxSqlLength <- envInt "CHUSQL_WEB_MAX_SQL_LENGTH" (wcMaxSqlLength d) + seedDemo <- envBool "CHUSQL_WEB_SEED" (wcSeedDemo d) + pure + WebConfig + { wcHost = host + , wcPort = port + , wcStaticDir = staticDir + , wcUser = user + , wcPasswordHash = passwordHash + , wcPassword = password + , wcStorageServer = server + , wcPipeName = pipe + , wcDataDir = dataDir + , wcCookieSecure = secure + , wcBodyLimit = limit + , wcSessionIdle = idle + , wcSessionMax = sessionMax + , wcLoginMaxAttempts = loginMax + , wcLoginWindow = loginWindow + , wcPageSize = pageSize + , wcMaxPageSize = maxPageSize + , wcMaxRows = maxRows + , wcMaxSqlLength = maxSqlLength + , wcSeedDemo = seedDemo + } + +-- | 静态目录候选位置,按顺序取第一个存在的 +staticDirCandidates :: FilePath -> [FilePath] +staticDirCandidates given = + [given, "static", "chusql-web/static", "../chusql-web/static", "../static"] + +-- | 找出真正能用的静态目录 +resolveStaticDir :: FilePath -> IO (Maybe FilePath) +resolveStaticDir given = go (staticDirCandidates given) + where + go [] = pure Nothing + go (d : ds) = do + ok <- doesDirectoryExist d + if ok then pure (Just d) else go ds + +-- | 读一个字符串,缺省给默认值 +envOr :: String -> String -> IO String +envOr name def = fromMaybe def <$> lookupEnv name + +-- | 读一个环境变量(空串当没设) +envMaybe :: String -> IO (Maybe String) +envMaybe name = do + v <- lookupEnv name + pure (case v of Just s | not (null s) -> Just s; _ -> Nothing) + +-- | 读成 Text +envText :: String -> IO (Maybe Text) +envText name = fmap (fmap T.pack) (envMaybe name) + +-- | 读成整数,解析不出来就用默认值 +envInt :: String -> Int -> IO Int +envInt name def = do + v <- envMaybe name + pure (fromMaybe def (v >>= \s -> readMaybe s)) + +-- | 读成布尔(1/true/yes/on 都算真) +envBool :: String -> Bool -> IO Bool +envBool name def = do + v <- envMaybe name + pure $ case v of + Nothing -> def + Just s -> map toLower s `elem` ["1", "true", "yes", "on"] diff --git a/chusql-web/src/ChuSQL/Web/Demo.hs b/chusql-web/src/ChuSQL/Web/Demo.hs new file mode 100644 index 0000000..8bead61 --- /dev/null +++ b/chusql-web/src/ChuSQL/Web/Demo.hs @@ -0,0 +1,166 @@ +{-# LANGUAGE OverloadedStrings #-} + +module ChuSQL.Web.Demo ( + SeedReport (..), + demoTableSpecs, + demoRows, + demoIndexes, + demoNames, + demoPlan, + seedDemo, +) where + +import ChuSQL.Model (Value (..)) +import ChuSQL.Storage.IPC (TableInfo (..)) +import ChuSQL.Web.Actions ( + ColumnSpec (..), + CreateTableSpec (..), + createIndexSql, + createTableSql, + insertRowsSql, + ) +import ChuSQL.Web.Backend (Backend (..)) +import qualified Data.Text as T + +-- 演示数据:三张表加一个二级索引,语句与界面走同一套拼 SQL 的代码。 + +-- | 演示表(建表顺序即依赖顺序;没有外键约束,纯展示) +demoTableSpecs :: [CreateTableSpec] +demoTableSpecs = + [ CreateTableSpec "users" [ColumnSpec "id" "int", ColumnSpec "name" "str", ColumnSpec "age" "int"] + , CreateTableSpec "orders" [ColumnSpec "id" "int", ColumnSpec "user_id" "int", ColumnSpec "product" "str", ColumnSpec "amount" "int"] + , CreateTableSpec "products" [ColumnSpec "id" "int", ColumnSpec "name" "str", ColumnSpec "sku" "int", ColumnSpec "price" "int"] + ] + +-- | 每张表的演示行 +demoRows :: [(T.Text, [[(String, Value)]])] +demoRows = + [ ( "users" + , [ [("id", VInt i), ("name", VStr name), ("age", VInt age)] + | (i, (name, age)) <- zip [1 :: Int ..] userRecords + ] + ) + , ( "orders" + , [ [("id", VInt i), ("user_id", VInt uid), ("product", VStr p), ("amount", VInt a)] + | (i, (uid, p, a)) <- zip [1 :: Int ..] orderRecords + ] + ) + , ( "products" + , [ [("id", VInt i), ("name", VStr n), ("sku", VInt sku), ("price", VInt price)] + | (i, (n, sku, price)) <- zip [1 :: Int ..] productRecords + ] + ) + ] + +-- | 演示索引:products.sku 取值唯一 +demoIndexes :: [(T.Text, T.Text)] +demoIndexes = [("products", "sku")] + +-- | 演示数据涉及的表名 +demoNames :: [T.Text] +demoNames = map ctsTable demoTableSpecs + +-- | 完整的一套语句(建表 + 插行 + 建索引),按顺序执行 +demoPlan :: Either String [String] +demoPlan = do + creates <- mapM createTableSql demoTableSpecs + inserts <- mapM (uncurry insertRowsSql) demoRows + indexes <- mapM (uncurry createIndexSql) demoIndexes + pure (creates ++ inserts ++ indexes) + +data SeedReport = SeedReport + { srCreated :: [String] + , srSkipped :: [String] + , srIndexes :: [String] + } + deriving (Show, Eq) + +-- | 缺什么补什么,可反复点不重复灌 +seedDemo :: Backend -> IO (Either String SeedReport) +seedDemo backend = do + catalog <- beCatalog backend + case catalog of + Left e -> pure (Left e) + Right infos -> do + let existingTables = map tiTable infos + missing = [spec | spec <- demoTableSpecs, T.unpack (ctsTable spec) `notElem` existingTables] + skipped = [T.unpack (ctsTable spec) | spec <- demoTableSpecs, T.unpack (ctsTable spec) `elem` existingTables] + missingNames = map ctsTable missing + wantedIndexes = + [ (t, c) + | (t, c) <- demoIndexes + , t `elem` missingNames || indexMissing infos t c + ] + created <- runAll (map createTableSql missing) + case created of + Left e -> pure (Left e) + Right () -> do + rowsDone <- runAll [insertRowsSql t rs | (t, rs) <- demoRows, t `elem` missingNames] + case rowsDone of + Left e -> pure (Left e) + Right () -> do + indexes <- + runAll [createIndexSql t c | (t, c) <- wantedIndexes] + pure $ + fmap + (const (SeedReport (map T.unpack missingNames) skipped (map (T.unpack . snd) wantedIndexes))) + indexes + where + -- | 逐条执行,遇到错就停 + runAll :: [Either String String] -> IO (Either String ()) + runAll [] = pure (Right ()) + runAll (sql : rest) = case sql of + Left e -> pure (Left e) + Right statement -> do + result <- beStatement backend statement + case result of + Left e -> pure (Left e) + Right _ -> runAll rest + + -- | 这张表上是否还没有这个索引 + indexMissing :: [TableInfo] -> T.Text -> T.Text -> Bool + indexMissing infos t c = case lookup (T.unpack t) [(tiTable i, tiIndexes i) | i <- infos] of + Nothing -> True + Just cols -> T.unpack c `notElem` cols + +userRecords :: [(String, Int)] +userRecords = + [ ("Alice", 24) + , ("Bob", 31) + , ("Carol", 45) + , ("Dave", 19) + , ("Erin", 38) + , ("Frank", 52) + , ("Grace", 27) + , ("Heidi", 33) + , ("Ivan", 41) + , ("Judy", 29) + ] + +orderRecords :: [(Int, String, Int)] +orderRecords = + [ (1, "Laptop", 1299) + , (1, "Mouse", 25) + , (2, "Keyboard", 79) + , (3, "Monitor", 349) + , (3, "Webcam", 89) + , (4, "Headset", 119) + , (5, "SSD", 139) + , (5, "RAM", 99) + , (6, "Dock", 199) + , (7, "Cable", 12) + , (8, "Stand", 45) + , (9, "Lamp", 39) + ] + +productRecords :: [(String, Int, Int)] +productRecords = + [ ("Laptop", 1001, 1299) + , ("Mouse", 1002, 25) + , ("Keyboard", 1003, 79) + , ("Monitor", 1004, 349) + , ("Webcam", 1005, 89) + , ("Headset", 1006, 119) + , ("SSD", 1007, 139) + , ("RAM", 1008, 99) + ] diff --git a/chusql-web/src/ChuSQL/Web/RateLimit.hs b/chusql-web/src/ChuSQL/Web/RateLimit.hs new file mode 100644 index 0000000..a77b4eb --- /dev/null +++ b/chusql-web/src/ChuSQL/Web/RateLimit.hs @@ -0,0 +1,72 @@ +module ChuSQL.Web.RateLimit ( + RateLimiter, + newRateLimiter, + setRateLimit, + rateLimitBlock, + rateLimitRecord, + rateLimitClear, +) where + +import Control.Concurrent.MVar (MVar, modifyMVar_, newMVar, readMVar) +import Data.IORef (IORef, newIORef, readIORef, writeIORef) +import qualified Data.Map.Strict as Map +import Data.Text (Text) +import Data.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime) + +-- 登录失败限流:按用户名数一个滑动窗口里的失败次数。 + +data Attempt = Attempt + { attCount :: Int + , attStart :: UTCTime + } + +data RateLimitPolicy = RateLimitPolicy + { rlpMax :: Int + , rlpWindow :: NominalDiffTime + } + +data RateLimiter = RateLimiter + { rlMap :: MVar (Map.Map Text Attempt) + , rlPolicy :: IORef RateLimitPolicy + , rlClock :: IO UTCTime + } + +-- | 造一个限流器:多少次失败、窗口多长 +newRateLimiter :: IO UTCTime -> Int -> NominalDiffTime -> IO RateLimiter +newRateLimiter clock maxFailures window = do + ref <- newMVar Map.empty + policyRef <- newIORef (RateLimitPolicy maxFailures window) + pure (RateLimiter ref policyRef clock) + +-- | 换一套限流阈值(设置页热改) +setRateLimit :: RateLimiter -> Int -> NominalDiffTime -> IO () +setRateLimit rl maxFailures window = + writeIORef (rlPolicy rl) (RateLimitPolicy maxFailures window) + +-- | 这个键被锁住了吗?锁住就返回还要等多少秒 +rateLimitBlock :: RateLimiter -> Text -> IO (Maybe NominalDiffTime) +rateLimitBlock rl key = do + now <- rlClock rl + pol <- readIORef (rlPolicy rl) + m <- readMVar (rlMap rl) + pure $ case Map.lookup key m of + Just a + | attCount a >= rlpMax pol -> + let left = rlpWindow pol - diffUTCTime now (attStart a) + in if left > 0 then Just left else Nothing + _ -> Nothing + +-- | 记一次失败 +rateLimitRecord :: RateLimiter -> Text -> IO () +rateLimitRecord rl key = do + now <- rlClock rl + pol <- readIORef (rlPolicy rl) + modifyMVar_ (rlMap rl) $ \m -> + let kept = Map.filter (\a -> diffUTCTime now (attStart a) < rlpWindow pol) m + old = Map.findWithDefault (Attempt 0 now) key kept + fresh = if attCount old == 0 then Attempt 1 now else old{attCount = attCount old + 1} + in pure (Map.insert key fresh kept) + +-- | 登录成功就把失败记录清掉 +rateLimitClear :: RateLimiter -> Text -> IO () +rateLimitClear rl key = modifyMVar_ (rlMap rl) (pure . Map.delete key) diff --git a/chusql-web/src/ChuSQL/Web/Secure.hs b/chusql-web/src/ChuSQL/Web/Secure.hs new file mode 100644 index 0000000..3e749bc --- /dev/null +++ b/chusql-web/src/ChuSQL/Web/Secure.hs @@ -0,0 +1,99 @@ +{-# LANGUAGE OverloadedStrings #-} + +module ChuSQL.Web.Secure ( + securityHeaders, + contentSecurityPolicy, + sameOriginOnly, + bodyLimit, + bodyLimitDynamic, +) where + +import qualified Data.ByteString as BS +import qualified Data.ByteString.Char8 as BSC +import Data.Text (Text) +import qualified Data.Text.Encoding as TE +import Network.HTTP.Types ( + Header, + Method, + hContentLength, + hOrigin, + methodPost, + status403, + status413, + ) +import Network.Wai (Middleware, Request (..), mapResponseHeaders, responseLBS) + +-- 三个 WAI 安全中间件:响应头、同源校验、请求体上限。 + +-- | 所有响应都带上的安全头 +securityHeaders :: Middleware +securityHeaders app req respond = + app req (respond . mapResponseHeaders (\headers -> filter (\(name, _) -> name /= "Content-Security-Policy" || lookup name headers == Nothing) extraHeaders ++ headers)) + +-- | 只给编辑器样式发随机 nonce +contentSecurityPolicy :: Maybe Text -> Text +contentSecurityPolicy nonce = + "default-src 'self'; script-src 'self'; style-src 'self'" + <> maybe "" (\value -> " 'nonce-" <> value <> "'") nonce + <> "; font-src 'self'; worker-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'" + +-- | 统一加的安全响应头 +extraHeaders :: [Header] +extraHeaders = + [ ("Content-Security-Policy", TE.encodeUtf8 (contentSecurityPolicy Nothing)) + , ("X-Content-Type-Options", "nosniff") + , ("X-Frame-Options", "DENY") + , ("Referrer-Policy", "no-referrer") + , ("Cache-Control", "no-store") + ] + +-- | 变更类请求要求同源,无 Origin 放行 +sameOriginOnly :: Middleware +sameOriginOnly app req respond = + if isMutating (requestMethod req) && not (originAllowed req) + then respond (responseLBS status403 [] "cross-origin request rejected") + else app req respond + +-- | 这个方法算不算会改数据的 +isMutating :: Method -> Bool +isMutating m = m `elem` [methodPost, "PUT", "PATCH", "DELETE"] + +-- | Origin 与 Host 是否一致 +originAllowed :: Request -> Bool +originAllowed req = case lookup hOrigin (requestHeaders req) of + Nothing -> True + Just origin -> case lookup "Host" (requestHeaders req) of + Nothing -> False + Just host -> authorityOf origin == host || authorityOf origin == stripPort host + +-- | 取出 host:port,去掉 scheme 与路径 +authorityOf :: BS.ByteString -> BS.ByteString +authorityOf raw = + let noScheme = case BSC.breakSubstring "//" raw of + (_, rest) | not (BSC.null rest) -> BSC.drop 2 rest + _ -> raw + in fst (BSC.break (== '/') noScheme) + +-- | 去掉端口 host:8080 -> host +stripPort :: BS.ByteString -> BS.ByteString +stripPort = fst . BSC.break (== ':') + +-- | 按 Content-Length 拦下过大的请求体 +bodyLimit :: Int -> Middleware +bodyLimit limit = bodyLimitDynamic (pure limit) + +-- | 同上,但上限每次请求现取 +bodyLimitDynamic :: IO Int -> Middleware +bodyLimitDynamic limitOf app req respond = do + limit <- limitOf + if declaredTooBig limit req + then respond (responseLBS status413 [] "request body too large") + else app req respond + +-- | Content-Length 是否超限 +declaredTooBig :: Int -> Request -> Bool +declaredTooBig limit req = case lookup hContentLength (requestHeaders req) of + Nothing -> False + Just v -> case BSC.readInt v of + Just (n, rest) -> BSC.null rest && n > limit + Nothing -> False diff --git a/chusql-web/src/ChuSQL/Web/Settings.hs b/chusql-web/src/ChuSQL/Web/Settings.hs new file mode 100644 index 0000000..03a5c4e --- /dev/null +++ b/chusql-web/src/ChuSQL/Web/Settings.hs @@ -0,0 +1,249 @@ +{-# LANGUAGE OverloadedStrings #-} + +module ChuSQL.Web.Settings ( + SettingItem (..), + settingCatalogue, + findItem, + isRootOnly, + isRestartRequired, + settingsFileCandidates, + resolveSettingsFile, + readSettingsFile, + writeSettingsFile, + applySettings, + liveKeys, + defaultOf, + effectiveSettings, +) where + +import ChuSQL.Web.Config (WebConfig (..)) +import qualified Data.Aeson as A +import qualified Data.Aeson.Key as K +import qualified Data.Aeson.KeyMap as KM +import qualified Data.ByteString as BS +import qualified Data.ByteString.Lazy as BL +import qualified Data.Map.Strict as Map +import Data.Text (Text) +import qualified Data.Text as T +import Control.Exception (IOException, try) +import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist) +import System.FilePath (takeDirectory, ()) + +-- Web 配置项目录,与启动脚本共用一份设置文件,支持热改与重启生效。 + +data SettingItem = SettingItem + { siKey :: Text + , siLabel :: Text + , siGroup :: Text + , siKind :: Text + , siEnv :: Text + , siDefault :: Text + , siRootOnly :: Bool + , siRestart :: Bool + } + deriving (Show, Eq) + +-- | 全部可配置项(顺序即界面里的顺序) +settingCatalogue :: [SettingItem] +settingCatalogue = + [ item "port" "HTTP port" "server" "int" "CHUSQL_WEB_PORT" "7777" True True + , item "host" "Listen address" "server" "text" "CHUSQL_WEB_HOST" "127.0.0.1" True True + , item "static-dir" "Static directory" "server" "text" "CHUSQL_WEB_STATIC" "static" True True + , item "cookie-secure" "Secure cookie (behind TLS)" "server" "bool" "CHUSQL_WEB_COOKIE_SECURE" "0" True False + , item "body-limit" "Max request body (bytes)" "limits" "int" "CHUSQL_WEB_BODY_LIMIT" "65536" False False + , item "session-idle" "Session idle timeout (s)" "limits" "int" "CHUSQL_WEB_SESSION_IDLE" "28800" False False + , item "session-max" "Session max age (s)" "limits" "int" "CHUSQL_WEB_SESSION_MAX" "86400" False False + , item "login-max-attempts" "Failed logins before lockout" "limits" "int" "CHUSQL_WEB_LOGIN_MAX_ATTEMPTS" "5" False False + , item "login-window" "Lockout window (s)" "limits" "int" "CHUSQL_WEB_LOGIN_WINDOW" "300" False False + , item "rows-per-page" "Rows per page" "limits" "int" "CHUSQL_WEB_PAGE_SIZE" "25" False False + , item "max-page-size" "Rows per page ceiling" "limits" "int" "CHUSQL_WEB_MAX_PAGE_SIZE" "500" False False + , item "max-rows" "Rows per query" "limits" "int" "CHUSQL_WEB_MAX_ROWS" "1000" False False + , item "max-sql-length" "Max SQL characters" "limits" "int" "CHUSQL_WEB_MAX_SQL_LENGTH" "20000" False False + , item "user" "Account name" "auth" "text" "CHUSQL_WEB_USER" "root" True True + , item "password" "Password (plain, hashed at start)" "auth" "secret" "CHUSQL_WEB_PASSWORD" "" True True + , item "password-hash" "Password hash (pbkdf2)" "auth" "secret" "CHUSQL_WEB_PASSWORD_HASH" "" True True + , item "data-dir" "Data directory" "storage" "text" "CHUSQL_DATA_DIR" "" True True + , item "pipe-name" "Named pipe" "storage" "text" "CHUSQL_PIPE" "" True True + , item "storage-page-size" "Storage page size" "storage" "int" "CHUSQL_PAGE_SIZE" "4096" True True + , item "storage-btree-order" "Storage B+tree order" "storage" "int" "CHUSQL_BTREE_ORDER" "4" True True + , item "storage-buffer-pool" "Storage buffer pool pages" "storage" "int" "CHUSQL_BUFFER_POOL_SIZE" "1024" True True + , item "storage-log" "Storage log level" "storage" "text" "CHUSQL_LOG" "info" True True + , item "seed" "Seed demo data on start" "storage" "bool" "CHUSQL_WEB_SEED" "1" True True + ] + where + item k l g kind env def rootOnly restart = + SettingItem + { siKey = k + , siLabel = l + , siGroup = g + , siKind = kind + , siEnv = env + , siDefault = def + , siRootOnly = rootOnly + , siRestart = restart + } + +-- | 按 key 找一项 +findItem :: Text -> Maybe SettingItem +findItem key = case [i | i <- settingCatalogue, siKey i == key] of + (i : _) -> Just i + [] -> Nothing + +-- | 这项是不是重要参数(只有配置账号能改) +isRootOnly :: Text -> Bool +isRootOnly key = maybe False siRootOnly (findItem key) + +-- | 这项是不是要重启才生效 +isRestartRequired :: Text -> Bool +isRestartRequired key = maybe False siRestart (findItem key) + +-- | 改完立刻生效的配置键 +liveKeys :: [Text] +liveKeys = + [ "body-limit" + , "session-idle" + , "session-max" + , "login-max-attempts" + , "login-window" + , "rows-per-page" + , "max-page-size" + , "max-rows" + , "max-sql-length" + ] + +-- | 设置文件的候选位置 +settingsFileCandidates :: [FilePath] +settingsFileCandidates = + [ "script" "chusql.settings.json" + , ".." "script" "chusql.settings.json" + , "chusql.settings.json" + ] + +-- | 找现成的设置文件,否则挑目录已存在的候选 +resolveSettingsFile :: IO FilePath +resolveSettingsFile = do + found <- firstExistingFile settingsFileCandidates + case found of + Just path -> pure path + Nothing -> do + ready <- firstExistingDir settingsFileCandidates + pure (maybe (headOr settingsFileCandidates "chusql.settings.json") id ready) + where + firstExistingFile [] = pure Nothing + firstExistingFile (p : ps) = do + ok <- doesFileExist p + if ok then pure (Just p) else firstExistingFile ps + + -- | 候选所在目录是否已存在 + firstExistingDir [] = pure Nothing + firstExistingDir (p : ps) = do + let dir = takeDirectory p + ok <- if null dir then pure True else doesDirectoryExist dir + if ok then pure (Just p) else firstExistingDir ps + +-- | 候选列表里的第一个 +headOr :: [a] -> a -> a +headOr (x : _) _ = x +headOr [] d = d + +-- | 读设置文件:键 -> 字符串值(文件不存在或坏掉都当空) +readSettingsFile :: FilePath -> IO (Map.Map Text Text) +readSettingsFile path = do + exists <- doesFileExist path + if not exists + then pure Map.empty + else do + raw <- readFileUtf8 path + pure $ case A.eitherDecodeStrict' raw of + Left _ -> Map.empty + Right (A.Object o) -> Map.fromList [(K.toText k, jsonToText v) | (k, v) <- KM.toList o] + Right _ -> Map.empty + where + -- | JSON 值当字符串看 + jsonToText v = case v of + A.String s -> s + A.Number n -> T.pack (show n) + A.Bool b -> if b then "1" else "0" + _ -> "" + +-- | 读文件(UTF-8 字节读,避免依赖系统编码) +readFileUtf8 :: FilePath -> IO BS.ByteString +readFileUtf8 = BS.readFile + +-- | 写设置文件,只写非空值 +writeSettingsFile :: FilePath -> Map.Map Text Text -> IO (Either String ()) +writeSettingsFile path values = do + let dir = takeDirectory path + prepared <- try (ensureDir dir) :: IO (Either IOException ()) + case prepared of + Left e -> pure (Left ("cannot create the settings directory " ++ dir ++ ": " ++ show e)) + Right () -> do + let encoded = + A.encode + ( A.Object + ( KM.fromList + [ (K.fromText k, A.String v) + | (k, v) <- Map.toList values + , not (T.null v) + ] + ) + ) + written <- try (BS.writeFile path (BL.toStrict encoded)) :: IO (Either IOException ()) + pure (either (Left . \e -> "cannot write " ++ path ++ ": " ++ show e) Right written) + where + ensureDir dir = if null dir then pure () else createDirectoryIfMissing True dir + +-- | 把界面上传来的值合并进现有文件内容(只接受目录里有的键) +applySettings :: Map.Map Text Text -> Map.Map Text Text -> Either String (Map.Map Text Text) +applySettings current = go (Right current) . Map.toList + where + go (Left e) _ = Left e + go (Right acc) [] = Right acc + go (Right acc) ((key, value) : rest) = case findItem key of + Nothing -> Left ("unknown setting: " ++ T.unpack key) + Just spec -> case checkKind spec value of + Left e -> Left e + Right () -> go (Right (Map.insert key value acc)) rest + +-- | 按类型校验一个值(空串表示"清掉",一律放行) +checkKind :: SettingItem -> Text -> Either String () +checkKind spec value + | T.null (T.strip value) = Right () + | siKind spec == "int" = case reads (T.unpack (T.strip value)) :: [(Int, String)] of + [(_, "")] -> Right () + _ -> Left (T.unpack (siKey spec) ++ " needs an integer, got: " ++ T.unpack value) + | siKind spec == "bool" = case T.toLower (T.strip value) of + v | v `elem` ["0", "1", "true", "false", "yes", "no", "on", "off"] -> Right () + _ -> Left (T.unpack (siKey spec) ++ " needs a boolean, got: " ++ T.unpack value) + | otherwise = Right () + +-- | 一项的内置默认值(设置被清掉、也没有别处给值时用它) +defaultOf :: Text -> Text +defaultOf key = maybe "" siDefault (findItem key) + +-- | 本进程启动时生效的配置,摊成键值对 +effectiveSettings :: WebConfig -> Map.Map Text Text +effectiveSettings cfg = + Map.fromList + [ ("port", tshow (wcPort cfg)) + , ("host", T.pack (wcHost cfg)) + , ("static-dir", T.pack (wcStaticDir cfg)) + , ("cookie-secure", boolText (wcCookieSecure cfg)) + , ("body-limit", tshow (wcBodyLimit cfg)) + , ("session-idle", tshow (wcSessionIdle cfg)) + , ("session-max", tshow (wcSessionMax cfg)) + , ("login-max-attempts", tshow (wcLoginMaxAttempts cfg)) + , ("login-window", tshow (wcLoginWindow cfg)) + , ("rows-per-page", tshow (wcPageSize cfg)) + , ("max-page-size", tshow (wcMaxPageSize cfg)) + , ("max-rows", tshow (wcMaxRows cfg)) + , ("max-sql-length", tshow (wcMaxSqlLength cfg)) + , ("user", wcUser cfg) + , ("seed", boolText (wcSeedDemo cfg)) + , ("data-dir", maybe "" T.pack (wcDataDir cfg)) + , ("pipe-name", maybe "" T.pack (wcPipeName cfg)) + ] + where + tshow = T.pack . show + boolText True = "1" + boolText False = "0" diff --git a/chusql-web/src/ChuSQL/Web/Static.hs b/chusql-web/src/ChuSQL/Web/Static.hs new file mode 100644 index 0000000..87bda62 --- /dev/null +++ b/chusql-web/src/ChuSQL/Web/Static.hs @@ -0,0 +1,61 @@ +{-# LANGUAGE OverloadedStrings #-} + +module ChuSQL.Web.Static ( + safeRelative, + readStatic, + contentTypeOf, +) where + +import qualified Data.ByteString as BS +import Data.Char (isAsciiLower, isAsciiUpper, isDigit, toLower) +import Data.Text (Text) +import qualified Data.Text as T +import System.Directory (doesFileExist) +import System.FilePath ((), takeExtension) + +-- 静态文件交付:文件名白名单加扩展名白名单,不做目录列举。 + +-- | 文件名长度上限 +maxNameLength :: Int +maxNameLength = 128 + +-- | 允许交付的扩展名(只有前端资产) +allowedExtensions :: [String] +allowedExtensions = [".html", ".css", ".js", ".svg", ".png", ".ico", ".ttf", ".woff2"] + +-- | 把请求里的文件名变成安全的相对路径;不合法给 Nothing +safeRelative :: Text -> Maybe FilePath +safeRelative t + | T.null t = Nothing + | T.length t > maxNameLength = Nothing + | ".." `T.isInfixOf` t = Nothing + | T.any badChar t = Nothing + | map toLower (takeExtension (T.unpack t)) `notElem` allowedExtensions = Nothing + | otherwise = Just (T.unpack t) + where + -- | 只允许字母数字与 ._- + badChar c = not (isAsciiLower c || isAsciiUpper c || isDigit c || c `elem` ("._-" :: String)) + +-- | 读一个静态文件;不存在(或是目录)给 Nothing +readStatic :: FilePath -> FilePath -> IO (Maybe BS.ByteString) +readStatic dir rel = case safeRelative (T.pack rel) of + Nothing -> pure Nothing + Just name -> do + let path = dir name + ok <- doesFileExist path + if ok then Just <$> BS.readFile path else pure Nothing + +-- | 按扩展名给 Content-Type +contentTypeOf :: FilePath -> Text +contentTypeOf path = case map toLower (takeExtension path) of + ".html" -> "text/html; charset=utf-8" + ".css" -> "text/css; charset=utf-8" + ".js" -> "text/javascript; charset=utf-8" + ".json" -> "application/json; charset=utf-8" + ".svg" -> "image/svg+xml" + ".png" -> "image/png" + ".ico" -> "image/x-icon" + ".ttf" -> "font/ttf" + ".woff2" -> "font/woff2" + ".txt" -> "text/plain; charset=utf-8" + _ -> "application/octet-stream" diff --git a/chusql-web/src/ChuSQL/Web/StorageProcess.hs b/chusql-web/src/ChuSQL/Web/StorageProcess.hs new file mode 100644 index 0000000..aae21ee --- /dev/null +++ b/chusql-web/src/ChuSQL/Web/StorageProcess.hs @@ -0,0 +1,119 @@ +{-# LANGUAGE OverloadedStrings #-} + +module ChuSQL.Web.StorageProcess ( + StorageProcess (..), + findStorageServer, + startStorageProcess, + stopStorageProcess, + waitForStorage, +) where + +import ChuSQL.Storage.IPC (Request (..), Response (..), closeConnection, sendRequest) +import Control.Concurrent (threadDelay) +import Control.Exception (SomeException, try) +import Control.Monad (filterM) +import Data.Time.Clock.POSIX (getPOSIXTime) +import System.Directory (createDirectoryIfMissing, doesFileExist, getTemporaryDirectory) +import System.Environment (getEnvironment, setEnv) +import System.Exit (ExitCode) +import System.FilePath (()) +import System.IO (Handle) +import System.Process ( + CreateProcess (..), + ProcessHandle, + StdStream (..), + createProcess, + proc, + terminateProcess, + waitForProcess, + ) + +-- 拉起并管理 Rust 存储进程,起来后等管道应答 ping 再继续。 + +data StorageProcess = StorageProcess + { spProcess :: ProcessHandle + , spPipe :: String + , spDataDir :: FilePath + } + +-- | 找存储进程,没有就给路径提示 +findStorageServer :: Maybe FilePath -> IO (Either String FilePath) +findStorageServer (Just p) = do + ok <- doesFileExist p + pure (if ok then Right p else Left ("storage server not found: " ++ p)) +findStorageServer Nothing = do + let dirs = + [ "chusql-storage/target" + , "../chusql-storage/target" + , "../../chusql-storage/target" + , "target" + ] + modes = ["release", "debug"] + exes = ["chusql-storage.exe", "chusql-storage"] + candidates = [d m e | d <- dirs, m <- modes, e <- exes] + found <- filterM doesFileExist candidates + pure $ case found of + (p : _) -> Right p + [] -> Left "no Rust storage server found; run `cargo build --release` in chusql-storage, or pass --storage " +-- | 起存储进程并等它就绪 +startStorageProcess :: FilePath -> IO (Either String StorageProcess) +startStorageProcess bin = do + stamp <- round . (* 1000000) <$> getPOSIXTime :: IO Integer + tmp <- getTemporaryDirectory + let pipe = "chusql-web-" ++ show stamp + dir = tmp pipe + createDirectoryIfMissing True dir + envs <- + childEnv + [ ("CHUSQL_PIPE", pipe) + , ("CHUSQL_DATA_DIR", dir) + ] + started <- + try (createProcess (proc bin []){env = Just envs, std_out = NoStream, std_err = Inherit}) :: + IO (Either SomeException (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle)) + case started of + Left e -> pure (Left ("failed to start the storage process: " ++ show e)) + Right (_, _, _, ph) -> do + setEnv "CHUSQL_PIPE" pipe + setEnv "CHUSQL_DATA_DIR" dir + ready <- waitForStorage (200 :: Int) + if ready + then pure (Right (StorageProcess ph pipe dir)) + else do + _ <- try (terminateProcess ph) :: IO (Either SomeException ()) + pure (Left "storage process did not become ready within 5 seconds (no ping reply on the pipe)") + +-- | 停掉存储进程,保留数据目录 +stopStorageProcess :: StorageProcess -> IO () +stopStorageProcess sp = do + closeConnection + _ <- try (terminateProcess (spProcess sp)) :: IO (Either SomeException ()) + _ <- try (waitForProcess (spProcess sp)) :: IO (Either SomeException ExitCode) + pure () + +-- | 轮询 ping 直到对面应答 +waitForStorage :: Int -> IO Bool +waitForStorage 0 = pure False +waitForStorage n = do + r <- try (sendRequest ReqPing) :: IO (Either SomeException Response) + case r of + Right RespPong -> pure True + _ -> threadDelay 25000 >> waitForStorage (n - 1) + +-- | 子进程环境:继承并覆盖两个专用变量 +childEnv :: [(String, String)] -> IO [(String, String)] +childEnv extra = do + base <- getEnvironment + let ours = ["CHUSQL_PIPE", "CHUSQL_DATA_DIR"] + kept = filter (\(k, _) -> k `notElem` ours) base + missing (k, _) = not (any ((== k) . fst) kept) + defaults = filter missing storageDefaults + pure (kept ++ defaults ++ extra) + +-- | 存储进程的调参默认值(可以被用户环境里的同名变量覆盖) +storageDefaults :: [(String, String)] +storageDefaults = + [ ("CHUSQL_PAGE_SIZE", "4096") + , ("CHUSQL_BTREE_ORDER", "4") + , ("CHUSQL_LOG", "info") + ] diff --git a/chusql-web/src/ChuSQL/Web/UiSettings.hs b/chusql-web/src/ChuSQL/Web/UiSettings.hs new file mode 100644 index 0000000..a19486e --- /dev/null +++ b/chusql-web/src/ChuSQL/Web/UiSettings.hs @@ -0,0 +1,180 @@ +{-# LANGUAGE OverloadedStrings #-} + +module ChuSQL.Web.UiSettings ( + uiSettingsFileCandidates, + resolveUiSettingsFile, + validateUiSettings, + readUiSettings, + writeUiSettings, +) where + +import Control.Exception (IOException, try) +import qualified Data.Aeson as A +import qualified Data.Aeson.Key as K +import qualified Data.Aeson.KeyMap as KM +import qualified Data.ByteString as BS +import qualified Data.ByteString.Lazy as BL +import qualified Data.Map.Strict as Map +import Data.Scientific (floatingOrInteger) +import Data.Text (Text) +import qualified Data.Text as T +import qualified Data.Vector as V +import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist) +import System.FilePath (takeDirectory, ()) + +-- 前端 IDE 设置单独存一份文件,与启动配置 chusql.settings.json 互不干扰。 + +-- | IDE 设置文件的候选位置 +uiSettingsFileCandidates :: [FilePath] +uiSettingsFileCandidates = + [ "script" "chusql.ui.settings.json" + , ".." "script" "chusql.ui.settings.json" + , "chusql.ui.settings.json" + ] + +-- | 找现成的设置文件,否则挑目录已存在的候选 +resolveUiSettingsFile :: IO FilePath +resolveUiSettingsFile = do + found <- firstExistingFile uiSettingsFileCandidates + case found of + Just path -> pure path + Nothing -> do + ready <- firstExistingDir uiSettingsFileCandidates + pure (maybe firstCandidate id ready) + where + -- | 候选清单里的第一个 + firstCandidate = case uiSettingsFileCandidates of + (p : _) -> p + [] -> "chusql.ui.settings.json" + + firstExistingFile [] = pure Nothing + firstExistingFile (p : ps) = do + ok <- doesFileExist p + if ok then pure (Just p) else firstExistingFile ps + + -- | 候选所在目录是否已存在 + firstExistingDir [] = pure Nothing + firstExistingDir (p : ps) = do + let dir = takeDirectory p + ok <- if null dir then pure True else doesDirectoryExist dir + if ok then pure (Just p) else firstExistingDir ps + +-- | 校验一份 IDE 设置,未知键与越界都给错 +validateUiSettings :: A.Value -> Either String (Map.Map Text A.Value) +validateUiSettings (A.Object o) = Map.fromList <$> mapM checkField (KM.toList o) + where + checkField (rawKey, value) = + let key = K.toText rawKey + in case key of + "uiFonts" -> pair key <$> fontChain key value + "gridFonts" -> pair key <$> fontChain key value + "sqlFonts" -> pair key <$> fontChain key value + "uiFontSize" -> pair key <$> boundedInt key 11 16 value + "gridFontSize" -> pair key <$> boundedInt key 10 18 value + "gridRowHeight" -> pair key <$> boundedInt key 18 40 value + "sqlFontSize" -> pair key <$> boundedInt key 10 20 value + "sqlLineHeight" -> pair key <$> boundedInt key 16 40 value + "sqlTabSize" -> pair key <$> boundedInt key 1 8 value + "pageSize" -> pair key <$> boundedInt key 10 500 value + "nullText" -> pair key <$> shortText key value + "sqlLineNumbers" -> pair key <$> boolValue key value + "autocomplete" -> pair key <$> boolValue key value + "minimap" -> pair key <$> boolValue key value + "confirmBeforeCommit" -> pair key <$> boolValue key value + _ -> Left ("unknown setting: " ++ T.unpack key) + + pair key value = (key, value) + +validateUiSettings _ = Left "settings must be a JSON object" + +fontChainMax :: Int +fontChainMax = 10 + +fontNameMax :: Int +fontNameMax = 64 + +nullTextMax :: Int +nullTextMax = 64 + +-- | 字体链:最多 10 项,每项非空且不超 64 字 +fontChain :: Text -> A.Value -> Either String A.Value +fontChain key value = case value of + A.Array xs + | V.length xs > fontChainMax -> bad + | otherwise -> A.Array . V.fromList <$> mapM item (V.toList xs) + _ -> bad + where + bad = + Left + ( T.unpack key + ++ " expects at most " + ++ show fontChainMax + ++ " non-empty font names of at most " + ++ show fontNameMax + ++ " characters" + ) + item (A.String s) = + let trimmed = T.strip s + in if not (T.null trimmed) && T.length trimmed <= fontNameMax + then Right (A.String trimmed) + else bad + item _ = bad + +-- | 区间内的整数,顺便把它整成规范写法 +boundedInt :: Text -> Int -> Int -> A.Value -> Either String A.Value +boundedInt key lo hi value = case value of + A.Number n -> case floatingOrInteger n :: Either Double Integer of + Right i + | i >= fromIntegral lo && i <= fromIntegral hi -> Right (A.Number (fromIntegral i)) + _ -> bad + _ -> bad + where + bad = Left (T.unpack key ++ " expects an integer in " ++ show lo ++ ".." ++ show hi) + +-- | 空值占位文本:字符串且不超 64 字 +shortText :: Text -> A.Value -> Either String A.Value +shortText key value = case value of + A.String s + | T.length s <= nullTextMax -> Right (A.String s) + _ -> + Left + ( T.unpack key + ++ " expects a string of at most " + ++ show nullTextMax + ++ " characters" + ) + +-- | 布尔开关 +boolValue :: Text -> A.Value -> Either String A.Value +boolValue key value = case value of + A.Bool b -> Right (A.Bool b) + _ -> Left (T.unpack key ++ " expects a boolean") + +-- | 读 IDE 设置文件,缺文件或坏内容都给空对象 +readUiSettings :: FilePath -> IO A.Value +readUiSettings path = do + exists <- doesFileExist path + if not exists + then pure emptyObject + else do + raw <- try (BS.readFile path) :: IO (Either IOException BS.ByteString) + pure $ case raw of + Left _ -> emptyObject + Right bytes -> case A.eitherDecodeStrict' bytes of + Right (A.Object o) -> A.Object o + _ -> emptyObject + where + emptyObject = A.Object KM.empty + +-- | 写 IDE 设置文件,先建目录再落盘 +writeUiSettings :: FilePath -> Map.Map Text A.Value -> IO (Either String ()) +writeUiSettings path values = do + prepared <- try (ensureDir (takeDirectory path)) :: IO (Either IOException ()) + case prepared of + Left e -> pure (Left ("cannot create the settings directory " ++ takeDirectory path ++ ": " ++ show e)) + Right () -> do + let encoded = A.encode (A.Object (KM.fromList [(K.fromText k, v) | (k, v) <- Map.toList values])) + written <- try (BS.writeFile path (BL.toStrict encoded)) :: IO (Either IOException ()) + pure (either (Left . \e -> "cannot write " ++ path ++ ": " ++ show e) Right written) + where + ensureDir dir = if null dir then pure () else createDirectoryIfMissing True dir diff --git a/chusql-web/stack.yaml b/chusql-web/stack.yaml new file mode 100644 index 0000000..4048110 --- /dev/null +++ b/chusql-web/stack.yaml @@ -0,0 +1,5 @@ +resolver: lts-24.59 + +packages: + - . + - ../chusql-engine diff --git a/chusql-web/stack.yaml.lock b/chusql-web/stack.yaml.lock new file mode 100644 index 0000000..e3f9041 --- /dev/null +++ b/chusql-web/stack.yaml.lock @@ -0,0 +1,12 @@ +# This file was autogenerated by Stack. +# You should not edit this file by hand. +# For more information, please see the documentation at: +# https://docs.haskellstack.org/en/stable/topics/lock_files + +packages: [] +snapshots: +- completed: + sha256: 0f728e30e843d7f00460ad56858b995a3b227fca683659c7df9f2fc3988a3ce1 + size: 732465 + url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/24/59.yaml + original: lts-24.59 diff --git a/chusql-web/test/Spec.hs b/chusql-web/test/Spec.hs new file mode 100644 index 0000000..08ababc --- /dev/null +++ b/chusql-web/test/Spec.hs @@ -0,0 +1,1769 @@ +{-# LANGUAGE OverloadedStrings #-} + +module Main (main) where + +import ChuSQL.Model (Column (..), Database, Table (..), Value (..)) +import ChuSQL.Storage.IPC (SchemaColumn (..), TableInfo (..)) +import ChuSQL.Syntax.AST (Expr (..), FromClause (..), Statement (..)) +import ChuSQL.Web.Actions ( + ColumnSpec (..), + CreateTableSpec (..), + coerceValue, + columnTypeOf, + createIndexSql, + createTableSql, + deleteRowSql, + dropIndexSql, + dropTableSql, + insertRowSql, + insertRowsSql, + isIdentifier, + selectRowsSql, + sqlLiteral, + updateRowSql, + ) +import ChuSQL.Web.Api ( + AppEnv (..), + Live (..), + defaultLive, + newAppEnv, + parseCookieHeader, + readLive, + renderRow, + setLive, + webApp, + ) +import ChuSQL.Web.Auth ( + Credential (..), + SessionPolicy (..), + createSession, + defaultSessionPolicy, + deleteOtherSessions, + deleteSession, + hashLooksValid, + hashPasswordWith, + lookupSession, + newSessionStore, + sessionPolicy, + sessionToken, + setSessionPolicy, + verifyPassword, + ) +import ChuSQL.Web.Backend (columnsFromStatement, ipcBackend, memoryBackend, tableInfoOf) +import ChuSQL.Web.Config ( + WebConfig (..), + defaultPassword, + defaultUser, + defaultWebConfig, + loadWebConfig, + resolveCredential, + usingDefaultCredentials, + ) +import ChuSQL.Web.RateLimit (newRateLimiter, rateLimitBlock, rateLimitRecord, setRateLimit) +import ChuSQL.Web.Settings ( + SettingItem (..), + applySettings, + defaultOf, + effectiveSettings, + findItem, + isRestartRequired, + isRootOnly, + liveKeys, + readSettingsFile, + resolveSettingsFile, + settingCatalogue, + writeSettingsFile, + ) +import ChuSQL.Web.Static (contentTypeOf, readStatic, safeRelative) +import ChuSQL.Web.StorageProcess (StorageProcess (..), startStorageProcess, stopStorageProcess) +import ChuSQL.Web.UiSettings ( + readUiSettings, + resolveUiSettingsFile, + uiSettingsFileCandidates, + validateUiSettings, + writeUiSettings, + ) +import Control.Concurrent.MVar (newMVar) +import Control.Exception (IOException, bracket, finally, try) +import Data.Aeson (decode) +import qualified Data.Aeson as A +import qualified Data.Aeson.Key as K +import qualified Data.Aeson.KeyMap as KM +import qualified Data.ByteString as BS +import qualified Data.ByteString.Char8 as BSC +import qualified Data.ByteString.Lazy as BL +import Data.IORef (newIORef, readIORef, writeIORef) +import Data.List (isInfixOf, isSuffixOf, nub, sort) +import qualified Data.Map.Strict as Map +import Data.Maybe (fromMaybe) +import Data.Text (Text) +import qualified Data.Text as T +import qualified Data.Text.Encoding as TE +import Data.Time.Calendar (fromGregorian) +import Data.Time.Clock (UTCTime (..), addUTCTime, getCurrentTime) +import qualified Data.Vector as V +import Network.HTTP.Client ( + Manager, + Request (method, requestBody, requestHeaders), + RequestBody (..), + Response (responseBody, responseHeaders, responseStatus), + defaultManagerSettings, + httpLbs, + newManager, + parseRequest, + ) +import Network.HTTP.Types (Header, Method, methodGet, methodPost, statusCode) +import Network.Wai (Application) +import qualified Network.Wai.Handler.Warp as Warp +import qualified Network.Wai.Test as WT +import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getTemporaryDirectory) +import qualified System.Directory +import System.Environment (lookupEnv, setEnv, unsetEnv) +import System.Exit (ExitCode (..)) +import System.FilePath (()) +import System.IO (Handle) +import System.Process ( + CreateProcess (cwd, std_err, std_out), + ProcessHandle, + StdStream (..), + createProcess, + proc, + waitForProcess, + ) +import Test.Hspec +import Test.Hspec.Wai hiding (pendingWith) + +-- ChuSQL Web 测试,覆盖三层: +-- 纯函数、路由级(内存后端)、端到端(真 Rust 存储进程)。 + +-- | users 5 行 + orders 1 行 +testDb :: Database +testDb = + [ ( "users" + , Table + "users" + [("id", TInt), ("name", TStr), ("age", TInt)] + [ [("id", VInt i), ("name", VStr ("user" ++ show i)), ("age", VInt (20 + i))] + | i <- [1 .. 5] + ] + ) + , ( "orders" + , Table + "orders" + [("id", TInt), ("user_id", TInt)] + [[("id", VInt 1), ("user_id", VInt 1)]] + ) + ] + +-- | 测试账号:迭代次数压低(跑得快),口令 `s3cret` +testCredential :: Credential +testCredential = Credential "admin" (hashPasswordWith 1000 (BS.replicate 16 7) "s3cret") + +-- | 可控时钟的起点 +epoch :: UTCTime +epoch = UTCTime (fromGregorian 2026 1 1) 0 + +-- | 默认会话策略 +policy :: SessionPolicy +policy = SessionPolicy (8 * 3600) (24 * 3600) + +-- | 造一条 `SELECT *` +selectStar :: FromClause -> Statement +selectStar from = Select ["*"] from Nothing [] Nothing + +-- | 造一条显式列名的 SELECT +selectThese :: [String] -> FromClause -> Statement +selectThese cols from = Select cols from Nothing [] Nothing + +-- | with 块里的会话类型别名 +type TestSession a = WaiSession () a + +-- | 每个用例组一套:内存后端 + 可控时钟 + 临时静态目录 +freshWorld :: FilePath -> IO AppEnv +freshWorld staticDir = do + db <- newMVar testDb + clock <- newIORef epoch + sessions <- newSessionStore (readIORef clock) policy + limiter <- newRateLimiter (readIORef clock) 5 (5 * 60) + env <- newAppEnv (memoryBackend db) sessions testCredential limiter staticDir + path <- tempSettingsPath "default" + removeIfExists path + uiPath <- tempUiSettingsPath "default" + removeIfExists uiPath + pure env{aeSettingsFile = path, aeUiSettingsFile = uiPath, aeEffective = Map.empty} + +-- | 临时设置文件(每个用例组一个名字,互不干扰) +tempSettingsPath :: String -> IO FilePath +tempSettingsPath name = do + tmp <- getTemporaryDirectory + pure (tmp ("chusql-web-test-" ++ name ++ "-settings.json")) + +-- | 临时 IDE 设置文件(同样按用例组取名) +tempUiSettingsPath :: String -> IO FilePath +tempUiSettingsPath name = do + tmp <- getTemporaryDirectory + pure (tmp ("chusql-web-test-" ++ name ++ "-ui-settings.json")) + +-- | 文件不存在也算成功 +removeIfExists :: FilePath -> IO () +removeIfExists path = do + there <- doesFileExist path + if there then removeFileSafe path else pure () + where + removeFileSafe p = do + _ <- try (System.Directory.removeFile p) :: IO (Either IOException ()) + pure () + +-- | 带自己的设置文件的用例组 +freshWorldAt :: FilePath -> String -> IO AppEnv +freshWorldAt staticDir name = do + env <- freshWorld staticDir + path <- tempSettingsPath name + removeIfExists path + uiPath <- tempUiSettingsPath name + removeIfExists uiPath + pure env{aeSettingsFile = path, aeUiSettingsFile = uiPath} + +-- | 临时静态目录,内容固定便于断言 +makeStaticDir :: IO FilePath +makeStaticDir = do + tmp <- getTemporaryDirectory + let dir = tmp "chusql-web-static-test" + createDirectoryIfMissing True dir + BS.writeFile (dir "index.html") "ChuSQL Console" + BS.writeFile (dir "app.js") "// test bundle\n" + BS.writeFile (dir "style.css") "body{}\n" + BS.writeFile (dir "secret.token") "do-not-serve\n" + pure dir + +-- | 一个用例组一套环境 +withWorld :: FilePath -> (AppEnv -> SpecWith ((), Application)) -> Spec +withWorld staticDir body = do + appEnv <- runIO (freshWorld staticDir) + with (webApp appEnv) (body appEnv) + +-- | 同上,但带着自己的设置文件(改设置/改口令的用例不互相串) +withWorldAt :: FilePath -> String -> (AppEnv -> SpecWith ((), Application)) -> Spec +withWorldAt staticDir name body = do + appEnv <- runIO (freshWorldAt staticDir name) + with (webApp appEnv) (body appEnv) + +-- | 响应体解成 JSON +jsonBody :: WT.SResponse -> A.Value +jsonBody res = fromMaybe A.Null (decode (WT.simpleBody res)) + +-- | 取字段 +field :: Text -> A.Value -> Maybe A.Value +field name (A.Object o) = KM.lookup (K.fromText name) o +field _ _ = Nothing + +-- | 取字段(没有给 Null) +at :: Text -> A.Value -> A.Value +at name value = fromMaybe A.Null (field name value) + +-- | 当数组看 +items :: A.Value -> [A.Value] +items (A.Array xs) = V.toList xs +items _ = [] + +-- | 当文本看 +asText :: A.Value -> Text +asText (A.String t) = t +asText _ = "" + +-- | 当整数看 +asInt :: A.Value -> Int +asInt (A.Number n) = round n +asInt _ = -1 + +-- | 第 i 个元素(越界给 Nothing,不用 `!!`) +nth :: Int -> [a] -> Maybe a +nth i xs = case drop i xs of + (x : _) -> Just x + [] -> Nothing + +-- | 取 Set-Cookie 里的会话令牌 +sessionCookieOf :: WT.SResponse -> Maybe Text +sessionCookieOf res = do + raw <- lookup "Set-Cookie" (WT.simpleHeaders res) + parseCookieHeader "chusql_session" (TE.decodeUtf8 raw) + +-- | 会话令牌变请求头 +cookieHeaders :: WT.SResponse -> [Header] +cookieHeaders res = case sessionCookieOf res of + Nothing -> [] + Just token -> [("Cookie", TE.encodeUtf8 ("chusql_session=" <> token))] + +-- | 带 JSON 体的请求 +jsonRequest :: Method -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse +jsonRequest httpMethod urlPath payload = + request httpMethod urlPath [("Content-Type", "application/json")] payload + +-- | 登录请求体 +loginBody :: Text -> Text -> BL.ByteString +loginBody user password = + BL.fromStrict . TE.encodeUtf8 $ + T.concat ["{\"user\":\"", user, "\",\"password\":\"", password, "\"}"] + +-- | 登录 +loginAs :: Text -> Text -> TestSession WT.SResponse +loginAs user password = jsonRequest methodPost "/api/login" (loginBody user password) + +-- | 带请求头的 GET +getWith :: [Header] -> BS.ByteString -> TestSession WT.SResponse +getWith headers path = request methodGet path headers "" + +-- | 带请求头的 JSON POST +postWith :: [Header] -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse +postWith headers path payload = + request methodPost path (("Content-Type", "application/json") : headers) payload + +-- | 带请求头的 JSON PUT(改设置用的是 PUT) +putWith :: [Header] -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse +putWith headers path payload = + request "PUT" path (("Content-Type", "application/json") : headers) payload + +-- | 在 items 数组里按键找一项(找不到给 Null) +headEntry :: Text -> [A.Value] -> A.Value +headEntry key entries = case [e | e <- entries, asText (at "key" e) == key] of + (e : _) -> e + [] -> A.Null + +-- | 纯断言抬进 WaiSession +check :: IO () -> TestSession () +check = liftIO + +-- | 已经拿到响应对象时的状态码断言 +hasStatus :: WT.SResponse -> Int -> IO () +hasStatus res code = statusCode (WT.simpleStatus res) `shouldBe` code + +-- | 临时设几个环境变量跑一段,跑完恢复原样(原来没有就删掉) +withEnv :: [(String, String)] -> IO a -> IO a +withEnv kvs act = do + saved <- mapM (\(k, _) -> fmap ((,) k) (lookupEnv k)) kvs + mapM_ (uncurry setEnv) kvs + act `finally` mapM_ restore saved + where + restore (k, mv) = maybe (unsetEnv k) (setEnv k) mv + +-- | 按 UTF-8 读文件(避免解码错) +readUtf8 :: FilePath -> IO Text +readUtf8 path = TE.decodeUtf8 <$> BS.readFile path + +-- | Either 判空 +isLeftE :: Either a b -> Bool +isLeftE (Left _) = True +isLeftE (Right _) = False + +-- | Either 判成 +isRightE :: Either a b -> Bool +isRightE = either (const False) (const True) + +-- | 响应体里第一行(表格数据) +firstRow :: WT.SResponse -> [A.Value] +firstRow res = case items (at "rows" (jsonBody res)) of + (r : _) -> case r of + A.Array xs -> V.toList xs + _ -> [] + [] -> [] + +-- | 数组字段的长度 +arrayLen :: A.Value -> Int +arrayLen = length . items + +main :: IO () +main = hspec spec + +spec :: Spec +spec = do + staticDir <- runIO makeStaticDir + unitSpec + actionsSpec + staticSpec staticDir + frontendBundleSpec + authSpec staticDir + catalogSpec staticDir + limitsSpec staticDir + oneClickSpec staticDir + sqlSpec staticDir + settingsSpec staticDir + uiSettingsSpec staticDir + sortSpec staticDir + securitySpec staticDir + e2eSpec staticDir + +unitSpec :: Spec +unitSpec = do + describe "Password hashing" $ do + it "accepts the correct password" $ + verifyPassword (hashPasswordWith 1000 (BS.replicate 16 1) "s3cret") "s3cret" `shouldBe` True + it "rejects a wrong password" $ + verifyPassword (hashPasswordWith 1000 (BS.replicate 16 1) "s3cret") "wrong" `shouldBe` False + it "treats a broken encoded hash as mismatch instead of throwing" $ + verifyPassword "garbage" "s3cret" `shouldBe` False + it "same password with different salts yields different hashes" $ + hashPasswordWith 1000 (BS.replicate 16 1) "s3cret" + `shouldNotBe` hashPasswordWith 1000 (BS.replicate 16 2) "s3cret" + it "encoded hash has four parts: algo$iterations$salt$hash" $ + T.splitOn "$" (hashPasswordWith 1000 (BS.replicate 16 3) "s3cret") + `shouldSatisfy` ((== 4) . length) + + describe "Sessions" $ do + it "token is 64 hex chars and differs between calls" $ do + a <- sessionToken + b <- sessionToken + T.length a `shouldBe` 64 + T.all (`elem` ("0123456789abcdef" :: String)) a `shouldBe` True + a `shouldNotBe` b + it "idle timeout drops the session" $ do + clock <- newIORef epoch + store <- newSessionStore (readIORef clock) policy + token <- createSession store "admin" + fresh <- lookupSession store token + fresh `shouldBe` Just "admin" + writeIORef clock (addUTCTime (9 * 3600) epoch) + stale <- lookupSession store token + stale `shouldBe` Nothing + it "activity refreshes the idle timer but the absolute limit still expires" $ do + clock <- newIORef epoch + store <- newSessionStore (readIORef clock) policy + token <- createSession store "admin" + writeIORef clock (addUTCTime (7 * 3600) epoch) + seen <- lookupSession store token + seen `shouldBe` Just "admin" + writeIORef clock (addUTCTime (13 * 3600) epoch) + seenAgain <- lookupSession store token + seenAgain `shouldBe` Just "admin" + writeIORef clock (addUTCTime (25 * 3600) epoch) + gone <- lookupSession store token + gone `shouldBe` Nothing + it "deleted session is no longer found" $ do + clock <- newIORef epoch + store <- newSessionStore (readIORef clock) policy + token <- createSession store "admin" + deleteSession store token + found <- lookupSession store token + found `shouldBe` Nothing + it "a new expiry policy takes effect immediately (settings hot reload path)" $ do + clock <- newIORef epoch + store <- newSessionStore (readIORef clock) (SessionPolicy (8 * 3600) (24 * 3600)) + token <- createSession store "admin" + setSessionPolicy store (SessionPolicy 10 20) + now <- sessionPolicy store + spIdleSeconds now `shouldBe` 10 + writeIORef clock (addUTCTime 11 epoch) + gone <- lookupSession store token + gone `shouldBe` Nothing + it "password change drops other sessions but keeps the current one" $ do + clock <- newIORef epoch + store <- newSessionStore (readIORef clock) policy + keep <- createSession store "admin" + other <- createSession store "admin" + removed <- deleteOtherSessions store keep + kept <- lookupSession store keep + dropped <- lookupSession store other + removed `shouldBe` 1 + kept `shouldBe` Just "admin" + dropped `shouldBe` Nothing + it "no other sessions means zero removed (visible in logs)" $ do + clock <- newIORef epoch + store <- newSessionStore (readIORef clock) policy + keep <- createSession store "admin" + removed <- deleteOtherSessions store keep + removed `shouldBe` 0 + stillThere <- lookupSession store keep + stillThere `shouldBe` Just "admin" + + describe "Login rate limiting" $ do + it "below the limit is not blocked" $ do + clock <- newIORef epoch + rl <- newRateLimiter (readIORef clock) 3 60 + rateLimitRecord rl "admin" + rateLimitRecord rl "admin" + open <- rateLimitBlock rl "admin" + open `shouldBe` Nothing + it "hitting the limit blocks and returns seconds to wait" $ do + clock <- newIORef epoch + rl <- newRateLimiter (readIORef clock) 3 60 + mapM_ (const (rateLimitRecord rl "admin")) [1 :: Int, 2, 3] + blocked <- rateLimitBlock rl "admin" + blocked `shouldSatisfy` maybe False (> 0) + it "window expiry allows retries again" $ do + clock <- newIORef epoch + rl <- newRateLimiter (readIORef clock) 3 60 + mapM_ (const (rateLimitRecord rl "admin")) [1 :: Int, 2, 3] + writeIORef clock (addUTCTime 61 epoch) + open <- rateLimitBlock rl "admin" + open `shouldBe` Nothing + it "raising the threshold unlocks immediately (settings hot reload)" $ do + clock <- newIORef epoch + rl <- newRateLimiter (readIORef clock) 3 60 + mapM_ (const (rateLimitRecord rl "admin")) [1 :: Int, 2, 3] + blocked <- rateLimitBlock rl "admin" + blocked `shouldSatisfy` maybe False (> 0) + setRateLimit rl 10 60 + unlocked <- rateLimitBlock rl "admin" + unlocked `shouldBe` Nothing + it "shortening the window allows immediately (settings hot reload)" $ do + clock <- newIORef epoch + rl <- newRateLimiter (readIORef clock) 3 3600 + mapM_ (const (rateLimitRecord rl "admin")) [1 :: Int, 2, 3] + setRateLimit rl 3 1 + writeIORef clock (addUTCTime 2 epoch) + open <- rateLimitBlock rl "admin" + open `shouldBe` Nothing + + describe "Cookie parsing" $ do + it "picks the session token out of many cookies" $ + parseCookieHeader "chusql_session" "a=1; chusql_session=abc123; b=2" `shouldBe` Just "abc123" + it "missing key yields Nothing" $ + parseCookieHeader "chusql_session" "a=1; b=2" `shouldBe` Nothing + it "empty value does not count as a value" $ + parseCookieHeader "chusql_session" "chusql_session=" `shouldBe` Nothing + + describe "Password hash validation (checked before changing the password)" $ do + it "self-computed hash is valid" $ + hashLooksValid (hashPasswordWith 1000 (BS.replicate 16 5) "s3cret") `shouldBe` True + it "broken hashes are invalid (bad length/algo/parts)" $ do + hashLooksValid "garbage" `shouldBe` False + hashLooksValid "pbkdf2-sha256$1000$00$00" `shouldBe` False + hashLooksValid "md5$1000$00$00" `shouldBe` False + + describe "Settings catalogue (shared by the settings page and the server)" $ do + it "keys are unique and kind/default are filled" $ do + let keys = map siKey settingCatalogue + length keys `shouldBe` length (nub keys) + all (not . T.null . siKey) settingCatalogue `shouldBe` True + all (not . T.null . siLabel) settingCatalogue `shouldBe` True + all (\i -> siKind i `elem` ["text", "int", "bool", "secret"]) settingCatalogue `shouldBe` True + let machineReadable = [i | i <- settingCatalogue, siKind i `elem` ["int", "bool"]] + machineReadable `shouldSatisfy` (not . null) + all (not . T.null . siDefault) machineReadable `shouldBe` True + [siDefault i | i <- settingCatalogue, siKind i == "secret"] `shouldSatisfy` all T.null + let envs = map siEnv settingCatalogue + all (not . T.null) envs `shouldBe` True + length envs `shouldBe` length (nub envs) + it "every item has an env var and can be found by key" $ do + all (not . T.null . siEnv) settingCatalogue `shouldBe` True + map (fmap siKey . findItem . siKey) settingCatalogue `shouldBe` map (Just . siKey) settingCatalogue + it "critical settings are root-only, tuning ones are not" $ do + isRootOnly "port" `shouldBe` True + isRootOnly "host" `shouldBe` True + isRootOnly "user" `shouldBe` True + isRootOnly "password-hash" `shouldBe` True + isRootOnly "rows-per-page" `shouldBe` False + isRootOnly "body-limit" `shouldBe` False + it "live keys exist in the catalogue and are all integers" $ do + liveKeys `shouldNotSatisfy` null + map findItem liveKeys `shouldSatisfy` all (/= Nothing) + [siKind i | Just i <- map findItem liveKeys] `shouldSatisfy` all (== "int") + it "restart and live flags do not conflict: port needs a restart, page size does not" $ do + isRestartRequired "port" `shouldBe` True + isRestartRequired "static-dir" `shouldBe` True + isRestartRequired "rows-per-page" `shouldBe` False + filter isRestartRequired liveKeys `shouldBe` [] + it "defaults are found, an unknown key gives an empty string" $ do + defaultOf "port" `shouldBe` "7777" + defaultOf "rows-per-page" `shouldBe` "25" + defaultOf "nope" `shouldBe` "" + it "the flattened startup config carries the real port/account" $ do + let flat = effectiveSettings defaultWebConfig + Map.lookup "port" flat `shouldBe` Just "7777" + Map.lookup "user" flat `shouldBe` Just defaultUser + Map.lookup "rows-per-page" flat `shouldBe` Just "25" + Map.member "password" flat `shouldBe` False + Map.member "password-hash" flat `shouldBe` False + it "merge settings: known keys applied, unknown rejected, empty clears" $ do + let merged = applySettings Map.empty (Map.fromList [("rows-per-page", "50"), ("port", "9000")]) + fmap (Map.lookup "rows-per-page") merged `shouldBe` Right (Just "50") + applySettings Map.empty (Map.fromList [("nope", "1")]) `shouldSatisfy` isLeftE + fmap (Map.lookup "port") (applySettings (Map.fromList [("port", "9000")]) (Map.fromList [("port", "")])) + `shouldBe` Right (Just "") + it "type validation: ints reject letters, bools accept only known spellings" $ do + applySettings Map.empty (Map.fromList [("rows-per-page", "abc")]) `shouldSatisfy` isLeftE + applySettings Map.empty (Map.fromList [("rows-per-page", "10")]) `shouldSatisfy` isRightE + applySettings Map.empty (Map.fromList [("seed", "maybe")]) `shouldSatisfy` isLeftE + applySettings Map.empty (Map.fromList [("seed", "on")]) `shouldSatisfy` isRightE + it "write settings file: empty values are not stored and reads come back" $ do + tmp <- getTemporaryDirectory + let path = tmp "chusql-web-test-write-settings.json" + removeIfExists path + written <- writeSettingsFile path (Map.fromList [("port", "9000"), ("host", "")]) + written `shouldBe` Right () + stored <- readSettingsFile path + Map.lookup "port" stored `shouldBe` Just "9000" + Map.member "host" stored `shouldBe` False + it "write settings file: reports failure when the directory cannot be created" $ do + tmp <- getTemporaryDirectory + let blocker = tmp "chusql-web-test-blocker" + removeIfExists blocker + BS.writeFile blocker "not a directory" + failed <- writeSettingsFile (blocker "settings.json") (Map.fromList [("port", "1")]) + failed `shouldSatisfy` isLeftE + removeIfExists blocker + it "an unreadable file counts as empty config (not a crash)" $ do + tmp <- getTemporaryDirectory + missing <- readSettingsFile (tmp "chusql-web-test-does-not-exist.json") + missing `shouldBe` Map.empty + it "settings file lookup: uses an existing file" $ do + tmp <- getTemporaryDirectory + let web = tmp "chusql-web-test-resolve" + createDirectoryIfMissing True (web "script") + BS.writeFile (web "script" "chusql.settings.json") "{\"port\":\"9000\"}" + System.Directory.withCurrentDirectory web $ do + found <- resolveSettingsFile + found `shouldBe` ("script" "chusql.settings.json") + it "settings file lookup: picks the candidate in an existing dir when none exists" $ do + tmp <- getTemporaryDirectory + let root = tmp "chusql-web-test-resolve-root" + let web = root "chusql-web" + createDirectoryIfMissing True (root "script") + createDirectoryIfMissing True web + System.Directory.withCurrentDirectory web $ do + found <- resolveSettingsFile + found `shouldBe` (".." "script" "chusql.settings.json") + + describe "Browsing SELECTs (column header click / WHERE filter cells)" $ do + it "no sort and no filter yields a plain SELECT" $ + selectRowsSql "users" Nothing [] `shouldBe` Right "SELECT * FROM users" + it "column and direction produce ORDER BY" $ do + selectRowsSql "users" (Just ("age", True)) [] `shouldBe` Right "SELECT * FROM users ORDER BY age ASC" + selectRowsSql "users" (Just ("age", False)) [] `shouldBe` Right "SELECT * FROM users ORDER BY age DESC" + it "equality filters become WHERE with AND, placed before ORDER BY" $ do + selectRowsSql "users" Nothing [("name", VStr "Ann"), ("age", VInt 30)] + `shouldBe` Right "SELECT * FROM users WHERE name = 'Ann' AND age = 30" + selectRowsSql "users" (Just ("age", False)) [("name", VStr "O'Brien")] + `shouldBe` Right "SELECT * FROM users WHERE name = 'O''Brien' ORDER BY age DESC" + selectRowsSql "users" Nothing [("vip", VBool True)] + `shouldBe` Right "SELECT * FROM users WHERE vip = TRUE" + it "table and column names go through the whitelist (including filter columns)" $ do + selectRowsSql "users; DROP TABLE users" Nothing [] `shouldSatisfy` isLeftE + selectRowsSql "users" (Just ("age; DROP TABLE users", True)) [] `shouldSatisfy` isLeftE + selectRowsSql "users" Nothing [("age; DROP TABLE users", VInt 1)] `shouldSatisfy` isLeftE + + describe "Identifier whitelist" $ do + it "normal table names pass" $ do + isIdentifier "users" `shouldBe` True + isIdentifier "user_1" `shouldBe` True + isIdentifier "_tmp" `shouldBe` True + it "leading digit / space / semicolon / empty string are rejected" $ do + isIdentifier "1users" `shouldBe` False + isIdentifier "user name" `shouldBe` False + isIdentifier "users;DROP TABLE users" `shouldBe` False + isIdentifier "" `shouldBe` False + it "over 64 characters is rejected" $ + isIdentifier (T.replicate 65 "a") `shouldBe` False + + describe "Static file name whitelist" $ do + it "normal file names pass" $ do + safeRelative "app.js" `shouldBe` Just "app.js" + safeRelative "style.css" `shouldBe` Just "style.css" + safeRelative "codicon.ttf" `shouldBe` Just "codicon.ttf" + safeRelative "editor.worker-a1.js" `shouldBe` Just "editor.worker-a1.js" + it "traversal / subdirectory / empty string are all rejected" $ do + safeRelative "../secret" `shouldBe` Nothing + safeRelative "sub/app.js" `shouldBe` Nothing + safeRelative "a\\b" `shouldBe` Nothing + safeRelative "" `shouldBe` Nothing + safeRelative "a b.js" `shouldBe` Nothing + it "disallowed extensions fail (.token / .db / no extension)" $ do + safeRelative "secret.token" `shouldBe` Nothing + safeRelative "catalog.json" `shouldBe` Nothing + safeRelative "data.db" `shouldBe` Nothing + safeRelative "README" `shouldBe` Nothing + + describe "Content-Type" $ do + it "chosen by extension (case-insensitive)" $ do + contentTypeOf "index.html" `shouldBe` "text/html; charset=utf-8" + contentTypeOf "app.JS" `shouldBe` "text/javascript; charset=utf-8" + contentTypeOf "style.css" `shouldBe` "text/css; charset=utf-8" + contentTypeOf "codicon.ttf" `shouldBe` "font/ttf" + contentTypeOf "x.bin" `shouldBe` "application/octet-stream" + + describe "Column inference (headers even for empty results)" $ do + it "SELECT * expands to the table columns" $ + columnsFromStatement testDb (selectStar (FromTable Nothing "users")) + `shouldBe` ["id", "name", "age"] + it "aliased SELECT * prefixes the alias" $ + columnsFromStatement testDb (selectStar (FromTable (Just "u") "users")) + `shouldBe` ["u.id", "u.name", "u.age"] + it "JOIN SELECT * puts the left table first" $ + columnsFromStatement + testDb + (selectStar (FromJoin (FromTable Nothing "users") (Just "o") "orders" (Eq (Col "id") (Col "user_id")))) + `shouldBe` ["id", "name", "age", "o.id", "o.user_id"] + it "explicit column names are kept as-is" $ + columnsFromStatement testDb (selectThese ["name"] (FromTable Nothing "users")) `shouldBe` ["name"] + it "write statements have no result columns" $ + columnsFromStatement testDb (DropTable "users") `shouldBe` [] + + describe "Row rendering" $ do + it "values follow the given column order, missing columns give null" $ + renderRow ["name", "id", "age"] [("id", VInt 1), ("name", VStr "a")] + `shouldBe` [A.String "a", A.Number 1, A.Null] + + describe "In-memory backend catalogue" $ do + it "row count, columns and stats all match" $ do + case lookup "users" testDb of + Nothing -> expectationFailure "testDb has no users" + Just tbl -> do + let info = tableInfoOf ("users", tbl) + tiTable info `shouldBe` "users" + tiRows info `shouldBe` 5 + map scName (tiColumns info) `shouldBe` ["id", "name", "age"] + tiStats info `shouldBe` [("id", 5, False), ("name", 5, False), ("age", 5, False)] + + describe "Default account and credential source" $ do + it "the default account is root and uses the built-in demo password" $ do + let cfg = defaultWebConfig + defaultUser `shouldBe` "root" + wcUser cfg `shouldBe` "root" + usingDefaultCredentials cfg `shouldBe` True + cred <- resolveCredential cfg + credUser cred `shouldBe` defaultUser + verifyPassword (credEncoded cred) defaultPassword `shouldBe` True + it "a plain password wins and the default stops working" $ do + let cfg = defaultWebConfig{wcPassword = Just "s3cret"} + usingDefaultCredentials cfg `shouldBe` False + cred <- resolveCredential cfg + verifyPassword (credEncoded cred) "s3cret" `shouldBe` True + verifyPassword (credEncoded cred) defaultPassword `shouldBe` False + it "a provided hash is used directly (not re-hashed)" $ do + let encoded = hashPasswordWith 1000 (BS.replicate 16 9) "from-hash" + cred <- resolveCredential defaultWebConfig{wcPasswordHash = Just encoded} + credEncoded cred `shouldBe` encoded + it "the account name can be changed" $ do + cred <- resolveCredential defaultWebConfig{wcUser = "tester"} + credUser cred `shouldBe` "tester" + + describe "Config: built-in defaults" $ do + it "port defaults to 7777, account defaults to admin/chusql" $ do + wcPort defaultWebConfig `shouldBe` 7777 + wcHost defaultWebConfig `shouldBe` "127.0.0.1" + wcUser defaultWebConfig `shouldBe` defaultUser + wcCookieSecure defaultWebConfig `shouldBe` False + it "session / rate limit / paging / row limits all have explicit defaults" $ do + wcSessionIdle defaultWebConfig `shouldBe` 8 * 3600 + wcSessionMax defaultWebConfig `shouldBe` 24 * 3600 + wcLoginMaxAttempts defaultWebConfig `shouldBe` 5 + wcLoginWindow defaultWebConfig `shouldBe` 5 * 60 + wcPageSize defaultWebConfig `shouldBe` 25 + wcMaxPageSize defaultWebConfig `shouldBe` 500 + wcMaxRows defaultWebConfig `shouldBe` 1000 + wcMaxSqlLength defaultWebConfig `shouldBe` 20000 + wcBodyLimit defaultWebConfig `shouldBe` 65536 + + describe "Config: environment variables override defaults" $ do + it "port / host / static dir / account" $ do + withEnv + [ ("CHUSQL_WEB_PORT", "8123") + , ("CHUSQL_WEB_HOST", "0.0.0.0") + , ("CHUSQL_WEB_STATIC", "mysite") + , ("CHUSQL_WEB_USER", "tester") + ] + $ do + cfg <- loadWebConfig + wcPort cfg `shouldBe` 8123 + wcHost cfg `shouldBe` "0.0.0.0" + wcStaticDir cfg `shouldBe` "mysite" + wcUser cfg `shouldBe` "tester" + it "session / rate limit / paging / rows / body limit" $ do + withEnv + [ ("CHUSQL_WEB_SESSION_IDLE", "60") + , ("CHUSQL_WEB_SESSION_MAX", "120") + , ("CHUSQL_WEB_LOGIN_MAX_ATTEMPTS", "3") + , ("CHUSQL_WEB_LOGIN_WINDOW", "30") + , ("CHUSQL_WEB_PAGE_SIZE", "7") + , ("CHUSQL_WEB_MAX_PAGE_SIZE", "70") + , ("CHUSQL_WEB_MAX_ROWS", "9") + , ("CHUSQL_WEB_MAX_SQL_LENGTH", "1234") + , ("CHUSQL_WEB_BODY_LIMIT", "2048") + , ("CHUSQL_WEB_COOKIE_SECURE", "1") + ] + $ do + cfg <- loadWebConfig + wcSessionIdle cfg `shouldBe` 60 + wcSessionMax cfg `shouldBe` 120 + wcLoginMaxAttempts cfg `shouldBe` 3 + wcLoginWindow cfg `shouldBe` 30 + wcPageSize cfg `shouldBe` 7 + wcMaxPageSize cfg `shouldBe` 70 + wcMaxRows cfg `shouldBe` 9 + wcMaxSqlLength cfg `shouldBe` 1234 + wcBodyLimit cfg `shouldBe` 2048 + wcCookieSecure cfg `shouldBe` True + it "unsetting the env var restores the default port 7777" $ do + withEnv [("CHUSQL_WEB_PORT", "8123")] $ do + tuned <- loadWebConfig + wcPort tuned `shouldBe` 8123 + restored <- loadWebConfig + wcPort restored `shouldBe` 7777 + + describe "Launcher script (script/chusql.ps1)" $ do + it "script/ has chusql.ps1 / chusql.cmd, default port 7777, covering all three modules" $ do + script <- readUtf8 (".." "script" "chusql.ps1") + batch <- readUtf8 (".." "script" "chusql.cmd") + batch `shouldSatisfy` T.isInfixOf "chusql.ps1" + script `shouldSatisfy` T.isInfixOf "chusql-storage" + script `shouldSatisfy` T.isInfixOf "chusql-web.exe" + script `shouldSatisfy` T.isInfixOf "/api/status" + script `shouldSatisfy` T.isInfixOf "7777" + it "has web / cli / config commands, cli is a placeholder (dev notice + still starts web)" $ do + script <- readUtf8 (".." "script" "chusql.ps1") + script `shouldSatisfy` T.isInfixOf "CLI interface is under development." + script `shouldSatisfy` T.isInfixOf "'web'" + script `shouldSatisfy` T.isInfixOf "'cli'" + script `shouldSatisfy` T.isInfixOf "config set" + it "settings persist to a file (config set/unset/reset + precedence note)" $ do + script <- readUtf8 (".." "script" "chusql.ps1") + script `shouldSatisfy` T.isInfixOf "chusql.settings.json" + script `shouldSatisfy` T.isInfixOf "command line > settings file > environment > default" + script `shouldSatisfy` T.isInfixOf "Save-SavedSettings" + it "child window hidden + logs flow back to the launcher window" $ do + script <- readUtf8 (".." "script" "chusql.ps1") + script `shouldSatisfy` T.isInfixOf "-WindowStyle Hidden" + script `shouldSatisfy` T.isInfixOf "Show-NewLogLines" + script `shouldSatisfy` T.isInfixOf "RedirectStandardOutput" + it "the script exposes settings as switches (port/session/paging/storage tuning)" $ do + script <- readUtf8 (".." "script" "chusql.ps1") + script `shouldSatisfy` T.isInfixOf "CHUSQL_WEB_SESSION_IDLE" + script `shouldSatisfy` T.isInfixOf "CHUSQL_WEB_MAX_ROWS" + script `shouldSatisfy` T.isInfixOf "CHUSQL_BUFFER_POOL_SIZE" + script `shouldSatisfy` T.isInfixOf "CHUSQL_PAGE_SIZE" + it "the Rust artifact is named chusql-storage (no longer server)" $ do + script <- readUtf8 (".." "script" "chusql.ps1") + script `shouldSatisfy` T.isInfixOf "target\\release\\chusql-storage.exe" + script `shouldSatisfy` (not . T.isInfixOf "target\\release\\server.exe") + +staticSpec :: FilePath -> Spec +staticSpec staticDir = describe "Static files" $ do + it "reads index.html from the temp dir" $ do + content <- readStatic staticDir "index.html" + content `shouldSatisfy` maybe False (not . BS.null) + it "an unreadable file gives Nothing" $ + readStatic staticDir "nope.js" `shouldReturn` Nothing + it "a traversal path is unreadable" $ + readStatic staticDir "../secret.token" `shouldReturn` Nothing + +frontendBundleSpec :: Spec +frontendBundleSpec = describe "React frontend build artifacts" $ do + present <- runIO (doesDirectoryExist "static") + let asset name = "static" name + readAsset name = readUtf8 (asset name) + -- | static/ 是构建产物,没构建就跳过 + check body + | present = body + | otherwise = pendingWith "static/ is not built; run npm run build under chusql-web/frontend" + it "entry HTML exists and is non-empty" $ check $ do + html <- BS.readFile (asset "index.html") + html `shouldSatisfy` (not . BS.null) + it "entry declares the Chinese page language" $ check $ do + html <- readAsset "index.html" + html `shouldSatisfy` T.isInfixOf "lang=\"zh-CN\"" + it "entry loads the app script only from /static/" $ check $ do + html <- readAsset "index.html" + html `shouldSatisfy` T.isInfixOf "src=\"/static/app.js\"" + it "entry loads styles only from /static/" $ check $ do + html <- readAsset "index.html" + html `shouldSatisfy` T.isInfixOf "href=\"/static/style.css\"" + it "entry has no inline script" $ check $ do + html <- readAsset "index.html" + html `shouldSatisfy` (not . T.isInfixOf "