大更新:

1、完善SQL语法
2、移除node前端,只保留静态资源
3、写好安装脚本
4、做好linux适配
This commit is contained in:
LIm1tZz7 committed 2026-10-01 20:48:38 +08:00
1 parent 687c012841
commit df6d348995
166 files changed
+16317 -10763

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
# Linux 流水线:Rust 存储 + Haskell 引擎/Web/CLI 的构建与测试。
#
# 这里每条命令都在 WSL Debian(cargo 1.98 / GHC 9.10.3 / stack 3.11.1)上跑通过,
# 见仓库根 README 的「构建与测试」段。IPC 用例需要 chusql-storage 可执行文件,
# 所以 Rust 先构建,Haskell 的 engine/web 用例才能真跑而不是 pending。
# 打包和发版资源走 .github/workflows/release.yml(tag 触发,也可手动 dispatch)。
name: linux
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: linux-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
jobs:
linux:
name: build + test
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
# ---------- Rust 存储 ----------
- name: Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
chusql-storage/target
key: ${{ runner.os }}-cargo-${{ hashFiles('chusql-storage/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: cargo build (release)
working-directory: chusql-storage
run: cargo build --release
- name: cargo build (debug, IPC 用例要它)
working-directory: chusql-storage
run: cargo build --bin chusql-storage
- name: cargo test
working-directory: chusql-storage
run: cargo test --release
- name: cargo clippy (-D warnings)
working-directory: chusql-storage
run: cargo clippy --all-targets -- -D warnings
# 故意不做 rustfmt 检查:仓库里没有 rustfmt.toml,历史代码也不是按当前 rustfmt 的
# 默认风格排的,直接跑会报上百处折行与 use 排序差异;格式化不是本仓的门禁,别拿它
# 当交付标准。
# ---------- Haskell ----------
- name: Haskell toolchain
uses: haskell-actions/setup@v2
with:
ghc-version: '9.10.3'
enable-stack: true
stack-version: '3.11.1'
- name: Cache stack
uses: actions/cache@v4
with:
path: |
~/.stack
chusql-engine/.stack-work
chusql-web/.stack-work
chusql-cli/.stack-work
key: ${{ runner.os }}-stack-${{ hashFiles('**/stack.yaml', '**/stack.yaml.lock') }}
restore-keys: ${{ runner.os }}-stack-
- name: stack build (cabal 里带 -Wall)
working-directory: chusql-cli
run: stack build --fast
- name: engine tests
working-directory: chusql-engine
run: stack test --fast
- name: web tests
working-directory: chusql-web
run: stack test --fast
- name: cli tests
working-directory: chusql-cli
run: stack test chusql-cli --fast
+221
View File
@@ -0,0 +1,221 @@
# 发版流水线:打 tag(v*)时为四个平台各出 web / cli 两个发行包,附 sha256,挂到 Release。
#
# 资源命名(install.sh 按 <os>-<arch> 挑资源,见 scripts/install.sh):
# chusql-web-linux-x86_64.tar.gz chusql-cli-linux-x86_64.tar.gz
# chusql-web-windows-x86_64.zip chusql-cli-windows-x86_64.zip
# chusql-web-macos-x86_64.tar.gz chusql-cli-macos-x86_64.tar.gz
# chusql-web-macos-arm64.tar.gz chusql-cli-macos-arm64.tar.gz
#
# 装的人拿到的就是编好的二进制,不需要 GHC / Rust:
# curl -fsSL https://raw.githubusercontent.com/TheBadRoger/ChuSQL/main/scripts/install.sh \
# | sh -s -- --component web
# 没有对应平台的资源时,同一个脚本可以退回本地编译:
# sh scripts/install.sh --component web --from-source
#
# 说明:macOS 两个 runner 会让总时长变长(每个 job 都要 cargo + stack 各编一遍);
# 不需要 Intel mac 的包就把 macos-13 那两行删掉。
name: release
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
tag:
description: 'tag to build and publish (must already exist)'
required: true
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
jobs:
build:
name: ${{ matrix.platform }} / ${{ matrix.component }}
runs-on: ${{ matrix.os }}
timeout-minutes: 120
strategy:
fail-fast: false
matrix:
include:
- { os: ubuntu-latest, platform: linux-x86_64 }
- { os: windows-latest, platform: windows-x86_64 }
- { os: macos-13, platform: macos-x86_64 }
- { os: macos-14, platform: macos-arm64 }
component: [web, cli]
steps:
- uses: actions/checkout@v4
# ---------- 工具链 ----------
- name: Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
chusql-storage/target
key: ${{ runner.os }}-${{ matrix.platform }}-cargo-${{ hashFiles('chusql-storage/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: Haskell toolchain
uses: haskell-actions/setup@v2
with:
ghc-version: '9.10.3'
enable-stack: true
stack-version: '3.11.1'
- name: Cache stack
uses: actions/cache@v4
with:
path: |
~/.stack
chusql-engine/.stack-work
chusql-web/.stack-work
chusql-cli/.stack-work
key: ${{ runner.os }}-${{ matrix.platform }}-stack-${{ hashFiles('**/stack.yaml', '**/stack.yaml.lock') }}
restore-keys: ${{ runner.os }}-stack-
# ---------- 构建 ----------
- name: build storage (Rust)
working-directory: chusql-storage
run: cargo build --release
- name: build web front end
if: matrix.component == 'web'
working-directory: chusql-cli
run: stack build --fast chusql-web:exe:chusql-web
- name: build cli
if: matrix.component == 'cli'
working-directory: chusql-cli
run: stack build --fast chusql-cli:exe:csql
# ---------- 装配 + 打归档(原来放在一个独立的打包脚本里,那个脚本已经删掉)----------
# 归档里是包内容本身(bin/、scripts/、static/、install.sh 就在根上),安装脚本按这个布局找文件。
- name: package (Unix)
if: runner.os != 'Windows'
shell: bash
run: |
set -eu
component='${{ matrix.component }}'
name="chusql-$component-${{ matrix.platform }}"
stage="dist/$name"
rm -rf "$stage"
mkdir -p "$stage/bin" "$stage/scripts"
cp chusql-storage/target/release/chusql-storage "$stage/bin/"
stack_root="$(cd chusql-cli && stack path --local-install-root | tail -n 1 | tr -d '\r')"
if [ "$component" = web ]; then front_name=chusql-web; else front_name=csql; fi
front="$stack_root/bin/$front_name"
if [ ! -f "$front" ]; then
front="$(find chusql-web/.stack-work chusql-cli/.stack-work -name "$front_name" -type f 2>/dev/null | head -n 1 || true)"
fi
[ -n "$front" ] && [ -f "$front" ] || { echo "front end binary not found: $front_name" >&2; exit 1; }
cp "$front" "$stage/bin/"
# Haskell 一般静态链接,靶目录里真有共享库就一起带上
for lib in chusql-storage/target/release/*.so chusql-storage/target/release/*.dylib; do
[ -f "$lib" ] && cp "$lib" "$stage/bin/"
done
cp scripts/chusql.toml scripts/init.sql "$stage/scripts/"
cp scripts/install.ps1 scripts/install.sh "$stage/"
if [ "$component" = web ]; then
mkdir -p "$stage/static"
cp -R chusql-web/static/. "$stage/static/"
cp scripts/csql-web.sh "$stage/"
if [ -f scripts/csql-web.ps1 ]; then cp scripts/csql-web.ps1 "$stage/"; fi
fi
chmod +x "$stage/install.sh"
if [ -f "$stage/csql-web.sh" ]; then chmod +x "$stage/csql-web.sh"; fi
mkdir -p dist
tar -czf "dist/$name.tar.gz" -C "$stage" .
rm -rf "$stage"
ls -l "dist/$name.tar.gz"
- name: package (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$component = '${{ matrix.component }}'
$name = "chusql-$component-${{ matrix.platform }}"
$stage = "dist/$name"
if (Test-Path $stage) { Remove-Item -Recurse -Force $stage }
New-Item -ItemType Directory -Force -Path "$stage/bin", "$stage/scripts" | Out-Null
Copy-Item 'chusql-storage/target/release/chusql-storage.exe' "$stage/bin/"
$stackRoot = (& stack path --local-install-root | Select-Object -Last 1).Trim()
$frontName = if ($component -eq 'web') { 'chusql-web.exe' } else { 'csql.exe' }
$front = Join-Path $stackRoot "bin/$frontName"
if (-not (Test-Path $front)) {
$front = Get-ChildItem -Path 'chusql-web/.stack-work', 'chusql-cli/.stack-work' -Recurse -Filter $frontName -File -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName
}
if (-not $front -or -not (Test-Path $front)) { throw "front end binary not found: $frontName" }
Copy-Item $front "$stage/bin/"
Get-ChildItem 'chusql-storage/target/release' -Filter '*.dll' -File -ErrorAction SilentlyContinue |
ForEach-Object { Copy-Item $_.FullName "$stage/bin/" }
Copy-Item 'scripts/chusql.toml', 'scripts/init.sql' "$stage/scripts/"
Copy-Item 'scripts/install.ps1', 'scripts/install.sh' $stage
if ($component -eq 'web') {
New-Item -ItemType Directory -Force -Path "$stage/static" | Out-Null
Copy-Item 'chusql-web/static/*' "$stage/static" -Recurse
Copy-Item 'scripts/csql-web.sh' $stage
if (Test-Path 'scripts/csql-web.ps1') { Copy-Item 'scripts/csql-web.ps1' $stage }
}
New-Item -ItemType Directory -Force -Path dist | Out-Null
Compress-Archive -Path "$stage/*" -DestinationPath "dist/$name.zip" -Force
Remove-Item -Recurse -Force $stage
Get-Item "dist/$name.zip" | Select-Object Name, Length | Format-Table
- name: checksum
shell: pwsh
run: |
Get-ChildItem dist -File | Where-Object { $_.Name -match '\.(zip|tar\.gz)$' } | ForEach-Object {
$hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower()
"$hash $($_.Name)" | Set-Content -NoNewline "$($_.FullName).sha256"
Write-Host "$($_.Name) $hash"
}
- name: upload
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.platform }}-${{ matrix.component }}
path: |
dist/*.zip
dist/*.tar.gz
dist/*.sha256
if-no-files-found: error
publish:
name: publish release
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: attach assets to the release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag || github.ref_name }}
run: |
set -eu
ls -l dist
gh release view "$TAG" >/dev/null 2>&1 || gh release create "$TAG" --title "ChuSQL $TAG" --generate-notes
gh release upload "$TAG" dist/* --clobber
+13 -21
View File
@@ -2,7 +2,7 @@
# ChuSQL 单仓库 .gitignore
# chusql-engine/ Haskell (Stack + Cabal)
# chusql-storage/ Rust (Cargo)
# chusql-web/ 前端 (Node / TypeScript)
# chusql-web/ 前端 (手写 HTML / CSS / JS,无 Node 工具链)
# ============================================================
# ------------------------------------------------------------
@@ -38,33 +38,19 @@ 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
# 前端 IDE 设置(字体等,由 /api/ui-settings 读写),本机专用,不入库
scripts/chusql.ui.settings.json
# Cargo.lock 要入库(本项目是要产出可执行程序的 crate,不是纯库)
# ------------------------------------------------------------
# 前端 / Node —— chusql-web
# 前端 —— chusql-web/static 是手写的源文件,必须入库
# ------------------------------------------------------------
node_modules/
.pnpm-store/
build/
.next/
.nuxt/
.vite/
.npm-cache/
.angular/
coverage/
*.tsbuildinfo
# 前端构建产物:vite outDir 指向 ../static,由 npm run build 生成,不入库
chusql-web/static/
# 这里曾经放着 node_modules / .vite / *.tsbuildinfo 等 Node 工具链规则;
# 前端已改为纯 HTML + CSS + JS 静态资源,不再需要它们。
# 环境变量:只提交 .env.example,本地实际配置不入库
.env
@@ -87,10 +73,16 @@ chusql-web/static/
*~
\#*\#
.\#*
agent_credientials
# 注意:.zed/tasks.json 是本仓库共享的构建任务(stack build / run / test / repl),
# 需要入库,所以这里不忽略 .zed/
# ------------------------------------------------------------
# 项目计划 / 提示词 / 设计决策 / 已知问题(本机文档,不入库)
# ------------------------------------------------------------
plandocs/
# ------------------------------------------------------------
# 操作系统
# ------------------------------------------------------------
+20 -120
View File
@@ -1,124 +1,24 @@
# AGENTS.md — ChuSQL 开发契约
# AGENTS.md — ChuSQL 开发契约(简版)
给 AI agent 的项目规则。**约束优先于便利**:任何"暂时放宽"都必须先在此文件里改数字,并说明理由,否则视为违约。
进度与待办的唯一事实来源是 `projectplan.txt`;本文件只管"什么算做完"。
参考:
- 进度与待办:`project-plan.txt`
- 已知问题:`known-issues.txt`
- 设计依据:`design-decisions.txt`
---
## 核心约束
- project-plan.txt 是唯一进度来源。
- 测试必须通过,禁止绕过检查。
- 不吞错误,使用明确错误返回。
- 表存在性以 catalog 为准。
- 语义检查和优化只使用 schema。
- 索引由存储层管理,优化器只判断可点查条件。
- 优化不能改变结果语义。
## 1. 硬约束(CONSTRAINTS)
## 协作规则
- engine / web / storage 使用规定测试入口。
- 注释、文案保持简短。
- 改动说明只在交流中记录。
违反任一条 = 改动不算完成,不许提交、不许进 `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`,CLI 与接口说明写 `docs/COMMANDS.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(复合键 + 字节键 → 非唯一索引)。
## 当前重点
- 完善 WAL 重放幂等。
- 完整事务路线见 project-plan.txt 阶段 12。
+31 -40
View File
@@ -1,54 +1,45 @@
# ChuSQL
本项目是一个简单的数据库项目,服务端采用Haskell和Rust联合编写,采取B/S模式,管理客户端运行在浏览器网页上。
本项目是一个简单的数据库项目,服务端采用 Haskell 和 Rust 联合编写,采取 B/S 模式,管理客户端运行在浏览器网页上。
## 仓库布局
优点:
| 目录 | 语言 | 组件 |
| ----------------- | -------------------------- | ----------------------------------------------------------------- |
| `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`:一键启动 / 子命令 / 设置管理 |
- 轻量:两个可执行文件加一份静态资源,解包即用,没有 Node / Python 之类的运行时依赖
- 部署容易:Linux 与 macOS 一条命令装完并启动,Windows 解压后跑一次安装脚本
- 配置统一:一个 `chusql.toml` 同时管住存储、Web 与命令行
- 账号统一:Web 管理端与命令行共用同一个管理员和同一张账号表
# 架构分层
## 安装
## Haskell部分
Linux / macOS(自动识别当前平台,取对应发行包):
作为函数式编程语言,从语言层面契合SQL语句解析和代数数学运算,同时有强大的类型安全系统。
```sh
curl -fsSL https://github.com/TheBadRoger/ChuSQL/releases/latest/download/install.sh | sh -s -- --component web
```
- SQL词法分析、AST
- 语义分析:表/列存在性与类型检查(执行前的关卡)
- 事务处理
- 查询优化、内存表管理
- 关系代数运算
- 服务端接口与请求相应
- Web 管理端:Scotty REST API、登录会话、静态页面交付
Windows:下载 `chusql-web-windows-x86_64.zip`,解压后在 PowerShell 里执行
## Rust部分
```powershell
.\install.ps1 -Component web
```
高性能、内存安全且具有强大并行异步编程能力的现代编程语言。
只装命令行客户端就把 `web` 换成 `cli`,入口变成 `csql`。安装位置、目录结构与卸载方式见
[doc/install.md](doc/install.md)。
- 磁盘IO
- B+树、页面管理
- 缓冲池
- WAL
- 事务日志和崩溃恢复
- 并发控制
## 启动
# 性能基准
```sh
csql-web
```
| 指标 | 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** |
浏览器打开 http://127.0.0.1:7777 ,默认账号 `root`,口令是安装时给的那个。服务启动后只有系统库 `system`(里面是账号表),**没有默认工作库**:自己建一个库再用 `USE <库名>` 选中它。
换口令、换端口、改数据目录都在安装时写好的 `chusql.toml` 里。
> 最近更新:2026-09-26
## 文档
| 文档 | 内容 |
| --------------------------------- | ------------------------------------------------------------------------ |
| [安装与卸载](doc/install.md) | 三种安装方式、全部安装选项、装完的目录结构、从源码安装、卸载 |
| [配置选项](doc/config.md) | 配置位置与优先级、全部配置键与默认值、端点规则、口令与安全 |
| [命令与选项](doc/commands.md) | `csql` 与 `chusql-web` 的选项、元命令、账号与角色权限、REST 接口 |
| [架构与组件](doc/architecture.md) | 三个组件怎么分工、一次请求经过什么、数据落在哪 |
@@ -1,14 +0,0 @@
cabal-version: 2.4
name: chusql-bench
version: 1.0.0
synopsis: ChuSQL 查询优化基准测试(对比开/关优化器)
build-type: Simple
executable chusql-bench
main-is: Main.hs
hs-source-dirs: src
build-depends: base >= 4.14 && < 5
, chusql-engine
default-language: Haskell2010
ghc-options: -O2 -Wall
-159
View File
@@ -1,159 +0,0 @@
module Main where
import ChuSQL.Algebra.Eval (evalRelOp)
import ChuSQL.Algebra.Optimize (optimize)
import ChuSQL.Algebra.Planner (translate)
import ChuSQL.Model
import ChuSQL.Syntax.Parser (parseStatement)
import Control.Exception (evaluate)
import Data.IORef (IORef, newIORef, readIORef)
import Data.List (sort)
import System.CPUTime (getCPUTime)
import System.Environment (getArgs)
import Text.Printf (printf)
-- 性能基准:同一批语句,比较优化前后的耗时。
-- | 造一张 users 表
mkUsers :: Int -> Table
mkUsers n =
Table
{ tableName = "users"
, tableCols = [("id", TInt), ("name", TStr), ("age", TInt)]
, tableRows =
[ [("id", VInt i), ("name", VStr ("user" ++ show i)), ("age", VInt (i `mod` 100))]
| i <- [1 .. n]
]
}
-- | 造一张 orders 表
mkOrders :: Int -> Int -> Table
mkOrders n m =
Table
{ tableName = "orders"
, tableCols = [("id", TInt), ("user_id", TInt), ("product", TStr)]
, tableRows =
[ [("id", VInt j), ("user_id", VInt (1 + (j - 1) `mod` n)), ("product", VStr ("item" ++ show (j `mod` 50)))]
| j <- [1 .. m]
]
}
-- | 结果校验和(防被优化掉)
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
-- | 结果行数
rowCount :: Either String [Row] -> Int
rowCount (Left _) = -1
rowCount (Right rows) = length rows
-- | 跑一次,返回校验和
runOnce :: IORef String -> Database -> String -> Bool -> IO Int
runOnce saltRef db sql useOpt = do
salt <- readIORef saltRef
let result = case parseStatement (sql ++ salt) >>= translate of
Left e -> Left e
Right plan -> evalRelOp db (if useOpt then optimize db plan else plan)
forced <- evaluate (checksum result)
return forced
-- | 跑一次,返回行数
resultRows :: IORef String -> Database -> String -> Bool -> IO Int
resultRows saltRef db sql useOpt = do
salt <- readIORef saltRef
let result = case parseStatement (sql ++ salt) >>= translate of
Left e -> Left e
Right plan -> evalRelOp db (if useOpt then optimize db plan else plan)
_ <- evaluate (checksum result)
return (rowCount result)
-- | 量一段 IO 的 CPU 时间
timed :: IO a -> IO Double
timed act = do
t0 <- getCPUTime
_ <- act
t1 <- getCPUTime
return (fromIntegral (t1 - t0) / 1e12)
minBatch :: Int
minBatch = 8
rounds :: Int
rounds = 3
-- | 跑一批 k 次,返回总耗时
timeBatch :: Int -> IO Int -> IO Double
timeBatch k act = timed (mapM_ (const act) [1 .. k])
-- | 中位数
median :: [Double] -> Double
median xs = sort xs !! (length xs `div` 2)
-- | 自适应批量(够长且至少 8 次),取 3 批中位数
benchOp :: Double -> IO Int -> IO (Double, Int)
benchOp target act = do
k <- pickBatch 1
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
-- | 打印一个用例的对比
report :: IORef String -> Database -> Double -> String -> IO ()
report saltRef db target sql = do
nUn <- resultRows saltRef db sql False
nOp <- resultRows saltRef db sql True
(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 " <<< result mismatch!"
printf "%s\n" sql
printf
" rows %-6d | baseline %9.3f ms | optimized %9.3f ms | speedup %6.1fx%s\n"
nOp
(tUn * 1000)
(tOp * 1000)
speedup
mismatch
printf
" (%d iters/batch x %d batches, median: baseline %d iters/batch, optimized %d iters/batch)\n\n"
minBatch
rounds
kUn
kOp
queries :: [String]
queries =
[ "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id WHERE u.age > 90"
, "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id"
, "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id WHERE u.age > 90 ORDER BY o.product LIMIT 10"
, "SELECT name FROM users WHERE age > 90"
]
warmupSql :: String
warmupSql = "SELECT name FROM users"
-- | 基准入口
main :: IO ()
main = do
args <- getArgs
let pick i d = if length args > i then read (args !! i) else d
n = pick 0 500
m = pick 1 500
target = pick 2 (0.3 :: Double)
db = [("users", mkUsers n), ("orders", mkOrders n m)]
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
mapM_ (report saltRef db target) queries
-5
View File
@@ -1,5 +0,0 @@
resolver: lts-24.59
packages:
- .
- ../../chusql-engine
File renamed without changes.
-334
View File
@@ -1,334 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "chusql-storage"
version = "0.1.0"
dependencies = [
"interprocess",
"serde",
"serde_json",
"toml",
]
[[package]]
name = "chusql-storage-bench"
version = "0.1.0"
dependencies = [
"chusql-storage",
"serde_json",
"tempfile",
]
[[package]]
name = "doctest-file"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "interprocess"
version = "2.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb02ffbf25e8ef0f4a95bff054eef75f6d1bf16919474cf2caa63b21997478a"
dependencies = [
"doctest-file",
"libc",
"recvmsg",
"widestring",
"windows-sys",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "recvmsg"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175"
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "widestring"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
-16
View File
@@ -1,16 +0,0 @@
[package]
name = "chusql-storage-bench"
version = "0.1.0"
edition = "2024"
# 存储层基准单独成包,不混进功能代码(见同目录 README)。
[dependencies]
chusql-storage = { path = "../../chusql-storage" }
serde_json = "1"
[dev-dependencies]
tempfile = "3"
[[bench]]
name = "storage"
harness = false
-337
View File
@@ -1,337 +0,0 @@
use std::time::{Duration, Instant};
use chusql_storage::btree::DiskBTree;
use chusql_storage::config::{DEFAULT_BTREE_ORDER, DEFAULT_PAGE_SIZE};
use chusql_storage::heap::HeapTable;
use chusql_storage::page::{Page, PageFile};
use chusql_storage::protocol::Row;
use chusql_storage::wal::{Wal, WalOp};
use serde_json::json;
// 存储层基准:页 IO、B+ 树、堆表、WAL 与服务端写入路径;
// 用 std::time::Instant 计时,不引第三方基准框架,
// 每项跑若干轮取最快一轮,离线也能跑。
/// 跑一个操作 ops 次,量总耗时并折算每次耗时
fn bench<F: FnMut()>(label: &str, ops: usize, rounds: usize, mut f: F) {
let mut best = Duration::MAX;
for _ in 0..rounds {
let t0 = Instant::now();
f();
let d = t0.elapsed();
if d < best {
best = d;
}
}
let per = best.as_secs_f64() * 1e6 / ops as f64;
println!(
"{:<38} {:>9.2} ms {:>10.3} us/op",
label,
best.as_secs_f64() * 1e3,
per
);
}
/// 只量一次(fsync 类操作太慢,不适合反复跑)
fn bench_once<F: FnOnce()>(label: &str, ops: usize, f: F) {
let t0 = Instant::now();
f();
let d = t0.elapsed();
let per = d.as_secs_f64() * 1e6 / ops as f64;
println!(
"{:<38} {:>9.2} ms {:>10.3} us/op",
label,
d.as_secs_f64() * 1e3,
per
);
}
/// 造一行
fn row(id: i64) -> Row {
let mut m = Row::new();
m.insert("id".to_string(), json!(id));
m.insert("name".to_string(), json!(format!("user{}", id)));
m
}
/// 小随机数发生器,避免引 rand
fn next_rand(state: &mut u64) -> u64 {
*state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
*state >> 33
}
/// 顺序写 / 顺序读 / 随机读
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 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();
}
pf.flush().unwrap();
});
drop(pf);
let mut pf = PageFile::with_options(&path, DEFAULT_PAGE_SIZE, 16).unwrap();
bench("page sequential read (pool 16, mostly misses)", n, 3, || {
for i in 0..n {
pf.read_page(i as u64).unwrap();
}
});
let mut pf = PageFile::with_options(&path, DEFAULT_PAGE_SIZE, 16).unwrap();
let mut state = 12345u64;
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 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!(
" (pool 1024: hits {}/{})",
pf.hits(),
pf.hits() + pf.misses()
);
}
/// 插入、点查、全量遍历
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 insert (with flush)", n, 2, || {
bt.clear().unwrap();
for i in 0..n {
bt.insert(i as i64, i as u64).unwrap();
}
bt.flush().unwrap();
});
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 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");
let idx = dir.join("heap.idx");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&idx);
let mut t = HeapTable::open(&path, DEFAULT_PAGE_SIZE, 1024).unwrap();
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 insert + flush", n, 2, || {
t.replace_all(&[]).unwrap();
for i in 0..n {
t.insert_row(&row(i as i64)).unwrap();
}
t.flush().unwrap();
});
bench("heap batch insert + flush (single commit)", n, 2, || {
t.replace_all(&[]).unwrap();
let rows: Vec<Row> = (0..n as i64).map(row).collect();
t.insert_rows(&rows).unwrap();
t.flush().unwrap();
});
bench("heap full scan", n, 3, || {
let rows = t.scan().unwrap();
assert_eq!(rows.len(), n);
});
}
/// 追加写与清空
fn bench_wal(dir: &std::path::Path, n: usize) {
let path = dir.join("wal.log");
let _ = std::fs::remove_file(&path);
let wal = Wal::new(&path);
let op = WalOp::Insert {
table: "t".to_string(),
row: row(1),
};
bench_once("wal append (fsync per record)", n, || {
for _ in 0..n {
wal.append(&op).unwrap();
}
});
wal.clear().unwrap();
bench_once("wal clear (fsync per record)", n, || {
for _ in 0..n {
wal.clear().unwrap();
}
});
wal.clear().unwrap();
bench_once("wal truncate (no fsync)", n, || {
for _ in 0..n {
wal.truncate().unwrap();
}
});
use std::fs::OpenOptions;
use std::io::Write;
let bytes = vec![0u8; 200];
bench_once(" - open + write + fsync", n, || {
for _ in 0..n {
let mut f = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.unwrap();
f.write_all(&bytes).unwrap();
f.sync_data().unwrap();
}
});
let mut open_file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.unwrap();
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 (no open, no write)", n, || {
for _ in 0..n {
open_file.sync_data().unwrap();
}
});
bench_once(" - only open/close", n, || {
for _ in 0..n {
let _ = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.unwrap();
}
});
wal.clear().unwrap();
}
/// 复刻 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");
let wal_path = dir.join("wp.wal");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&idx);
let _ = std::fs::remove_file(&wal_path);
let mut t = HeapTable::open_indexed(
&path,
&idx,
DEFAULT_PAGE_SIZE,
DEFAULT_BTREE_ORDER,
1024,
)
.unwrap();
let wal = Wal::new(&wal_path);
bench_once("write path single INSERT (end to end)", n, || {
for i in 0..n as i64 {
wal.truncate().unwrap();
let op = WalOp::Insert {
table: "t".to_string(),
row: row(i),
};
wal.append(&op).unwrap();
t.insert_row(&row(i)).unwrap();
t.flush().unwrap();
wal.clear().unwrap();
}
});
bench_once(" - wal.truncate() (no fsync)", n, || {
for _ in 0..n {
wal.truncate().unwrap();
}
});
let op = WalOp::Insert {
table: "t".to_string(),
row: row(1),
};
bench_once(" - wal.append() (with fsync)", n, || {
for _ in 0..n {
wal.append(&op).unwrap();
}
});
wal.clear().unwrap();
bench_once(" - wal.clear() (with fsync)", n, || {
for _ in 0..n {
wal.clear().unwrap();
}
});
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, || {
for _ in 0..n {
t.flush().unwrap();
}
});
}
fn main() {
let n: usize = std::env::var("CHUSQL_BENCH_N")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(2000);
println!("chusql-storage benchmark: n = {}, page size = {} bytes, btree order = {}",
n, DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER);
println!("best of each round set; fsync entries run once only (repeats are too slow)\n");
let dir = tempfile::tempdir().unwrap();
println!("[page IO]");
bench_page(dir.path(), n);
println!("\n[B+ tree]");
bench_btree(dir.path(), n);
println!("\n[heap]");
bench_heap(dir.path(), n);
println!("\n[WAL]");
bench_wal(dir.path(), n.min(200));
println!("\n[server write path]");
bench_write_path(dir.path(), n.min(200));
}
@@ -13,12 +13,13 @@ import ChuSQL.Storage.IPC (
doListCatalog,
doLookupByColumn,
sendRequest,
setPipeName,
)
import ChuSQL.Syntax.Parser (parseStatement)
import Control.Concurrent (threadDelay)
import Control.Exception (SomeException, bracket, evaluate, try)
import Control.Monad (filterM, unless, when)
import Data.List (intercalate, isInfixOf, isPrefixOf)
import Data.List (intercalate, isInfixOf)
import GHC.Clock (getMonotonicTime)
import System.Directory (
createDirectoryIfMissing,
@@ -26,9 +27,10 @@ import System.Directory (
getTemporaryDirectory,
removeDirectoryRecursive,
)
import System.Environment (getArgs, getEnvironment, setEnv)
import System.Environment (getArgs)
import System.Exit (exitFailure)
import System.FilePath ((</>))
import System.Info (os)
import System.Process (
CreateProcess (..),
ProcessHandle,
@@ -40,21 +42,19 @@ import System.Process (
)
import Text.Printf (printf)
-- 全链路基准:Haskell 引擎与 Rust 存储进程一起跑,
-- 语句走完整链路(解析、优化、命名管道、WAL、落盘),
-- 用墙上时钟计时,结果打印到标准输出。
-- 全链路基准:Haskell 引擎与 Rust 存储进程一起跑,语句走完整链路并打印墙上耗时。
-- | 找 Rust 存储进程:优先 release
findServer :: FilePath -> IO FilePath
findServer "" = do
let dirs =
[ "chusql-storage/target"
, "../../chusql-storage/target"
, "../chusql-storage/target"
, "target"
]
modes = ["release", "debug"]
exes = ["chusql-storage.exe", "chusql-storage"]
-- 名字跟平台走:同一个 target 目录里可能躺着一份别处交叉编译出来的 .exe,
-- 在 Linux 上误选它只会拿到 config file not found
exes = [if os == "mingw32" then "chusql-storage.exe" else "chusql-storage"]
candidates = [d </> m </> e | d <- dirs, m <- modes, e <- exes]
found <- filterM doesFileExist candidates
case found of
@@ -74,27 +74,12 @@ findServer p = do
putStrLn ("storage binary not found: " ++ p)
exitFailure
-- | 子进程环境:继承并覆盖 CHUSQL_*
childEnv :: [(String, String)] -> IO [(String, String)]
childEnv extra = do
base <- getEnvironment
let stripped = filter (\(k, _) -> not ("CHUSQL_" `isPrefixOf` k)) base
pure (stripped ++ extra)
-- | 起存储进程并等管道可连
startServer :: FilePath -> String -> FilePath -> IO ProcessHandle
startServer serverPath pipe dataDir = do
envs <-
childEnv
[ ("CHUSQL_PIPE", pipe)
, ("CHUSQL_DATA_DIR", dataDir)
, ("CHUSQL_PAGE_SIZE", "4096")
, ("CHUSQL_BTREE_ORDER", "4")
, ("CHUSQL_LOG", "error")
]
-- | 起存储进程并等管道可连:调参走 --config 指的那份配置文件,环境原样继承
startServer :: FilePath -> FilePath -> IO ProcessHandle
startServer serverPath configPath = do
(_, _, _, ph) <-
createProcess
(proc serverPath []) {env = Just envs, std_out = NoStream, std_err = Inherit}
(proc serverPath ["--config", configPath]){std_out = NoStream, std_err = Inherit}
ready <- waitReady (200 :: Int)
unless ready $ do
putStrLn "storage process was not ready within 5 s"
@@ -194,12 +179,12 @@ batchInsert label n mkSql = do
-- | 一次点查(原始 IPC,不经过引擎)
benchLookup :: String -> String -> Int -> Int -> IO ()
benchLookup table column key times = do
_ <- doLookupByColumn table column key
_ <- doLookupByColumn table column (VInt key)
t0 <- getMonotonicTime
results <- mapM (const (doLookupByColumn table column key)) [1 .. times]
results <- mapM (const (doLookupByColumn table column (VInt key))) [1 .. times]
t1 <- getMonotonicTime
let total = t1 - t0
hit = length [() | Right (IndexRow (Just _)) <- results]
hit = length [() | Right (IndexRows (_ : _)) <- results]
printf
"%-38s %8.1f ms %7.3f ms/op (hits %d/%d)\n"
"lookup_by_index (raw IPC, by id)"
@@ -255,7 +240,6 @@ runBatch sqls = do
chunked :: Int -> Int -> [[Int]]
chunked size n = go [1 .. n]
where
-- | 每次切下 size 个
go [] = []
go xs = let (a, b) = splitAt size xs in a : go b
@@ -391,11 +375,26 @@ main = do
stamp <- getMonotonicTime
let pipe = "chusql-fullchain-" ++ show (round (stamp * 1e6) :: Int)
dataDir = tmp </> pipe
configPath = tmp </> (pipe ++ ".toml")
-- TOML 的普通字符串会吃反斜杠,路径统一用正斜杠
slashed = map (\c -> if c == '\\' then '/' else c) dataDir
createDirectoryIfMissing True dataDir
setEnv "CHUSQL_PIPE" pipe
writeFile
configPath
( unlines
[ "[storage]"
, "data_dir = \"" ++ slashed ++ "\""
, "[server]"
, "pipe_name = \"" ++ pipe ++ "\""
, "[log]"
, "level = \"error\""
]
)
setPipeName pipe
printf "full-chain benchmark: Haskell engine <--named pipe--> Rust storage\n"
printf "storage : %s\n" serverPath
printf "config : %s\n" configPath
printf "pipe : %s\n" pipe
printf "data dir : %s\n" dataDir
printf "dataset : users = %d rows, orders = %d rows\n" u o
@@ -404,7 +403,7 @@ main = do
bracket
(do
t1 <- getMonotonicTime
ph <- startServer serverPath pipe dataDir
ph <- startServer serverPath configPath
t2 <- getMonotonicTime
printf "\n[startup] storage ready in %.2f s\n" (t2 - t1)
pure ph
@@ -2,4 +2,4 @@ resolver: lts-24.59
packages:
- .
- ../../chusql-engine
- ../chusql-engine
File renamed without changes.
+206
View File
@@ -0,0 +1,206 @@
{-# LANGUAGE OverloadedStrings #-}
module Main (main) where
import ChuSQL.CLI.Format (OutputFormat (..), formatName, parseFormat)
import ChuSQL.CLI.Script (stripTerminator)
import ChuSQL.CLI.Session (
Session,
authenticateSession,
isPlainIdentifier,
newSession,
switchDatabase,
)
import ChuSQL.Web.Auth (Credential (..))
import ChuSQL.Web.Backend (ipcBackend)
import ChuSQL.Web.Config (WebConfig (..), defaultPassword, loadWebConfigAt, resolveCredential, resolvePipeName)
import ChuSQL.Web.Settings (readSettingsFile)
import ChuSQL.Web.TOML (resolveConfigPath)
import ChuSQL.Web.StorageProcess (
StorageProcess,
findStorageServer,
startStorageProcessAt,
stopStorageProcess,
waitForStorage,
)
import ChuSQL.Storage.IPC (setPipeName)
import Control.Exception (finally)
import Control.Monad (unless)
import Data.IORef (newIORef)
import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import Options.Applicative
import Repl (reportError, runRepl, runStatementOnce)
import System.Console.Haskeline (defaultSettings, getPassword, runInputT)
import System.Directory (getHomeDirectory)
import System.Exit (exitFailure)
import System.FilePath ((</>))
import System.IO (hSetEncoding, stderr, stdout, utf8)
-- chusql-cli:与 Web 管理端并列的前端,直连存储与引擎,账号与 Web 共用。
data Options = Options
{ optUser :: Maybe Text
, optPromptPassword :: Bool
, optDatabase :: Text
, optFormat :: OutputFormat
, optExecute :: Maybe Text
, optHistory :: Maybe FilePath
, optPipe :: Maybe String
, optStorage :: Maybe FilePath
, optConfig :: Maybe FilePath
}
optionsParser :: Parser Options
optionsParser =
Options
<$> optional (T.pack <$> strOption (long "user" <> short 'u' <> metavar "USER" <> help "Sign in as this account (default: the configured administrator)"))
<*> switch (long "password" <> short 'p' <> help "Always ask for the password, ignoring the configured one")
<*> ( T.pack
<$> strOption
( long "database"
<> short 'd'
<> metavar "DB"
<> value ""
<> help "Database to use (default: none, select one with USE)"
)
)
<*> option
(eitherReader formatReader)
( long "format"
<> short 'f'
<> metavar "FORMAT"
<> value FormatTable
<> showDefaultWith (T.unpack . formatName)
<> help "Output format: table, json or csv"
)
<*> optional (T.pack <$> strOption (long "execute" <> short 'e' <> metavar "SQL" <> help "Run one statement and exit"))
<*> optional (strOption (long "history" <> metavar "FILE" <> help "SQL history file (default ~/.chusql_history)"))
<*> optional (strOption (long "pipe" <> metavar "NAME" <> help "Endpoint name of the storage process (Windows named pipe, Unix socket file; default: [server] pipe_name in chusql.toml)"))
<*> optional (strOption (long "storage" <> metavar "SERVER" <> help "Rust storage executable to spawn when nothing answers the endpoint"))
<*> optional (strOption (long "config" <> metavar "FILE" <> help "Config file to read (default: the fixed chusql.toml under the system config directory)"))
formatReader :: String -> Either String OutputFormat
formatReader raw = maybe (Left "format must be table, json or csv") Right (parseFormat (T.pack raw))
parserInfo :: ParserInfo Options
parserInfo =
info
(optionsParser <**> helper)
( fullDesc
<> progDesc "Run SQL against the ChuSQL storage engine"
<> header "csql - the ChuSQL command line client"
)
main :: IO ()
main = do
hSetEncoding stdout utf8
hSetEncoding stderr utf8
options <- execParser parserInfo
configPath <- resolveConfigPath (optConfig options)
base <- loadWebConfigAt configPath
let settingsFile = configPath
saved <- readSettingsFile settingsFile
credential <- resolveCredential base saved
let rootName = credUser credential
userName = fromMaybe rootName (optUser options)
pipeName = resolvePipe options base
setPipeName pipeName
password <- resolvePassword (optPromptPassword options) userName rootName saved
storage <- acquireStorage options pipeName settingsFile
case storage of
Left message -> reportError message >> exitFailure
Right spawned ->
connect options credential settingsFile userName password
`finally` maybe (pure ()) stopStorageProcess spawned
-- | 接上存储,登录,然后进交互或跑单条语句
connect :: Options -> Credential -> FilePath -> Text -> Text -> IO ()
connect options credential settingsFile userName password = do
backend <- ipcBackend
session <- newSession backend credential settingsFile
signedIn <- authenticateSession session userName password
case signedIn of
Left message -> reportError message >> exitFailure
Right () -> runSession options session
runSession :: Options -> Session -> IO ()
runSession options session = do
formatRef <- newIORef (optFormat options)
ready <- selectDatabase session (T.strip (optDatabase options))
if not ready
then exitFailure
else case optExecute options of
Just sql -> do
ok <- runStatementOnce session (optFormat options) (stripTerminator sql)
unless ok exitFailure
Nothing -> do
historyFile <- resolveHistory (optHistory options)
runRepl session formatRef historyFile
-- | 只有 -d 明确给了一个库才切;没给就保持"未选库"(服务没有默认库)
selectDatabase :: Session -> Text -> IO Bool
selectDatabase _ wanted | T.null wanted = pure True
selectDatabase session wanted
| not (isPlainIdentifier wanted) = reportError ("not a plain database name: " <> wanted) >> pure False
| otherwise = do
switched <- switchDatabase session wanted
case switched of
Left message -> reportError message >> pure False
Right () -> pure True
-- | 口令:-p 强制交互输入;否则配置里的 root 明文(同 Web)> 交互输入
resolvePassword :: Bool -> Text -> Text -> Map.Map Text Text -> IO Text
resolvePassword forcePrompt userName rootName saved
| forcePrompt = promptPassword userName
| T.toLower userName == T.toLower rootName = pure (configuredPassword saved)
| otherwise = promptPassword userName
where
configuredPassword values = case nonEmpty (Map.lookup "password" values) of
Just plain -> plain
Nothing -> defaultPassword
promptPassword :: Text -> IO Text
promptPassword user = do
entered <- runInputT defaultSettings (getPassword (Just '*') (T.unpack user <> "'s password: "))
pure (maybe "" T.pack entered)
-- | 先找已经在跑的存储;--storage 给了可执行文件才自己拉一个
acquireStorage :: Options -> String -> FilePath -> IO (Either Text (Maybe StorageProcess))
acquireStorage options pipeName configPath = do
up <- waitForStorage 4
case optStorage options of
Nothing
| up -> pure (Right Nothing)
| otherwise ->
pure
( Left
( "no storage process answered on pipe "
<> T.pack pipeName
<> "; start the server first, or pass --storage <path>"
)
)
Just given
| up -> pure (Right Nothing)
| otherwise -> do
found <- findStorageServer (Just given)
case found of
Left err -> pure (Left (T.pack err))
Right binary -> fmap (either (Left . T.pack) (Right . Just)) (startStorageProcessAt binary pipeName configPath)
-- | 管名:--pipe > chusql.toml 的 [server] pipe_name(缺省 chusql-joint)
resolvePipe :: Options -> WebConfig -> String
resolvePipe options base = fromMaybe (resolvePipeName base) (optPipe options)
nonEmpty :: Maybe Text -> Maybe Text
nonEmpty given = case T.strip <$> given of
Just text | not (T.null text) -> Just text
_ -> Nothing
resolveHistory :: Maybe FilePath -> IO (Maybe FilePath)
resolveHistory (Just path) = pure (Just path)
resolveHistory Nothing = do
home <- getHomeDirectory
pure (Just (home </> ".chusql_history"))
+192
View File
@@ -0,0 +1,192 @@
{-# LANGUAGE OverloadedStrings #-}
module Repl
( runRepl
, runStatementOnce
, reportError
) where
import ChuSQL.CLI.Format (OutputFormat, formatName, renderResult, renderRows)
import ChuSQL.CLI.Script (Meta (..), errorHint, metaHelp, parseMeta, takeStatement)
import ChuSQL.CLI.Session (Session, accountTable, catalog, databases, roleViews, runStatement, sessionDatabase, switchDatabase)
import ChuSQL.Storage.IPC (SchemaColumn (..), TableInfo (..))
import ChuSQL.Web.Privileges (Grant (..), RoleView (..))
import Control.Monad (forM_, when)
import Control.Monad.IO.Class (liftIO)
import Data.IORef (IORef, readIORef, writeIORef)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import System.Console.Haskeline
import System.IO (hIsTerminalDevice, stderr, stdin)
-- 交互层:提示符、多行输入、历史、元命令与错误提示。
-- | 交互循环;historyPath 为 Nothing 就不落盘历史
runRepl :: Session -> IORef OutputFormat -> Maybe FilePath -> IO ()
runRepl session formatRef historyPath =
runInputT (defaultSettings{historyFile = historyPath}) (loop session formatRef T.empty)
-- | 入口处直接报错(-e 与启动阶段用)
reportError :: Text -> IO ()
reportError message = do
TIO.hPutStrLn stderr ("chusql: error: " <> message)
forM_ (errorHint message) (\hint -> TIO.hPutStrLn stderr ("hint: " <> hint))
-- | 跑一条语句并按格式打印;成功与否当退出码
runStatementOnce :: Session -> OutputFormat -> Text -> IO Bool
runStatementOnce session format sql = do
result <- runStatement session sql
case result of
Left message -> reportError message >> pure False
Right answer -> TIO.putStrLn (renderResult format answer) >> pure True
loop :: Session -> IORef OutputFormat -> Text -> InputT IO ()
loop session formatRef buffer = do
prompt <- liftIO (promptFor session buffer)
entered <- getInputLine (T.unpack prompt)
case entered of
Nothing -> do
interactive <- liftIO (hIsTerminalDevice stdin)
when interactive (outputStrLn "")
Just raw -> do
let line = T.strip (T.pack raw)
if T.null line
then loop session formatRef buffer
else case (T.null buffer, parseMeta line) of
(True, Just meta) -> do
again <- runMeta session formatRef meta
when again (loop session formatRef T.empty)
_ -> do
let extended = if T.null buffer then line else buffer <> "\n" <> line
remaining <- drain session formatRef extended
loop session formatRef remaining
-- | 提示符带上当前库;续行换个样子,方便看出语句还没写完;管道输入时不出提示符
promptFor :: Session -> Text -> IO Text
promptFor session buffer = do
interactive <- hIsTerminalDevice stdin
if not interactive
then pure ""
else
if not (T.null (T.strip buffer))
then pure " -> "
else do
current <- sessionDatabase session
pure (if T.null current then "chusql> " else "chusql [" <> current <> "]> ")
-- | 把攒下来的内容里所有完整语句依次执行,返回没写完的残余
drain :: Session -> IORef OutputFormat -> Text -> InputT IO Text
drain session formatRef buffer = case takeStatement buffer of
Nothing -> pure (T.strip buffer)
Just (statement, rest) -> do
when (not (T.null statement)) (runOne session formatRef statement)
drain session formatRef rest
runOne :: Session -> IORef OutputFormat -> Text -> InputT IO ()
runOne session formatRef statement = do
result <- liftIO (runStatement session statement)
case result of
Left message -> liftIO (reportError message)
Right answer -> do
format <- liftIO (readIORef formatRef)
outputStrLn (T.unpack (renderResult format answer))
runMeta :: Session -> IORef OutputFormat -> Meta -> InputT IO Bool
runMeta session formatRef meta = case meta of
MetaQuit -> pure False
MetaHelp -> outputStrLn (T.unpack metaHelp) >> pure True
MetaDatabases -> listDatabases session >> pure True
MetaTables -> listTables session >> pure True
MetaDescribe name -> describeTable session name >> pure True
MetaRoles -> listRoles session >> pure True
MetaConnect name -> do
switched <- liftIO (switchDatabase session name)
case switched of
Left message -> liftIO (reportError message)
Right () -> outputStrLn ("database is now " <> T.unpack name)
pure True
MetaSetFormat format -> do
liftIO (writeIORef formatRef format)
outputStrLn ("output format is now " <> T.unpack (formatName format))
pure True
MetaUnknown name -> outputStrLn ("unknown command: \\" <> T.unpack name <> " (try \\?)") >> pure True
-- | \l:库清单
listDatabases :: Session -> InputT IO ()
listDatabases session = do
answer <- liftIO (databases session)
case answer of
Left message -> liftIO (reportError message)
Right names -> outputStrLn (T.unpack (renderRows ["database"] (map (: []) names)))
-- | \dt:表清单(base 里补上账号表)
listTables :: Session -> InputT IO ()
listTables session = do
answer <- liftIO (catalog session)
case answer of
Left message -> liftIO (reportError message)
Right tables -> do
allTables <- liftIO (withAccountTable session tables)
outputStrLn (T.unpack (renderRows headers (map tableRow allTables)))
where
headers = ["table", "columns", "rows", "indexes"]
tableRow info =
[ T.pack (tiTable info)
, tshow (length (tiColumns info))
, tshow (tiRows info)
, if null (tiIndexes info) then "-" else T.intercalate ", " (map T.pack (tiIndexes info))
]
-- | \d <table>:列结构
describeTable :: Session -> Text -> InputT IO ()
describeTable session name = do
answer <- liftIO (catalog session)
case answer of
Left message -> liftIO (reportError message)
Right tables -> do
allTables <- liftIO (withAccountTable session tables)
case filter (matches name) allTables of
[] -> liftIO (reportError ("unknown table: " <> name))
(info : _) -> outputStrLn (T.unpack (renderRows headers (map columnRow (tiColumns info))))
where
headers = ["column", "type", "primary", "unique", "nullable", "auto"]
matches wanted info = T.toLower (T.pack (tiTable info)) == T.toLower wanted
columnRow column =
[ T.pack (scName column)
, T.pack (scType column)
, yesNo (scPrimaryKey column)
, yesNo (scUnique column)
, yesNo (scNullable column)
, yesNo (scAutoIncrement column)
]
yesNo True = "yes"
yesNo False = "no"
-- | \dr:角色清单(授权对象与成员)
listRoles :: Session -> InputT IO ()
listRoles session = do
answer <- liftIO (roleViews session)
case answer of
Left message -> liftIO (reportError message)
Right roles -> outputStrLn (T.unpack (renderRows ["role", "grants", "members"] (map roleRow roles)))
where
roleRow view =
[ roleName view
, if null (roleGrants view) then "-" else T.intercalate ", " (map grantText (roleGrants view))
, if null (roleMembers view) then "-" else T.intercalate ", " (roleMembers view)
]
grantText grant = grantPrivilege grant <> " on " <> grantObject grant
-- | 当前库是 system 时,把账号表也列出来(与 Web 端一致)
withAccountTable :: Session -> [TableInfo] -> IO [TableInfo]
withAccountTable session tables = do
current <- sessionDatabase session
if current /= "system"
then pure tables
else do
extra <- accountTable session
pure (maybe tables (: tables) extra)
tshow :: Show a => a -> Text
tshow = T.pack . show
+57
View File
@@ -0,0 +1,57 @@
cabal-version: 2.4
name: chusql-cli
version: 1.0.0
synopsis: ChuSQL 命令行客户端:直连引擎与存储,与 Web 管理端共用账号
description: ChuSQL 的命令行前端。直接走命名管道接 Rust 存储进程与 Haskell 引擎,
不经过 Web REST;root 凭据与 __chusql_users 表与 Web 管理端完全共用。
build-type: Simple
library
hs-source-dirs: src
exposed-modules: ChuSQL.CLI.Format
ChuSQL.CLI.Script
ChuSQL.CLI.Session
build-depends: base >= 4.14 && < 5
, aeson
, bytestring
, chusql-engine
, chusql-web
, scientific
, text
, time
default-language: Haskell2010
ghc-options: -Wall
executable csql
main-is: Main.hs
other-modules: Repl
hs-source-dirs: app
build-depends: base
, aeson
, chusql-cli
, chusql-engine
, chusql-web
, containers
, directory
, filepath
, haskeline
, optparse-applicative
, text
default-language: Haskell2010
ghc-options: -Wall
test-suite chusql-cli-test
type: exitcode-stdio-1.0
main-is: Spec.hs
hs-source-dirs: test
build-depends: base
, aeson
, bytestring
, chusql-cli
, chusql-engine
, chusql-web
, hspec
, text
default-language: Haskell2010
ghc-options: -Wall
+102
View File
@@ -0,0 +1,102 @@
{-# LANGUAGE OverloadedStrings #-}
module ChuSQL.CLI.Format
( OutputFormat (..)
, parseFormat
, formatName
, renderResult
, renderTable
, renderRows
, renderCsv
, renderJson
, displayValue
) where
import ChuSQL.CLI.Session (QueryResult (..), queryResultJson)
import ChuSQL.Model (Value (..))
import qualified Data.Aeson as A
import qualified Data.ByteString.Lazy as BL
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (decodeUtf8)
-- 结果输出:表格、JSON、CSV 三种格式。
data OutputFormat = FormatTable | FormatJson | FormatCsv
deriving (Eq, Show)
formatName :: OutputFormat -> Text
formatName FormatTable = "table"
formatName FormatJson = "json"
formatName FormatCsv = "csv"
-- | 认一个格式名
parseFormat :: Text -> Maybe OutputFormat
parseFormat raw = case T.toLower (T.strip raw) of
"table" -> Just FormatTable
"json" -> Just FormatJson
"csv" -> Just FormatCsv
_ -> Nothing
renderResult :: OutputFormat -> QueryResult -> Text
renderResult FormatTable = renderTable
renderResult FormatJson = renderJson
renderResult FormatCsv = renderCsv
-- | 紧凑 JSON,方便管道里再用
renderJson :: QueryResult -> Text
renderJson = decodeUtf8 . BL.toStrict . A.encode . queryResultJson
renderCsv :: QueryResult -> Text
renderCsv result = T.unlines (header : map rowLine (qrRows result))
where
header = T.intercalate "," (map csvField (qrColumns result))
rowLine row = T.intercalate "," (map (csvField . csvCell) row)
-- | CSV 里的空值就是空字段
csvCell :: Value -> Text
csvCell VNull = ""
csvCell value = displayValue value
-- | 按 RFC 4180 给需要的字段加引号
csvField :: Text -> Text
csvField field
| T.any (`elem` (",\"\n\r" :: String)) field = "\"" <> T.replace "\"" "\"\"" field <> "\""
| otherwise = field
renderTable :: QueryResult -> Text
renderTable result
| null (qrColumns result) = "OK"
| otherwise = renderRows (qrColumns result) (map (map displayValue) (qrRows result))
-- | 画一张等宽表,列太宽就截断
renderRows :: [Text] -> [[Text]] -> Text
renderRows columns rawRows = T.unlines (border : headerLine : border : body ++ [border, summary])
where
widths = [columnWidth index | index <- [0 .. length columns - 1]]
columnWidth index =
min maxColumnWidth (maximum (T.length (columns !! index) : [T.length (cellAt row index) | row <- rawRows]))
maxColumnWidth = 64
cellAt row index = if index < length row then row !! index else ""
cellLine cells = "| " <> T.intercalate " | " (zipWith pad widths (cells ++ repeat "")) <> " |"
headerLine = cellLine columns
body = map cellLine rawRows
border = "+" <> T.intercalate "+" ["-" <> T.replicate (width + 2) "-" | width <- widths] <> "+"
summary = "(" <> T.pack (show (length rawRows)) <> " row" <> (if length rawRows == 1 then "" else "s") <> ")"
-- | 补到指定宽度,超出的按 ... 截断
pad :: Int -> Text -> Text
pad width text = clipped <> T.replicate (width - T.length clipped) " "
where
clipped
| T.length text <= width = text
| width <= 3 = T.take width text
| otherwise = T.take (width - 3) text <> "..."
displayValue :: Value -> Text
displayValue VNull = "NULL"
displayValue (VInt n) = T.pack (show n)
displayValue (VFloat d) = T.pack (show d)
displayValue (VStr s) = T.pack s
displayValue (VBool True) = "true"
displayValue (VBool False) = "false"
+123
View File
@@ -0,0 +1,123 @@
{-# LANGUAGE OverloadedStrings #-}
module ChuSQL.CLI.Script
( Meta (..)
, parseMeta
, takeStatement
, statementComplete
, stripTerminator
, errorHint
, metaHelp
) where
import ChuSQL.CLI.Format (OutputFormat (..), formatName, parseFormat)
import Data.Char (isSpace)
import Data.Text (Text)
import qualified Data.Text as T
-- 交互层的纯逻辑:元命令、多行语句切分、错误提示。
data Meta
= MetaQuit
| MetaHelp
| MetaDatabases
| MetaTables
| MetaDescribe Text
| MetaRoles
| MetaConnect Text
| MetaSetFormat OutputFormat
| MetaUnknown Text
deriving (Eq, Show)
-- | 反斜杠开头的行是元命令,其余交给 SQL
parseMeta :: Text -> Maybe Meta
parseMeta raw = do
body <- T.stripPrefix "\\" (T.strip raw)
let (command, rest) = T.break isSpace body
argument = T.strip rest
pure $ case T.toLower command of
"q" -> MetaQuit
"quit" -> MetaQuit
"exit" -> MetaQuit
"?" -> MetaHelp
"h" -> MetaHelp
"help" -> MetaHelp
"l" -> MetaDatabases
"list" -> MetaDatabases
"dt" -> MetaTables
"tables" -> MetaTables
"d" | T.null argument -> MetaTables
| otherwise -> MetaDescribe argument
"dr" -> MetaRoles
"roles" -> MetaRoles
"c" -> connectTo argument
"connect" -> connectTo argument
"use" -> connectTo argument
"f" -> selectFormat argument
"format" -> selectFormat argument
other -> MetaUnknown other
where
connectTo argument
| T.null argument = MetaUnknown "c"
| otherwise = MetaConnect argument
selectFormat argument = maybe (MetaUnknown "format") MetaSetFormat (parseFormat argument)
-- | 取出第一条以分号结束的语句与后面的残余;字符串与注释里的分号不算
takeStatement :: Text -> Maybe (Text, Text)
takeStatement = go ScanNormal [] . T.unpack
where
go _ _ [] = Nothing
go ScanNormal acc (';' : rest) = Just (T.strip (T.pack (reverse acc)), T.pack rest)
go ScanNormal acc ('-' : '-' : rest) = go ScanLineComment ('-' : '-' : acc) rest
go ScanNormal acc ('/' : '*' : rest) = go ScanBlockComment ('*' : '/' : acc) rest
go ScanNormal acc ('\'' : rest) = go ScanString ('\'' : acc) rest
go ScanNormal acc (ch : rest) = go ScanNormal (ch : acc) rest
go ScanString acc ('\'' : '\'' : rest) = go ScanString ('\'' : '\'' : acc) rest
go ScanString acc ('\'' : rest) = go ScanNormal ('\'' : acc) rest
go ScanString acc (ch : rest) = go ScanString (ch : acc) rest
go ScanLineComment acc ('\n' : rest) = go ScanNormal ('\n' : acc) rest
go ScanLineComment acc (ch : rest) = go ScanLineComment (ch : acc) rest
go ScanBlockComment acc ('*' : '/' : rest) = go ScanNormal ('/' : '*' : acc) rest
go ScanBlockComment acc (ch : rest) = go ScanBlockComment (ch : acc) rest
data ScanState = ScanNormal | ScanString | ScanLineComment | ScanBlockComment
-- | 攒下来的内容里有没有一条以分号结束的语句
statementComplete :: Text -> Bool
statementComplete = maybe False (const True) . takeStatement
-- | 去掉结尾的空白与分号(-e 直接执行时用)
stripTerminator :: Text -> Text
stripTerminator raw = T.stripEnd (T.dropWhileEnd (== ';') (T.stripEnd raw))
-- | 常见错误给一句怎么查
errorHint :: Text -> Maybe Text
errorHint message
| "unknown table" `T.isInfixOf` lowered = Just "run \\dt to list the tables of the current database"
| "unknown column" `T.isInfixOf` lowered = Just "run \\d <table> to see the columns of a table"
| "unknown database" `T.isInfixOf` lowered = Just "run \\l to list the databases"
| "syntax" `T.isInfixOf` lowered = Just "one statement at a time, and every statement ends with a semicolon"
| "invalid user name or password" `T.isInfixOf` lowered = Just "check --user and the password"
| "sign in first" `T.isInfixOf` lowered = Just "the session expired, restart the CLI"
| "too many failed sign-in attempts" `T.isInfixOf` lowered = Just "wait a few minutes before trying again"
| otherwise = Nothing
where
lowered = T.toLower message
-- | \? 的帮助文本
metaHelp :: Text
metaHelp =
T.unlines
[ "SQL statements end with a semicolon; a statement may span several lines."
, ""
, " \\q, \\quit leave the CLI"
, " \\?, \\h, \\help show this help"
, " \\l, \\list list databases"
, " \\dt, \\d list tables"
, " \\d <table> describe a table"
, " \\dr, \\roles list roles with their grants and members"
, " \\c <database> switch database"
, " \\f, \\format <fmt> output format: " <> T.intercalate ", " (map formatName [FormatTable, FormatJson, FormatCsv])
, ""
, "At the chusql> prompt the arrow keys walk the SQL history."
]
+270
View File
@@ -0,0 +1,270 @@
{-# LANGUAGE OverloadedStrings #-}
module ChuSQL.CLI.Session
( QueryResult (..)
, Session
, newSession
, sessionUser
, sessionIsAdmin
, sessionDatabase
, isPlainIdentifier
, authenticateSession
, switchDatabase
, runStatement
, catalog
, accountTable
, roleViews
, databases
, queryResultJson
) where
import ChuSQL.Model (Value (..))
import ChuSQL.Storage.IPC (TableInfo (..))
import ChuSQL.Syntax.AST (Statement (..))
import ChuSQL.Syntax.Parser (parseStatement)
import ChuSQL.Web.Accounts
import ChuSQL.Web.API (configurePasswordPolicy, systemTableInfo)
import ChuSQL.Web.Auth (Credential, defaultSessionPolicy, newSessionStore)
import ChuSQL.Web.Backend (Backend (..), StatementResult (..))
import ChuSQL.Web.Privileges (PrivilegeCommand, PrivilegeError (..), Privileges, RoleView, authorize, filterTables, listRoleViews, newPrivileges, privilegeCommand, runPrivilegeCommand)
import Data.Aeson (object, (.=))
import qualified Data.Aeson as A
import Data.Char (isAlpha, isAlphaNum)
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.Scientific (fromFloatDigits)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock (getCurrentTime)
-- CLI 会话:直接连存储与引擎,但账号服务与 Web 管理端是同一套
-- (同一个 root 凭据,同一张 __chusql_users)。
data QueryResult = QueryResult
{ qrColumns :: [Text]
, qrRows :: [[Value]]
, qrRowCount :: Int
, qrTruncated :: Bool
, qrDatabase :: Maybe Text
}
deriving (Eq, Show)
data Session = Session
{ ssBackend :: Backend
, ssAccounts :: Accounts
, ssPrivileges :: Privileges
, ssCurrent :: IORef Text
, ssPrincipal :: IORef (Maybe Principal)
}
-- | 开一个会话:账号与角色服务、配置策略都和 Web 端一致
newSession :: Backend -> Credential -> FilePath -> IO Session
newSession backend credential settingsFile = do
sessions <- newSessionStore getCurrentTime defaultSessionPolicy
accounts <- newAccounts backend sessions credential
privileges <- newPrivileges backend
configurePasswordPolicy accounts settingsFile
-- 服务启动后只有系统库:没 USE 之前不预设任何工作库
current <- newIORef ""
principal <- newIORef Nothing
pure (Session backend accounts privileges current principal)
-- | 当前登录的账号名
sessionUser :: Session -> IO (Maybe Text)
sessionUser session = fmap principalName <$> readIORef (ssPrincipal session)
-- | 是不是配置里的 root
sessionIsAdmin :: Session -> IO Bool
sessionIsAdmin session = maybe False principalIsRoot <$> readIORef (ssPrincipal session)
-- | 当前库
sessionDatabase :: Session -> IO Text
sessionDatabase = readIORef . ssCurrent
-- | 与解析器一致的裸标识符判断
isPlainIdentifier :: Text -> Bool
isPlainIdentifier name =
not (T.null name)
&& T.length name <= 64
&& (isAlpha (T.head name) || T.head name == '_')
&& T.all (\ch -> isAlphaNum ch || ch == '_') name
-- | 登录:root 比配置里的凭据,普通账号查 __chusql_users
authenticateSession :: Session -> Text -> Text -> IO (Either Text ())
authenticateSession session user password = do
result <- authenticate (ssAccounts session) user password
case result of
Left err -> pure (Left (accountMessage err))
Right token -> do
who <- currentPrincipal (ssAccounts session) token
case who of
Left err -> pure (Left (accountMessage err))
Right principal -> do
writeIORef (ssPrincipal session) (Just principal)
pure (Right ())
-- | 换库;系统库只有 root 能进
switchDatabase :: Session -> Text -> IO (Either Text ())
switchDatabase session rawName
| not (isPlainIdentifier name) = pure (Left ("not a plain database name: " <> name))
| name == "system" = do
admin <- sessionIsAdmin session
if admin then go else pure (Left "the system database is only available to the administrator")
| otherwise = go
where
name = T.toLower (T.strip rawName)
go = do
backend <- currentBackend session
result <- beStatement backend ("USE " ++ T.unpack name)
case result of
Left err -> pure (Left (T.pack err))
Right _ -> writeIORef (ssCurrent session) name >> pure (Right ())
-- | 跑一条语句:USE、账号语句与角色语句在这里分流,其余交给引擎
-- (普通身份先过一遍权限判定,管理员与未登录直接放行)
runStatement :: Session -> Text -> IO (Either Text QueryResult)
runStatement session sql = case parseStatement (T.unpack sql) of
Right (UseDatabase name) -> do
switched <- switchDatabase session (T.pack name)
case switched of
Left err -> pure (Left err)
Right () -> do
current <- readIORef (ssCurrent session)
pure (Right (emptyResult (Just current)))
Right statement
| Just command <- accountCommand statement -> runAccount session command
| Just command <- privilegeCommand statement -> runPrivilege session command
| otherwise -> do
allowed <- authorized session statement
case allowed of
Left err -> pure (Left err)
Right () -> runPlain session sql
Left _ -> runPlain session sql
-- | 普通身份跑语句前的权限判定;管理员与未登录放行
authorized :: Session -> Statement -> IO (Either Text ())
authorized session statement = do
who <- readIORef (ssPrincipal session)
case who of
Just principal | not (principalIsRoot principal) -> do
database <- readIORef (ssCurrent session)
result <- authorize (ssPrivileges session) principal database statement
pure (either (Left . privilegeMessage) (const (Right ())) result)
_ -> pure (Right ())
-- | 落到引擎/存储层的一条语句
runPlain :: Session -> Text -> IO (Either Text QueryResult)
runPlain session sql = do
backend <- currentBackend session
result <- beStatement backend (T.unpack sql)
pure (either (Left . T.pack) (Right . resultOf) result)
-- | 数据字典(\dt 与 \d 用);普通身份只看得到自己有 SELECT 的表
catalog :: Session -> IO (Either Text [TableInfo])
catalog session = do
current <- readIORef (ssCurrent session)
if T.null current
then pure (Left "no database selected")
else do
backend <- currentBackend session
result <- beCatalog backend
case result of
Left e -> pure (Left (T.pack e))
Right infos -> do
who <- readIORef (ssPrincipal session)
case who of
Just principal | not (principalIsRoot principal) -> do
allowed <- filterTables (ssPrivileges session) principal current (map (T.pack . tiTable) infos)
pure (either (Left . privilegeMessage) (\names -> Right (filter (\info -> T.pack (tiTable info) `elem` names) infos)) allowed)
_ -> pure (Right infos)
-- | 库清单(\l 用)
databases :: Session -> IO (Either Text [Text])
databases session = do
result <- beDatabases (ssBackend session)
pure (either (Left . T.pack) (Right . map T.pack) result)
-- | 系统库里的账号表:列定义与 Web 端共用一份,行数从同一套账号服务来
accountTable :: Session -> IO (Maybe TableInfo)
accountTable session = do
admin <- sessionIsAdmin session
if not admin
then pure Nothing
else do
result <- listAccounts (ssAccounts session)
pure (either (const Nothing) (\accounts -> Just systemTableInfo{tiRows = length accounts}) result)
-- | 角色总览(\dr 用);与 Web 的 /api/roles 一样只给管理员
roleViews :: Session -> IO (Either Text [RoleView])
roleViews session = do
admin <- sessionIsAdmin session
if not admin
then pure (Left "administrator required")
else do
result <- listRoleViews (ssPrivileges session)
pure (either (Left . privilegeMessage) Right result)
-- | 当前库对应的后端
currentBackend :: Session -> IO Backend
currentBackend session = do
name <- readIORef (ssCurrent session)
pure (beWithDatabase (ssBackend session) (T.unpack name))
-- | 账号语句走账号服务,普通账号一律拒绝
runAccount :: Session -> AccountCommand -> IO (Either Text QueryResult)
runAccount session command = do
who <- readIORef (ssPrincipal session)
case who of
Nothing -> pure (Left "sign in first")
Just principal -> do
result <- runAccountCommand (ssAccounts session) principal command
pure (either (Left . accountMessage) (const (Right (emptyResult Nothing))) result)
-- | 角色语句走权限服务,普通账号一律拒绝;当前库决定授权对象的落库口径
runPrivilege :: Session -> PrivilegeCommand -> IO (Either Text QueryResult)
runPrivilege session command = do
who <- readIORef (ssPrincipal session)
case who of
Nothing -> pure (Left "sign in first")
Just principal -> do
database <- readIORef (ssCurrent session)
result <- runPrivilegeCommand (ssPrivileges session) principal database command
pure (either (Left . privilegeMessage) (const (Right (emptyResult Nothing))) result)
accountMessage :: AccountError -> Text
accountMessage (AccountError _ message) = message
privilegeMessage :: PrivilegeError -> Text
privilegeMessage (PrivilegeError _ message) = message
emptyResult :: Maybe Text -> QueryResult
emptyResult database = QueryResult [] [] 0 False database
resultOf :: StatementResult -> QueryResult
resultOf result =
QueryResult
{ qrColumns = map T.pack (srColumns result)
, qrRows = [map (cellOf row) (srColumns result) | row <- srRows result]
, qrRowCount = length (srRows result)
, qrTruncated = False
, qrDatabase = Nothing
}
where
cellOf row name = maybe VNull id (lookup name row)
-- | 结果还原成 JSON(JSON 输出用)
queryResultJson :: QueryResult -> A.Value
queryResultJson result =
object
[ "columns" .= qrColumns result
, "rows" .= map (map valueToJson) (qrRows result)
, "rowCount" .= qrRowCount result
, "truncated" .= qrTruncated result
, "database" .= qrDatabase result
]
valueToJson :: Value -> A.Value
valueToJson VNull = A.Null
valueToJson (VInt n) = A.Number (fromIntegral n)
valueToJson (VFloat d) = A.Number (fromFloatDigits d)
valueToJson (VStr s) = A.String (T.pack s)
valueToJson (VBool b) = A.Bool b
+6
View File
@@ -0,0 +1,6 @@
resolver: lts-24.59
packages:
- .
- ../chusql-engine
- ../chusql-web
File renamed without changes.
+251
View File
@@ -0,0 +1,251 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}
module Main (main) where
import ChuSQL.CLI.Format (OutputFormat (..), parseFormat, renderCsv, renderJson, renderTable)
import ChuSQL.CLI.Script (Meta (..), errorHint, parseMeta, statementComplete, stripTerminator, takeStatement)
import ChuSQL.CLI.Session (
QueryResult (..),
Session,
authenticateSession,
catalog,
databases,
newSession,
queryResultJson,
roleViews,
runStatement,
sessionDatabase,
sessionIsAdmin,
switchDatabase,
)
import ChuSQL.Model (Database, Table (..), Value (..), pattern TInt, pattern TStr)
import ChuSQL.Storage.IPC (TableInfo (..))
import ChuSQL.Web.Auth (Credential (..), hashPasswordWith)
import ChuSQL.Web.Backend (memoryBackend)
import ChuSQL.Web.Privileges (Grant (..), RoleView (..))
import Control.Concurrent.MVar (newMVar)
import Data.Aeson (decode)
import qualified Data.Aeson as A
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as BL
import Data.Either (isLeft, isRight)
import Data.List (nub, sort)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Test.Hspec
-- chusql-cli 测试:输出格式、交互层纯逻辑与共享账号会话。
main :: IO ()
main = hspec spec
spec :: Spec
spec = do
formatSpec
scriptSpec
sessionSpec
roleSpec
formatSpec :: Spec
formatSpec = describe "CLI: output formats" $ do
it "renders a boxed table and marks NULL" $
renderTable sampleResult
`shouldBe` T.unlines
[ "+-----+--------+"
, "| id | name |"
, "+-----+--------+"
, "| 1 | Alice |"
, "| 2 | NULL |"
, "+-----+--------+"
, "(2 rows)"
]
it "renders CSV with RFC 4180 quoting and empty NULL fields" $
renderCsv
QueryResult
{ qrColumns = ["id", "name"]
, qrRows = [[VInt 1, VStr "a,b"], [VNull, VStr "say \"hi\""]]
, qrRowCount = 2
, qrTruncated = False
, qrDatabase = Nothing
}
`shouldBe` "id,name\n1,\"a,b\"\n,\"say \"\"hi\"\"\"\n"
it "renders JSON that parses back to the same result" $
(decode (BL.fromStrict (encodeUtf8 (renderJson sampleResult))) :: Maybe A.Value)
`shouldBe` Just (queryResultJson sampleResult)
it "accepts table/json/csv in any case and rejects anything else" $ do
parseFormat "TABLE" `shouldBe` Just FormatTable
parseFormat " json " `shouldBe` Just FormatJson
parseFormat "csv" `shouldBe` Just FormatCsv
parseFormat "xml" `shouldBe` Nothing
scriptSpec :: Spec
scriptSpec = do
describe "CLI: statement buffering" $ do
it "a statement is complete only at an unquoted semicolon" $ do
statementComplete "SELECT 1;" `shouldBe` True
statementComplete "SELECT 1" `shouldBe` False
statementComplete "SELECT ';'" `shouldBe` False
statementComplete "SELECT 'a''b'" `shouldBe` False
statementComplete "SELECT 1 -- ;" `shouldBe` False
statementComplete "SELECT /* ; */ 1" `shouldBe` False
statementComplete "SELECT /* ; */ 1;" `shouldBe` True
it "splits the first statement off and keeps the rest" $
takeStatement "SELECT 1; SELECT 2;"
`shouldBe` Just ("SELECT 1", " SELECT 2;")
it "drops the terminator before sending SQL to the server" $ do
stripTerminator "SELECT 1 ; " `shouldBe` "SELECT 1"
stripTerminator "SELECT 1" `shouldBe` "SELECT 1"
it "suggests how to look around after a common error" $ do
errorHint "unknown table: nope" `shouldBe` Just "run \\dt to list the tables of the current database"
errorHint "unknown column: nope" `shouldBe` Just "run \\d <table> to see the columns of a table"
errorHint "something else" `shouldBe` Nothing
describe "CLI: meta commands" $ do
it "parses the backslash commands" $ do
parseMeta "\\q" `shouldBe` Just MetaQuit
parseMeta "\\?" `shouldBe` Just MetaHelp
parseMeta "\\l" `shouldBe` Just MetaDatabases
parseMeta "\\dt" `shouldBe` Just MetaTables
parseMeta "\\d" `shouldBe` Just MetaTables
parseMeta "\\d users" `shouldBe` Just (MetaDescribe "users")
parseMeta "\\dr" `shouldBe` Just MetaRoles
parseMeta "\\roles" `shouldBe` Just MetaRoles
parseMeta "\\c sales" `shouldBe` Just (MetaConnect "sales")
parseMeta "\\format CSV" `shouldBe` Just (MetaSetFormat FormatCsv)
it "leaves ordinary SQL alone" $ do
parseMeta "SELECT 1" `shouldBe` Nothing
parseMeta "\\nope" `shouldBe` Just (MetaUnknown "nope")
parseMeta "\\f xml" `shouldBe` Just (MetaUnknown "format")
sessionSpec :: Spec
sessionSpec = describe "CLI: session over the shared account service" $ do
it "signs in as the configured administrator and refuses a wrong password" $ do
session <- cliSession
authenticateSession session "admin" "wrong-password" >>= \answer -> isLeft answer `shouldBe` True
authenticateSession session "admin" "s3cret" >>= \answer -> isRight answer `shouldBe` True
it "runs SQL straight through the engine" $ do
session <- cliSession
signInRoot session
result <- runStatement session "SELECT id, name FROM users ORDER BY id LIMIT 1"
fmap qrColumns result `shouldBe` Right ["id", "name"]
fmap qrRowCount result `shouldBe` Right 1
it "starts with nothing selected, lists what exists and refuses an unknown USE" $ do
session <- cliSession
authenticateSession session "admin" "s3cret" >>= (`shouldBe` Right ())
sessionDatabase session >>= (`shouldBe` "")
runStatement session "SELECT id FROM users" >>= \answer -> answer `shouldBe` Left "no database selected"
listed <- databases session
listed `shouldBe` Right ["test", "system"]
runStatement session "USE missing" >>= \answer -> isLeft answer `shouldBe` True
sessionDatabase session >>= (`shouldBe` "")
runStatement session "USE test" >>= \answer -> isRight answer `shouldBe` True
sessionDatabase session >>= (`shouldBe` "test")
it "keeps accounts on the same store and system administrator-only" $ do
session <- cliSession
signInRoot session
runStatement session "CREATE USER alice IDENTIFIED BY 'alice-password-1'" >>= \answer -> isRight answer `shouldBe` True
authenticateSession session "alice" "alice-password-1" >>= \answer -> isRight answer `shouldBe` True
sessionIsAdmin session >>= (`shouldBe` False)
switchDatabase session "system" >>= \answer -> isLeft answer `shouldBe` True
runStatement session "DROP USER admin" >>= \answer -> isLeft answer `shouldBe` True
roleSpec :: Spec
roleSpec = describe "CLI: roles and grants over the shared privilege service" $ do
it "runs role statements as the administrator and refuses them for anyone else" $ do
session <- cliSession
signInRoot session
created <- runStatement session "CREATE ROLE analyst"
isRight created `shouldBe` True
duplicate <- runStatement session "CREATE ROLE analyst"
duplicate `shouldBe` Left "role already exists: analyst"
granted <- runStatement session "GRANT SELECT ON users TO analyst"
isRight granted `shouldBe` True
tallied <- runStatement session "GRANT SELECT, INSERT, UPDATE, DELETE ON users TO analyst"
isRight tallied `shouldBe` True
views <- roleViews session
fmap (map roleName) views `shouldBe` Right ["analyst"]
fmap (sort . nub . concatMap (map grantPrivilege . roleGrants)) views `shouldBe` Right ["delete", "insert", "select", "update"]
fmap (sort . nub . concatMap (map grantObject . roleGrants)) views `shouldBe` Right ["test.users"]
unknown <- runStatement session "GRANT SELECT ON users TO nobody"
unknown `shouldBe` Left "unknown role: nobody"
dropped <- runStatement session "DROP ROLE analyst"
isRight dropped `shouldBe` True
missing <- runStatement session "GRANT SELECT ON users TO analyst"
missing `shouldBe` Left "unknown role: analyst"
it "an ordinary account only gets what a grant allows" $ do
session <- cliSession
signInRoot session
runStatement session "CREATE TABLE secrets (id INT)" >>= \answer -> isRight answer `shouldBe` True
runStatement session "CREATE ROLE reader" >>= \answer -> isRight answer `shouldBe` True
runStatement session "GRANT SELECT ON users TO reader" >>= \answer -> isRight answer `shouldBe` True
runStatement session "CREATE USER bob IDENTIFIED BY 'bob-password-1'" >>= \answer -> isRight answer `shouldBe` True
runStatement session "GRANT reader TO bob" >>= \answer -> isRight answer `shouldBe` True
authenticateSession session "bob" "bob-password-1" >>= \answer -> isRight answer `shouldBe` True
granted <- runStatement session "SELECT id, name FROM users"
fmap qrRowCount granted `shouldBe` Right 3
refused <- runStatement session "SELECT id FROM secrets"
refused `shouldBe` Left "permission denied: SELECT ON secrets"
write <- runStatement session "INSERT INTO users (id, name) VALUES (4, 'four')"
write `shouldBe` Left "permission denied: INSERT ON users"
admin <- runStatement session "CREATE ROLE other"
admin `shouldBe` Left "administrator required"
listed <- catalog session
fmap (map tiTable) listed `shouldBe` Right ["users"]
hidden <- roleViews session
hidden `shouldBe` Left "administrator required"
it "revoking the membership closes the door again" $ do
session <- cliSession
signInRoot session
runStatement session "CREATE ROLE guest" >>= \answer -> isRight answer `shouldBe` True
runStatement session "GRANT SELECT ON users TO guest" >>= \answer -> isRight answer `shouldBe` True
runStatement session "CREATE USER carol IDENTIFIED BY 'carol-password-1'" >>= \answer -> isRight answer `shouldBe` True
runStatement session "GRANT guest TO carol" >>= \answer -> isRight answer `shouldBe` True
roster <- roleViews session
fmap (concatMap roleMembers) roster `shouldBe` Right ["carol"]
authenticateSession session "carol" "carol-password-1" >>= \answer -> isRight answer `shouldBe` True
runStatement session "SELECT id FROM users" >>= \answer -> isRight answer `shouldBe` True
signInRoot session
runStatement session "REVOKE guest FROM carol" >>= \answer -> isRight answer `shouldBe` True
authenticateSession session "carol" "carol-password-1" >>= \answer -> isRight answer `shouldBe` True
refused <- runStatement session "SELECT id FROM users"
refused `shouldBe` Left "permission denied: SELECT ON users"
cliSession :: IO Session
cliSession = do
db <- newMVar testDb
newSession (memoryBackend testDatabaseName db) testCredential "chusql-test-missing.settings.json"
-- | 夹具库名:服务不再有默认库,这个库要显式选
testDatabaseName :: String
testDatabaseName = "test"
-- | 登录管理员并选中夹具库(未选库时连表名都解析不了)
signInRoot :: Session -> Expectation
signInRoot session = do
authenticateSession session "admin" "s3cret" >>= (`shouldBe` Right ())
switchDatabase session (T.pack testDatabaseName) >>= (`shouldBe` Right ())
sampleResult :: QueryResult
sampleResult =
QueryResult
{ qrColumns = ["id", "name"]
, qrRows = [[VInt 1, VStr "Alice"], [VInt 2, VNull]]
, qrRowCount = 2
, qrTruncated = False
, qrDatabase = Nothing
}
-- | 测试库:三行 users
testDb :: Database
testDb =
[ ( "users"
, Table
"users"
[("id", TInt), ("name", TStr)]
[[("id", VInt i), ("name", VStr ("user" ++ show i))] | i <- [1 .. 3]]
)
]
-- | 测试账号:迭代次数压低(跑得快),口令 `s3cret`
testCredential :: Credential
testCredential = Credential "admin" (hashPasswordWith 1000 (BS.replicate 16 7) "s3cret")
+1
View File
@@ -28,6 +28,7 @@ library
, bytestring
, hashable
, megaparsec
, network
, parser-combinators
, scientific
, text
+440 -107
View File
@@ -1,147 +1,480 @@
module ChuSQL.Algebra.Eval (evalRelOp, evalRelOpM) where
module ChuSQL.Algebra.Eval (evalRelOp, evalRelOpM, evalExprM, evalCondForRowM) where
import ChuSQL.Algebra.Expr (evalCondForRow)
import ChuSQL.Algebra.Expr (evalCondForRow, evalExpr)
import ChuSQL.Algebra.Optimize (optimize, relOpCols)
import ChuSQL.Algebra.Op
import ChuSQL.Algebra.Sort (sortRows)
import ChuSQL.Algebra.Planner (translate)
import ChuSQL.Algebra.Sort (compareValue, sortRows)
import ChuSQL.Model
import ChuSQL.Storage (IndexResult (..), MonadStorage (..))
import ChuSQL.Syntax.AST (Expr (..))
import ChuSQL.Storage.Memory (runMemoryStorage)
import ChuSQL.Syntax.AST (Expr (..), JoinKind (..), Subquery (..))
import Control.Monad (filterM, foldM)
import qualified Data.HashMap.Strict as HM
-- 执行:按算子树算出结果行(Scan 阶段会加别名前缀)。
-- 外层行 outer 给相关子查询用:子查询里的列先在本层行里找,找不到再落到外层行。
-- | 只保留清单里的列
-- | 只保留清单里的列,顺序按清单(SELECT 的列序就是结果的列序)
project :: [String] -> Row -> Row
project ["*"] row = row
project cs row = [(c, v) | (c, v) <- row, c `elem` cs]
project cs row = [(c, v) | c <- cs, Just v <- [lookup c row]]
-- | 给列名加别名前缀
addPrefix :: Maybe String -> Row -> Row
addPrefix mAlias = map (\(k, v) -> (qualify mAlias k, v))
-- | 扫描一张表
evalScan :: Database -> Maybe String -> String -> Either String [Row]
evalScan db mAlias tbl = do
table <- lookupTable db tbl
Right (map (addPrefix mAlias) (tableRows table))
prefixRows :: Maybe String -> [Row] -> [Row]
prefixRows mAlias = map (addPrefix mAlias)
-- | 值 → 字面量
valueLiteral :: Value -> Expr
valueLiteral (VInt n) = LitInt n
valueLiteral (VFloat d) = LitFloat d
valueLiteral (VStr s) = LitStr s
valueLiteral (VBool b) = LitBool b
valueLiteral VNull = LitNull
-- | 点查退化成扫描时用的条件
lookupCondition :: Maybe String -> String -> Int -> Expr
lookupCondition mAlias col k = Eq (Col (qualify mAlias col)) (LitInt k)
lookupCondition :: Maybe String -> String -> Value -> Expr
lookupCondition mAlias col k = Eq (Col (qualify mAlias col)) (valueLiteral k)
-- | 范围扫描退化成扫描时用的条件(闭端写成 > 或 = 的并)
rangeCondition :: Maybe String -> String -> Maybe (Value, Bool) -> Maybe (Value, Bool) -> Expr
rangeCondition mAlias col lo hi = case (lo, hi) of
(Just l, Just h) -> And (bound Gt l) (bound Lt h)
(Just l, Nothing) -> bound Gt l
(Nothing, Just h) -> bound Lt h
(Nothing, Nothing) -> LitBool True
where
c = Col (qualify mAlias col)
bound rel (v, inclusive)
| inclusive = Or (rel c (valueLiteral v)) (Eq c (valueLiteral v))
| otherwise = rel c (valueLiteral v)
-- | 一批行的列名(空批给空清单)
rowsCols :: [Row] -> [String]
rowsCols (r : _) = map fst r
rowsCols [] = []
-- | 参与等值匹配的键:NULL 不参与匹配,整数值的浮点归一到整数
-- (嵌套循环走 compareValues,哈希要对齐它)
joinKey :: Value -> Maybe Value
joinKey VNull = Nothing
joinKey (VFloat d)
| not (isNaN d)
, not (isInfinite d)
, fromIntegral (rounded :: Integer) == d =
Just (VInt (fromIntegral rounded))
where
rounded = round d :: Integer
joinKey v = Just v
joinKeyOf :: String -> Row -> Maybe Value
joinKeyOf c row = lookup c row >>= joinKey
data EquiKeys = EquiKeys
{ ekLeft :: String
, ekRight :: String
}
-- | 一批行的列名(空批给空清单,此时一律退回嵌套循环)
rowsCols :: [Row] -> [String]
rowsCols (r : _) = map fst r
rowsCols [] = []
-- | 认出 a = b 这种两列等值条件,并且能分清谁在左谁在右
equiKeys :: [Row] -> [Row] -> Expr -> Maybe EquiKeys
equiKeys lrows rrows (Eq (Col a) (Col b))
equiKeys :: [String] -> [String] -> Expr -> Maybe EquiKeys
equiKeys lcols rcols (Eq (Col a) (Col b))
| a /= b, a `elem` lcols, b `elem` rcols = Just (EquiKeys a b)
| a /= b, b `elem` lcols, a `elem` rcols = Just (EquiKeys b a)
where
lcols = rowsCols lrows
rcols = rowsCols rrows
equiKeys _ _ _ = Nothing
-- | 哈希连接,输出顺序同嵌套循环
hashJoin :: EquiKeys -> [Row] -> [Row] -> [Row]
hashJoin keys lrows rrows = concatMap probe lrows
-- | 连接:能认出等值键就走哈希,否则退回嵌套循环;左连接没配上的左行补 NULL
joinRowsIn ::
(MonadStorage m) =>
Database ->
Row ->
JoinKind ->
[String] ->
Expr ->
[Row] ->
[Row] ->
m (Either String [Row])
joinRowsIn db outer kind rcols cond lrows rrows =
case equiKeys (rowsCols lrows) rcols cond of
Just ks -> pure (Right (hashJoin kind ks ncols lrows rrows))
Nothing -> filterPairsM db outer kind ncols cond lrows rrows
where
buckets =
HM.fromListWith
(flip (++))
[(v, [r]) | r <- rrows, Just v <- [lookup (ekRight keys) r]]
probe l = case lookup (ekLeft keys) l of
Nothing -> []
Just v -> [l ++ r | r <- HM.lookupDefault [] v buckets]
-- 右表为空时行上没有列名,用算子推导出来的列名补 NULL
ncols = if null rrows then rcols else rowsCols rrows
-- | 左连接里配不上的左行补右表 NULL;内连接里没有匹配就是没行
unmatched :: JoinKind -> Row -> [String] -> [Row]
unmatched InnerJoin _ _ = []
unmatched LeftJoin l cols = [l ++ [(c, VNull) | c <- cols]]
-- | 哈希连接,输出顺序同嵌套循环
hashJoin :: JoinKind -> EquiKeys -> [String] -> [Row] -> [Row] -> [Row]
hashJoin kind ks ncols lrows rrows = concatMap probe lrows
where
buckets = HM.fromListWith (flip (++)) [(v, [r]) | r <- rrows, Just v <- [joinKeyOf (ekRight ks) r]]
probe l =
let miss = case kind of
InnerJoin -> []
LeftJoin -> [l ++ nullRow]
in case joinKeyOf (ekLeft ks) l of
Nothing -> miss
Just v -> case HM.lookupDefault [] v buckets of
[] -> miss
rs -> [l ++ r | r <- rs]
nullRow = [(c, VNull) | c <- ncols]
-- | 把两边的行按条件配对(边配边筛,不先建整张积表)
filterPairs :: (Row -> Either String Bool) -> [Row] -> [Row] -> Either String [Row]
filterPairs cond lrows rrows = go lrows []
filterPairsM ::
(MonadStorage m) =>
Database ->
Row ->
JoinKind ->
[String] ->
Expr ->
[Row] ->
[Row] ->
m (Either String [Row])
filterPairsM db outer kind ncols cond lrows rrows = go lrows []
where
go [] acc = Right (reverse acc)
go [] acc = pure (Right (reverse acc))
go (l : ls) acc = do
acc' <- foldM (keep l) acc rrows
go ls acc'
keep l acc r = do
let row = l ++ r
ok <- cond row
pure (if ok then row : acc else acc)
hits <- keep l rrows []
case hits of
Left e -> pure (Left e)
Right hs -> do
let hs' = case (kind, hs) of
(LeftJoin, []) -> [l ++ nullRow]
_ -> hs
go ls (reverse hs' ++ acc)
keep _ [] acc = pure (Right (reverse acc))
keep l (r : rs) acc = do
ok <- evalCondForRowM db cond (l ++ r ++ outer)
case ok of
Left e -> pure (Left e)
Right True -> keep l rs ((l ++ r) : acc)
Right False -> keep l rs acc
nullRow = [(c, VNull) | c <- ncols]
-- | 一次连接:能认出等值键就走哈希,否则退回嵌套循环
joinRows :: Expr -> [Row] -> [Row] -> Either String [Row]
joinRows cond lrows rrows = case equiKeys lrows rrows cond of
Just keys -> Right (hashJoin keys lrows rrows)
Nothing -> filterPairs (evalCondForRow cond) lrows rrows
-- | 索引连接:右表是裸 Scan 时逐左行点查,返回 Nothing 表示该走通用路径
probeIndexJoin ::
(MonadStorage m) =>
Database ->
Row ->
JoinKind ->
Maybe String ->
String ->
EquiKeys ->
[String] ->
Expr ->
Row ->
m (Either String (Maybe [Row]))
probeIndexJoin db outer kind mAlias t ks rcols cond l = case lookup (ekLeft ks) l of
Nothing -> pure (Right (Just (unmatched kind l rcols)))
Just k
| not (indexSafe db t (unqualify mAlias (ekRight ks)) k) -> pure (Right Nothing)
| otherwise -> do
res <- lookupByColumn t (unqualify mAlias (ekRight ks)) k
case res of
Left e -> pure (Left e)
Right NoIndex -> pure (Right Nothing)
Right (IndexRows rows) -> do
pairs <- joinRowsIn db outer kind rcols cond [l] (prefixRows mAlias rows)
pure (fmap Just pairs)
-- | 纯求值(不需要存储)
-- | 索引按值的相等匹配,而数值比较会把 2 和 2.0 看成相等,
-- 所以只有非浮点列 + 非浮点键才敢用索引,保证优化不改变结果语义
indexSafe :: Database -> String -> String -> Value -> Bool
indexSafe db t col k = case lookupTable db t of
Left _ -> False
Right tbl -> case lookup col (tableCols tbl) of
Nothing -> False
Just c -> not (floatType (columnType c)) && not (floatValue k)
floatType :: ColumnType -> Bool
floatType CFloat = True
floatType CDouble = True
floatType (CDecimal _ _) = True
floatType _ = False
floatValue :: Value -> Bool
floatValue (VFloat _) = True
floatValue _ = False
-- | 分组键的值
groupKey :: [String] -> Row -> Either String [Value]
groupKey keys row = mapM get keys
where
get c = maybe (Left ("unknown column: " ++ c)) Right (lookup c row)
-- | 按分组键把行分组,保留分组首次出现的顺序
groupRows :: [String] -> [Row] -> Either String [([Value], [Row])]
groupRows keys rows = go rows (HM.empty, [])
where
go [] (acc, order) = Right [(k, reverse (HM.lookupDefault [] k acc)) | k <- reverse order]
go (r : rs) (acc, order) = do
k <- groupKey keys r
let order' = if HM.member k acc then order else k : order
go rs (HM.insertWith (++) k [r] acc, order')
-- | 一组行上求同一个表达式(外层列从 outer 取)
valuesOf :: Row -> Expr -> [Row] -> Either String [Value]
valuesOf outer e rows = mapM (evalExpr e . (++ outer)) rows
-- | 去掉 NULL
nonNull :: [Value] -> [Value]
nonNull = filter (/= VNull)
-- | 数值相加:整数配整数还是整数
addValue :: Value -> Value -> Either String Value
addValue (VInt a) (VInt b) = Right (VInt (a + b))
addValue (VInt a) (VFloat b) = Right (VFloat (fromIntegral a + b))
addValue (VFloat a) (VInt b) = Right (VFloat (a + fromIntegral b))
addValue (VFloat a) (VFloat b) = Right (VFloat (a + b))
addValue _ _ = Left "type error: expected two numbers"
-- | 能当数用的值
numberOf :: Value -> Either String Double
numberOf (VInt n) = Right (fromIntegral n)
numberOf (VFloat d) = Right d
numberOf _ = Left "type error: expected a number"
-- | 一组值里的极值(NULL 已经先剔掉)
extremeOf :: (Value -> Value -> Value) -> [Value] -> Either String Value
extremeOf _ [] = Right VNull
extremeOf pick (x : xs) = Right (foldl pick x xs)
-- | 一个聚合算子在一组行上的结果
evalAgg :: Row -> Expr -> [Row] -> Either String Value
evalAgg outer agg rows = case agg of
CountAll -> Right (VInt (length rows))
CountOf e -> do
vs <- valuesOf outer e rows
Right (VInt (length (nonNull vs)))
SumOf e -> do
vs <- valuesOf outer e rows
case nonNull vs of
[] -> Right VNull
xs -> foldM addValue (VInt 0) xs
AvgOf e -> do
vs <- valuesOf outer e rows
case nonNull vs of
[] -> Right VNull
xs -> do
total <- foldM addValue (VInt 0) xs
sumOf <- numberOf total
Right (VFloat (sumOf / fromIntegral (length xs)))
MinOf e -> valuesOf outer e rows >>= extremeOf lower . nonNull
MaxOf e -> valuesOf outer e rows >>= extremeOf higher . nonNull
_ -> Left "only COUNT, SUM, AVG, MIN and MAX can be aggregated"
where
lower a b = if compareValue b a == LT then b else a
higher a b = if compareValue b a == GT then b else a
-- | 一组的输出行:分组列 + 各聚合值
oneGroup :: Row -> [String] -> [(String, Expr)] -> ([Value], [Row]) -> Either String Row
oneGroup outer keys aggs (keyValues, rows) = do
values <- mapM (\(_, e) -> evalAgg outer e rows) aggs
Right (zip keys keyValues ++ zip (map fst aggs) values)
-- | 分组求值:没有分组列时,即使没有行也要出一行
evalAggregate :: Row -> [String] -> [(String, Expr)] -> [Row] -> Either String [Row]
evalAggregate outer keys aggs rows = do
groups <- groupRows keys rows
let top = if null keys && null groups then [([], [])] else groups
mapM (oneGroup outer keys aggs) top
-- | 单子过滤:条件本身是单子求值的
filterRowsM :: (Monad m) => (Row -> m (Either String Bool)) -> [Row] -> m (Either String [Row])
filterRowsM p = go []
where
go acc [] = pure (Right (reverse acc))
go acc (r : rs) = do
keep <- p r
case keep of
Left e -> pure (Left e)
Right True -> go (r : acc) rs
Right False -> go acc rs
-- | 按投影顺序逐项求值(可以带子查询)
computeM :: (MonadStorage m) => Database -> Row -> [(String, Expr)] -> Row -> m (Either String Row)
computeM db outer items row = go items []
where
go [] acc = pure (Right (reverse acc))
go ((label, e) : rest) acc = do
v <- evalExprM db e (row ++ outer)
case v of
Left err -> pure (Left err)
Right val -> go rest ((label, val) : acc)
-- | 表达式求值(先把子查询求成字面量)
evalExprM :: (MonadStorage m) => Database -> Expr -> Row -> m (Either String Value)
evalExprM db e env = do
e' <- substSubqueries db e env
pure (e' >>= \x -> evalExpr x env)
-- | 条件求值(先把子查询求成字面量)
evalCondForRowM :: (MonadStorage m) => Database -> Expr -> Row -> m (Either String Bool)
evalCondForRowM db e env = do
e' <- substSubqueries db e env
pure (e' >>= \x -> evalCondForRow x env)
-- | 子查询结果里唯一那一列的值
rowValue :: Row -> Either String Value
rowValue [(_, v)] = Right v
rowValue _ = Left "subquery must return exactly one column"
-- | 标量子查询:0 行给 NULL,多行报错
scalarResult :: Either String [Row] -> Either String Expr
scalarResult rows = do
rs <- rows
case rs of
[] -> Right LitNull
[r] -> valueLiteral <$> rowValue r
_ -> Left "scalar subquery returned more than one row"
-- | 把子查询求值成字面量;子查询的错直接往上传
substSubqueries :: (MonadStorage m) => Database -> Expr -> Row -> m (Either String Expr)
substSubqueries db e env = case e of
ScalarSub sq -> scalarResult <$> runSubqueryRows db sq env
InSub a sq negated -> do
lhs <- substSubqueries db a env
rows <- runSubqueryRows db sq env
pure (do
a' <- lhs
rs <- rows
vs <- mapM rowValue rs
Right (InList a' (map valueLiteral vs) negated))
ExistsSub sq negated -> do
rows <- runSubqueryRows db sq env
pure (fmap (\rs -> LitBool (if negated then null rs else not (null rs))) rows)
InList a es negated -> do
lhs <- substSubqueries db a env
es' <- mapM (\x -> substSubqueries db x env) es
pure (do
a' <- lhs
items <- sequence es'
Right (InList a' items negated))
Add a b -> bin Add a b
Sub a b -> bin Sub a b
Mul a b -> bin Mul a b
Div a b -> bin Div a b
Neg a -> un Neg a
Gt a b -> bin Gt a b
Lt a b -> bin Lt a b
Eq a b -> bin Eq a b
And a b -> bin And a b
Or a b -> bin Or a b
IsNull a -> un IsNull a
IsNotNull a -> un IsNotNull a
CountOf a -> un CountOf a
SumOf a -> un SumOf a
AvgOf a -> un AvgOf a
MinOf a -> un MinOf a
MaxOf a -> un MaxOf a
_ -> pure (Right e)
where
bin ctor a b = do
ra <- substSubqueries db a env
case ra of
Left err -> pure (Left err)
Right a' -> do
rb <- substSubqueries db b env
pure ((ctor a') <$> rb)
un ctor a = do
ra <- substSubqueries db a env
pure (ctor <$> ra)
-- | 跑子查询:翻译成算子树再求值,外层行做相关子查询的绑定
runSubqueryRows :: (MonadStorage m) => Database -> Subquery -> Row -> m (Either String [Row])
runSubqueryRows db sq outer = case translate (subqueryStatement sq) of
Left e -> pure (Left e)
Right op -> evalRelOpIn db outer (optimize db op)
-- | 单子求值:Scan/点查/范围查都问存储
evalRelOpM :: (MonadStorage m) => Database -> RelOp -> m (Either String [Row])
evalRelOpM db = evalRelOpIn db []
-- | 纯求值(内存存储,子查询走同一条路径)
evalRelOp :: Database -> RelOp -> Either String [Row]
evalRelOp db (Scan mAlias tbl) = evalScan db mAlias tbl
evalRelOp db (Lookup mAlias tbl col k) = do
rows <- evalScan db mAlias tbl
filterM (evalCondForRow (lookupCondition mAlias col k)) rows
evalRelOp db (Filter e op) = do
rows <- evalRelOp db op
filterM (evalCondForRow e) rows
evalRelOp db (Project cols op) = do
rows <- evalRelOp db op
Right (map (project cols) rows)
evalRelOp db (Sort spec op) = do
rows <- evalRelOp db op
Right (sortRows spec rows)
evalRelOp db (Limit n op) = do
rows <- evalRelOp db op
Right (take n rows)
evalRelOp db (Join left right cond) = do
lrows <- evalRelOp db left
rrows <- evalRelOp db right
joinRows cond lrows rrows
evalRelOp db op = do
(result, _) <- runMemoryStorage (evalRelOpIn db [] op) db
result
-- | 单子求值:点查先问存储有无索引
evalRelOpM :: (MonadStorage m) => RelOp -> m (Either String [Row])
evalRelOpM (Scan mAlias t) = do
result <- scan t
pure $ do
rows <- result
case mAlias of
Nothing -> Right rows
Just _ -> Right (map (addPrefix mAlias) rows)
evalRelOpM (Lookup mAlias t col k) = do
result <- lookupByColumn t col k
case result of
-- | 带外层行的执行器
evalRelOpIn :: (MonadStorage m) => Database -> Row -> RelOp -> m (Either String [Row])
evalRelOpIn _ _ Unit = pure (Right [[]])
evalRelOpIn db outer (Compute items op) = do
rows <- evalRelOpIn db outer op
case rows of
Left e -> pure (Left e)
Right NoIndex -> do
rows <- evalRelOpM (Scan mAlias t)
pure $ do
rs <- rows
filterM (evalCondForRow (lookupCondition mAlias col k)) rs
Right (IndexRow Nothing) -> pure (Right [])
Right (IndexRow (Just row)) -> pure (Right [addPrefix mAlias row])
evalRelOpM (Filter p x) = do
result <- evalRelOpM x
Right rs -> do
computed <- mapM (computeM db outer items) rs
pure (sequence computed)
evalRelOpIn db outer (Aggregate keys aggs op) = do
rows <- evalRelOpIn db outer op
pure (rows >>= evalAggregate outer keys aggs)
evalRelOpIn _ _ (Scan mAlias tbl cols) = do
res <- maybe (scan tbl) (scanColumns tbl) cols
pure (fmap (prefixRows mAlias) res)
evalRelOpIn db outer (Lookup mAlias tbl col k) = do
res <- lookupByColumn tbl col k
case res of
Left e -> pure (Left e)
Right NoIndex -> scanFiltered db outer mAlias tbl (lookupCondition mAlias col k)
Right (IndexRows rows) -> pure (Right (prefixRows mAlias rows))
evalRelOpIn db outer (Range mAlias tbl col lo hi) = do
res <- scanRange tbl col lo hi
case res of
Left e -> pure (Left e)
Right Nothing -> scanFiltered db outer mAlias tbl (rangeCondition mAlias col lo hi)
Right (Just rows) -> pure (Right (prefixRows mAlias rows))
evalRelOpIn db outer (Filter e op) = do
rows <- evalRelOpIn db outer op
case rows of
Left err -> pure (Left err)
Right rs -> filterRowsM (\r -> evalCondForRowM db e (r ++ outer)) rs
evalRelOpIn db outer (Project cols op) = do
rows <- evalRelOpIn db outer op
pure (fmap (map (project cols)) rows)
evalRelOpIn db outer (Sort spec op) = do
rows <- evalRelOpIn db outer op
pure (fmap (sortRows spec) rows)
evalRelOpIn db outer (Limit n op) = do
rows <- evalRelOpIn db outer op
pure (fmap (take n) rows)
evalRelOpIn db outer (Join kind l r c) = do
lres <- evalRelOpIn db outer l
case lres of
Left e -> pure (Left e)
Right ls -> do
let nested = do
rres <- evalRelOpIn db outer r
case rres of
Left e -> pure (Left e)
Right rs -> joinRowsIn db outer kind (relOpCols db r) c ls rs
case (r, equiKeys (relOpCols db l) (relOpCols db r) c) of
(Scan mAlias t _, Just ks) -> do
probed <- mapM (probeIndexJoin db outer kind mAlias t ks (relOpCols db r) c) ls
case sequence probed of
Left e -> pure (Left e)
Right ms -> case sequence ms of
Just rows -> pure (Right (concat rows))
Nothing -> nested
_ -> nested
-- | 扫描后按条件过滤(点查/范围查退化成扫描时用)
scanFiltered ::
(MonadStorage m) =>
Database ->
Row ->
Maybe String ->
String ->
Expr ->
m (Either String [Row])
scanFiltered _ outer mAlias tbl cond = do
res <- scan tbl
pure $ do
rows <- result
filterM (evalCondForRow p) rows
evalRelOpM (Project cols x) = do
result <- evalRelOpM x
pure (fmap (map (project cols)) result)
evalRelOpM (Sort spec x) = do
result <- evalRelOpM x
pure (fmap (sortRows spec) result)
evalRelOpM (Limit n x) = do
result <- evalRelOpM x
pure (fmap (take n) result)
evalRelOpM (Join l r cond) = do
ls <- evalRelOpM l
rs <- evalRelOpM r
pure $ do
left <- ls
right <- rs
joinRows cond left right
rows <- res
filterM (evalCondForRow cond . (++ outer)) (prefixRows mAlias rows)
+239 -18
View File
@@ -1,40 +1,188 @@
module ChuSQL.Algebra.Expr (evalExpr, evalCondForRow, colsInExpr) where
module ChuSQL.Algebra.Expr (evalExpr, evalCondForRow, colsInExpr, aggregatesIn, hasAggregate, bareColumns, hasSubquery, inValues, threeValuedNot) where
import ChuSQL.Model
import ChuSQL.Syntax.AST
import Control.Monad (join)
-- 表达式求值:拿一行数据算出一个值。
-- 表达式求值:三值逻辑,NULL 参与运算结果仍是 NULL。
-- | 在一行上求出表达式的值
evalExpr :: Expr -> Row -> Either String Value
evalExpr (Col name) row =
maybe (Left ("unknown column: " ++ name)) Right (lookup name row)
evalExpr LitNull _ = Right VNull
evalExpr (LitInt n) _ = Right (VInt n)
evalExpr (LitFloat d) _ = Right (VFloat d)
evalExpr (LitStr s) _ = Right (VStr s)
evalExpr (LitBool b) _ = Right (VBool b)
evalExpr (Gt a b) row = liftBinOp (intOp (>)) a b row
evalExpr (Lt a b) row = liftBinOp (intOp (<)) a b row
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
evalExpr (LitDate s) _ = Right (VStr s)
evalExpr (LitTimestamp s) _ = Right (VStr s)
evalExpr (LitBlob s) _ = Right (VStr s)
evalExpr (Add a b) row = liftArith addArith a b row
evalExpr (Sub a b) row = liftArith subArith a b row
evalExpr (Mul a b) row = liftArith mulArith a b row
evalExpr (Div a b) row = liftArith divArith a b row
evalExpr (Neg a) row = do
v <- evalExpr a row
case v of
VNull -> Right VNull
VInt n -> Right (VInt (negate n))
VFloat d -> Right (VFloat (negate d))
_ -> Left "type error: expected a number"
evalExpr (Gt a b) row = liftCompare (> EQ) a b row
evalExpr (Lt a b) row = liftCompare (< EQ) a b row
evalExpr (Eq a b) row = liftCompare (== EQ) a b row
evalExpr (And a b) row = liftBool andValues a b row
evalExpr (Or a b) row = liftBool orValues a b row
evalExpr (IsNull a) row = do
v <- evalExpr a row
Right (VBool (v == VNull))
evalExpr (IsNotNull a) row = do
v <- evalExpr a row
Right (VBool (v /= VNull))
evalExpr (InList a es negated) row = do
v <- evalExpr a row
vs <- mapM (\e -> evalExpr e row) es
r <- inValues v vs
Right (if negated then threeValuedNot r else r)
evalExpr (ScalarSub _) _ = Left subqueryNeedsDatabase
evalExpr (InSub _ _ _) _ = Left subqueryNeedsDatabase
evalExpr (ExistsSub _ _) _ = Left subqueryNeedsDatabase
evalExpr agg _ = Left ("aggregate functions are not allowed here: " ++ show agg)
-- | 子查询要连上存储才能跑
subqueryNeedsDatabase :: String
subqueryNeedsDatabase = "subquery needs a database to run"
-- | IN 的三值逻辑:命中为真,没命中但碰到 NULL 就给 NULL,否则为假
inValues :: Value -> [Value] -> Either String Value
inValues VNull _ = Right VNull
inValues v vs = do
results <- mapM (compareValues (== EQ) v) vs
Right $
if VBool True `elem` results
then VBool True
else if VNull `elem` results then VNull else VBool False
-- | NOT 的三值取反
threeValuedNot :: Value -> Value
threeValuedNot (VBool b) = VBool (not b)
threeValuedNot _ = VNull
-- | 表达式用到了哪些列
colsInExpr :: Expr -> [String]
colsInExpr (Col c) = [c]
colsInExpr (Add a b) = colsInExpr a ++ colsInExpr b
colsInExpr (Sub a b) = colsInExpr a ++ colsInExpr b
colsInExpr (Mul a b) = colsInExpr a ++ colsInExpr b
colsInExpr (Div a b) = colsInExpr a ++ colsInExpr b
colsInExpr (Neg a) = colsInExpr a
colsInExpr (Gt a b) = colsInExpr a ++ colsInExpr b
colsInExpr (Lt a b) = colsInExpr a ++ colsInExpr b
colsInExpr (Eq a b) = colsInExpr a ++ colsInExpr b
colsInExpr (And a b) = colsInExpr a ++ colsInExpr b
colsInExpr (Or a b) = colsInExpr a ++ colsInExpr b
colsInExpr (IsNull a) = colsInExpr a
colsInExpr (IsNotNull a) = colsInExpr a
colsInExpr (CountOf a) = colsInExpr a
colsInExpr (SumOf a) = colsInExpr a
colsInExpr (AvgOf a) = colsInExpr a
colsInExpr (MinOf a) = colsInExpr a
colsInExpr (MaxOf a) = colsInExpr a
colsInExpr (ScalarSub sq) = subqueryRefs sq
colsInExpr (InSub a sq _) = colsInExpr a ++ subqueryRefs sq
colsInExpr (InList a es _) = colsInExpr a ++ concatMap colsInExpr es
colsInExpr (ExistsSub sq _) = subqueryRefs sq
colsInExpr _ = []
-- | 判断条件真假
-- | 表达式里有没有子查询
hasSubquery :: Expr -> Bool
hasSubquery e = case e of
ScalarSub _ -> True
InSub _ _ _ -> True
InList a es _ -> any hasSubquery (a : es)
ExistsSub _ _ -> True
Add a b -> both a b
Sub a b -> both a b
Mul a b -> both a b
Div a b -> both a b
Neg a -> hasSubquery a
Gt a b -> both a b
Lt a b -> both a b
Eq a b -> both a b
And a b -> both a b
Or a b -> both a b
IsNull a -> hasSubquery a
IsNotNull a -> hasSubquery a
CountOf a -> hasSubquery a
SumOf a -> hasSubquery a
AvgOf a -> hasSubquery a
MinOf a -> hasSubquery a
MaxOf a -> hasSubquery a
_ -> False
where
both a b = hasSubquery a || hasSubquery b
-- | 表达式里出现的聚合调用(按出现顺序)
aggregatesIn :: Expr -> [Expr]
aggregatesIn e = case e of
a@CountAll -> [a]
a@(CountOf _) -> [a]
a@(SumOf _) -> [a]
a@(AvgOf _) -> [a]
a@(MinOf _) -> [a]
a@(MaxOf _) -> [a]
Add a b -> both a b
Sub a b -> both a b
Mul a b -> both a b
Div a b -> both a b
Neg a -> aggregatesIn a
Gt a b -> both a b
Lt a b -> both a b
Eq a b -> both a b
And a b -> both a b
Or a b -> both a b
IsNull a -> aggregatesIn a
IsNotNull a -> aggregatesIn a
_ -> []
where
both a b = aggregatesIn a ++ aggregatesIn b
-- | 表达式里有没有聚合调用
hasAggregate :: Expr -> Bool
hasAggregate = not . null . aggregatesIn
-- | 聚合之外的列引用(聚合参数里的列不算)
bareColumns :: Expr -> [String]
bareColumns e = case e of
Col c -> [c]
Add a b -> both a b
Sub a b -> both a b
Mul a b -> both a b
Div a b -> both a b
Neg a -> bareColumns a
Gt a b -> both a b
Lt a b -> both a b
Eq a b -> both a b
And a b -> both a b
Or a b -> both a b
IsNull a -> bareColumns a
IsNotNull a -> bareColumns a
ScalarSub sq -> subqueryRefs sq
InSub a sq _ -> bareColumns a ++ subqueryRefs sq
InList a es _ -> bareColumns a ++ concatMap bareColumns es
ExistsSub sq _ -> subqueryRefs sq
_ -> []
where
both a b = bareColumns a ++ bareColumns b
-- | 判断条件真假:只有 TRUE 通过,NULL 当作不通过
evalCondForRow :: Expr -> Row -> Either String Bool
evalCondForRow e row = do
v <- evalExpr e row
case v of
VBool b -> Right b
VNull -> Right False
_ -> Left "type error: WHERE condition must be a boolean"
-- | 把二元运算提升到 Either
@@ -46,16 +194,89 @@ liftBinOp ::
Either String Value
liftBinOp f a b row = join (f <$> evalExpr a row <*> evalExpr b row)
-- | 整数二元运算
intOp :: (Int -> Int -> Bool) -> Value -> Value -> Either String Value
intOp f (VInt x) (VInt y) = Right (VBool (f x y))
intOp _ _ _ = Left "type error: expected two integers"
-- | 整数算术与浮点算术成对出现
data Arith = Arith
{ arithInt :: Int -> Int -> Either String Int
, arithFloat :: Double -> Double -> Either String Double
}
-- | 一边是 NULL 就出 NULL,两边都是整数就出整数
liftArith :: Arith -> Expr -> Expr -> Row -> Either String Value
liftArith ops a b row = liftBinOp go a b row
where
go VNull _ = Right VNull
go _ VNull = Right VNull
go (VInt x) (VInt y) = VInt <$> arithInt ops x y
go x y = case (asDouble x, asDouble y) of
(Just p, Just q) -> VFloat <$> arithFloat ops p q
_ -> Left "type error: expected two numbers"
-- | 能当数用的值
asDouble :: Value -> Maybe Double
asDouble (VInt n) = Just (fromIntegral n)
asDouble (VFloat d) = Just d
asDouble _ = Nothing
addArith :: Arith
addArith = Arith (\x y -> Right (x + y)) (\x y -> Right (x + y))
subArith :: Arith
subArith = Arith (\x y -> Right (x - y)) (\x y -> Right (x - y))
mulArith :: Arith
mulArith = Arith (\x y -> Right (x * y)) (\x y -> Right (x * y))
-- | 整数除法向零截断,溢出返回错误;浮点除法不能除以零
divArith :: Arith
divArith = Arith intDiv floatDiv
where
intDiv _ 0 = Left "division by zero"
intDiv x y
| x == minBound && y == -1 = Left "integer division overflow"
| otherwise = Right (x `quot` y)
floatDiv _ 0 = Left "division by zero"
floatDiv x y = Right (x / y)
-- | 比较:任一侧是 NULL 就给 NULL
liftCompare ::
(Ordering -> Bool) ->
Expr ->
Expr ->
Row ->
Either String Value
liftCompare rel a b row = liftBinOp (compareValues rel) a b row
compareValues :: (Ordering -> Bool) -> Value -> Value -> Either String Value
compareValues rel x y
| x == VNull || y == VNull = Right VNull
| Just p <- asDouble x, Just q <- asDouble y = Right (VBool (rel (compare p q)))
| (VStr p, VStr q) <- (x, y) = Right (VBool (rel (compare p q)))
| (VBool p, VBool q) <- (x, y) = Right (VBool (rel (compare p q)))
| otherwise = Left "type error: cannot compare these values"
-- | 布尔二元运算
boolOp :: (Bool -> Bool -> Bool) -> Value -> Value -> Either String Value
boolOp f (VBool x) (VBool y) = Right (VBool (f x y))
boolOp _ _ _ = Left "type error: expected two booleans"
liftBool ::
(Value -> Value -> Either String Value) ->
Expr ->
Expr ->
Row ->
Either String Value
liftBool f = liftBinOp f
-- | 相等比较
eqOp :: Value -> Value -> Either String Value
eqOp x y = Right (VBool (x == y))
andValues :: Value -> Value -> Either String Value
andValues (VBool False) _ = Right (VBool False)
andValues _ (VBool False) = Right (VBool False)
andValues (VBool True) (VBool True) = Right (VBool True)
andValues VNull (VBool True) = Right VNull
andValues (VBool True) VNull = Right VNull
andValues VNull VNull = Right VNull
andValues _ _ = Left "type error: expected two booleans"
orValues :: Value -> Value -> Either String Value
orValues (VBool True) _ = Right (VBool True)
orValues _ (VBool True) = Right (VBool True)
orValues (VBool False) (VBool False) = Right (VBool False)
orValues VNull (VBool False) = Right VNull
orValues (VBool False) VNull = Right VNull
orValues VNull VNull = Right VNull
orValues _ _ = Left "type error: expected two booleans"
+27 -6
View File
@@ -1,30 +1,51 @@
module ChuSQL.Algebra.Op (RelOp (..), renderPlan) where
import ChuSQL.Syntax.AST (Expr, SortDir)
import ChuSQL.Model (Value)
import ChuSQL.Syntax.AST (Expr, JoinKind (..), SortDir)
-- 关系代数算子,以及把算子树渲染成文本。
data RelOp
= Scan (Maybe String) String
| Lookup (Maybe String) String String Int
= Scan (Maybe String) String (Maybe [String])
| Lookup (Maybe String) String String Value
| Range (Maybe String) String String (Maybe (Value, Bool)) (Maybe (Value, Bool))
| Filter Expr RelOp
| Project [String] RelOp
| Compute [(String, Expr)] RelOp
| Aggregate [String] [(String, Expr)] RelOp
| Unit
| Sort [(String, SortDir)] RelOp
| Limit Int RelOp
| Join RelOp RelOp Expr
| Join JoinKind RelOp RelOp Expr
deriving (Show, Eq)
-- | 连接的显示名
kindLabel :: JoinKind -> String
kindLabel InnerJoin = "Join"
kindLabel LeftJoin = "LeftJoin"
-- | 渲染成缩进文本
renderPlan :: RelOp -> String
renderPlan = go 0
where
go ind op = case op of
Scan a t -> pad ++ "Scan " ++ show a ++ " " ++ show t
Scan a t cols -> pad ++ "Scan " ++ show a ++ " " ++ show t ++ maybe "" ((" columns=" ++) . show) cols
Lookup _ t c k -> pad ++ "Lookup " ++ show t ++ "." ++ show c ++ " = " ++ show k
Range _ t c lo hi -> pad ++ "Range " ++ show t ++ "." ++ show c ++ " " ++ show lo ++ " .. " ++ show hi
Filter e x -> pad ++ "Filter " ++ show e ++ "\n" ++ go (ind + 1) x
Project c x -> pad ++ "Project " ++ show c ++ "\n" ++ go (ind + 1) x
Compute c x -> pad ++ "Compute " ++ show c ++ "\n" ++ go (ind + 1) x
Aggregate keys aggs x ->
pad
++ "Aggregate keys="
++ show keys
++ " aggs="
++ show [label ++ " <- " ++ show e | (label, e) <- aggs]
++ "\n"
++ go (ind + 1) x
Unit -> pad ++ "Unit"
Sort s x -> pad ++ "Sort " ++ show s ++ "\n" ++ go (ind + 1) x
Limit n x -> pad ++ "Limit " ++ show n ++ "\n" ++ go (ind + 1) x
Join l r c -> pad ++ "Join on " ++ show c ++ "\n" ++ go (ind + 1) l ++ "\n" ++ go (ind + 1) r
Join k l r c -> pad ++ kindLabel k ++ " on " ++ show c ++ "\n" ++ go (ind + 1) l ++ "\n" ++ go (ind + 1) r
where
pad = replicate (ind * 2) ' '
+98 -17
View File
@@ -1,27 +1,33 @@
module ChuSQL.Algebra.Optimize (optimize, pushProject) where
module ChuSQL.Algebra.Optimize (optimize, pushProject, relOpCols) where
import ChuSQL.Algebra.Expr (colsInExpr, evalExpr)
import ChuSQL.Algebra.Op (RelOp (..))
import ChuSQL.Model
import ChuSQL.Syntax.AST (Expr (..))
import ChuSQL.Syntax.AST (Expr (..), JoinKind (..))
import Data.List (nub)
-- 查询优化:谓词下推 + 投影下推 + 常量折叠,跑到不动点。
-- | 算子会产出哪些列
relOpCols :: Database -> RelOp -> [String]
relOpCols db (Scan mAlias tbl) =
relOpCols _ Unit = []
relOpCols _ (Compute items _) = map fst items
relOpCols _ (Aggregate keys aggs _) = keys ++ map fst aggs
relOpCols _ (Scan mAlias _ (Just cols)) = map (qualify mAlias) cols
relOpCols db (Scan mAlias tbl Nothing) =
case lookup tbl db of
Nothing -> []
Just t -> map (prefix ++) (colNames t)
where
prefix = maybe "" (++ ".") mAlias
relOpCols db (Lookup mAlias tbl _ _) = relOpCols db (Scan mAlias tbl)
relOpCols db (Lookup mAlias tbl _ _) = relOpCols db (Scan mAlias tbl Nothing)
relOpCols db (Range mAlias tbl _ _ _) = relOpCols db (Scan mAlias tbl Nothing)
relOpCols db (Filter _ x) = relOpCols db x
relOpCols _ (Project ["*"] _) = ["*"]
relOpCols _ (Project cols _) = cols
relOpCols db (Sort _ x) = relOpCols db x
relOpCols db (Limit _ x) = relOpCols db x
relOpCols db (Join l r _) = relOpCols db l ++ relOpCols db r
relOpCols db (Join _ l r _) = relOpCols db l ++ relOpCols db r
-- | need 是否都在 have 里
isSubsetOf :: [String] -> [String] -> Bool
@@ -51,7 +57,7 @@ applyFilters :: [Expr] -> RelOp -> RelOp
applyFilters [] op = op
applyFilters (e : es) op = applyFilters es (Filter e op)
-- | Filter 压在 Join 上时下推
-- | Filter 压在内连接上时下推
pushJoin :: Database -> Expr -> RelOp -> RelOp -> Expr -> RelOp
pushJoin db p l r c =
let preds = splitConj p
@@ -61,17 +67,56 @@ pushJoin db p l r c =
(rPs, bothPs) = partitionPred rCols rest1
l' = applyFilters lPs l
r' = applyFilters rPs r
joined = Join l' r' c
joined = Join InnerJoin l' r' c
in case combineConj bothPs of
Nothing -> joined
Just p' -> Filter p' joined
-- | Filter 压在左连接上时,只把纯左表的谓词下推到左表;
-- 右表没配上会补 NULL,右表谓词不能提前算。
pushLeftJoin :: Database -> Expr -> RelOp -> RelOp -> Expr -> RelOp
pushLeftJoin db p l r c =
let (lPs, rest) = partitionPred (relOpCols db l) (splitConj p)
joined = Join LeftJoin (applyFilters lPs l) r c
in case combineConj rest of
Nothing -> joined
Just p' -> Filter p' joined
-- | 合并两层 Filter 或挪到 Sort 下
pushOne :: Database -> RelOp -> RelOp
pushOne _ (Filter p (Filter q x)) = Filter (And p q) x
pushOne _ (Filter p (Sort spec x)) = Sort spec (Filter p x)
pushOne _ (Filter p (Filter q x))
| not (hasDivision p || hasDivision q) = Filter (And p q) x
pushOne _ (Filter p (Sort spec x))
| not (hasDivision p) = Sort spec (Filter p x)
pushOne _ op = op
-- | 除法可能报错,不能提前或延后求值
hasDivision :: Expr -> Bool
hasDivision e = case e of
Div _ _ -> True
Neg a -> hasDivision a
Add a b -> both a b
Sub a b -> both a b
Mul a b -> both a b
Gt a b -> both a b
Lt a b -> both a b
Eq a b -> both a b
And a b -> both a b
Or a b -> both a b
_ -> False
where
both a b = hasDivision a || hasDivision b
-- | 检查子计划中可能报错的除法
planHasDivision :: RelOp -> Bool
planHasDivision (Filter p x) = hasDivision p || planHasDivision x
planHasDivision (Join _ l r c) = hasDivision c || planHasDivision l || planHasDivision r
planHasDivision (Compute items x) = any (hasDivision . snd) items || planHasDivision x
planHasDivision (Project _ x) = planHasDivision x
planHasDivision (Sort _ x) = planHasDivision x
planHasDivision (Limit _ x) = planHasDivision x
planHasDivision _ = False
-- | 是否要全部列
needsAll :: [String] -> Bool
needsAll = elem allColumns
@@ -85,29 +130,39 @@ pushProjectLeaf db need leaf
-- | 把需要哪些列往下传
pushProject :: Database -> [String] -> RelOp -> RelOp
pushProject _ _ Unit = Unit
pushProject db _ (Compute items x) = Compute items (pushProject db (concatMap (colsInExpr . snd) items) x)
pushProject db _ (Aggregate keys aggs x) =
Aggregate keys aggs (pushProject db (keys ++ concatMap (colsInExpr . snd) aggs) x)
pushProject db _ (Project cols x) = case pushProject db cols x of
Project cols' y | cols' == cols -> Project cols y
other -> Project cols other
pushProject db need (Filter p x) = Filter p (pushProject db (need ++ colsInExpr p) x)
pushProject db need (Sort spec x) = Sort spec (pushProject db (need ++ map fst spec) x)
pushProject db need (Limit n x) = Limit n (pushProject db need x)
pushProject db need (Join l r c)
| needsAll need = Join (pushProject db need l) (pushProject db need r) c
pushProject db need (Join kind l r c)
| needsAll need = Join kind (pushProject db need l) (pushProject db need r) c
| otherwise =
let tot = need ++ colsInExpr c
lCols = relOpCols db l
rCols = relOpCols db r
lNeed = if needsAll lCols then ["*"] else [x | x <- tot, x `elem` lCols]
rNeed = if needsAll rCols then ["*"] else [x | x <- tot, x `elem` rCols]
in Join (pushProject db lNeed l) (pushProject db rNeed r) c
pushProject db need leaf@(Scan _ _) = pushProjectLeaf db need leaf
in Join kind (pushProject db lNeed l) (pushProject db rNeed r) c
pushProject db need leaf@(Scan a t _)
| needsAll need = leaf
| all (`elem` need) (relOpCols db leaf) = leaf
| otherwise = Scan a t (Just (nub (map (unqualify a) need)))
pushProject db need leaf@(Lookup _ _ _ _) = pushProjectLeaf db need leaf
pushProject db need leaf@(Range _ _ _ _ _) = pushProjectLeaf db need leaf
-- | 折一个表达式节点
foldNode :: Expr -> Expr
foldNode e
| null (colsInExpr e) = case evalExpr e [] of
Right VNull -> LitNull
Right (VInt n) -> LitInt n
Right (VFloat d) -> LitFloat d
Right (VStr s) -> LitStr s
Right (VBool b) -> LitBool b
Left _ -> e
@@ -115,6 +170,11 @@ foldNode e
-- | 自底向上折整棵表达式
foldConstants :: Expr -> Expr
foldConstants (Add a b) = foldNode (Add (foldConstants a) (foldConstants b))
foldConstants (Sub a b) = foldNode (Sub (foldConstants a) (foldConstants b))
foldConstants (Mul a b) = foldNode (Mul (foldConstants a) (foldConstants b))
foldConstants (Div a b) = foldNode (Div (foldConstants a) (foldConstants b))
foldConstants (Neg a) = foldNode (Neg (foldConstants a))
foldConstants (Gt a b) = foldNode (Gt (foldConstants a) (foldConstants b))
foldConstants (Lt a b) = foldNode (Lt (foldConstants a) (foldConstants b))
foldConstants (Eq a b) = foldNode (Eq (foldConstants a) (foldConstants b))
@@ -124,13 +184,32 @@ foldConstants e = e
-- | 单节点重写
rewriteNode :: Database -> RelOp -> RelOp
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
rewriteNode _ (Filter (Eq (Col k) (LitInt v)) (Scan mAlias t Nothing)) =
Lookup mAlias t (unqualify mAlias k) (VInt v)
rewriteNode _ (Filter (Eq (Col k) (LitStr v)) (Scan mAlias t Nothing)) =
Lookup mAlias t (unqualify mAlias k) (VStr v)
rewriteNode _ (Filter (And (Gt (Col k1) (LitInt lo)) (Lt (Col k2) (LitInt hi))) (Scan mAlias t Nothing))
| k1 == k2 = Range mAlias t (unqualify mAlias k1) (Just (VInt lo, False)) (Just (VInt hi, False))
rewriteNode _ (Filter (And (Gt (Col k1) (LitStr lo)) (Lt (Col k2) (LitStr hi))) (Scan mAlias t Nothing))
| k1 == k2 = Range mAlias t (unqualify mAlias k1) (Just (VStr lo, False)) (Just (VStr hi, False))
rewriteNode _ (Filter (Gt (Col k) (LitInt v)) (Scan mAlias t Nothing)) =
Range mAlias t (unqualify mAlias k) (Just (VInt v, False)) Nothing
rewriteNode _ (Filter (Gt (Col k) (LitStr v)) (Scan mAlias t Nothing)) =
Range mAlias t (unqualify mAlias k) (Just (VStr v, False)) Nothing
rewriteNode _ (Filter (Lt (Col k) (LitInt v)) (Scan mAlias t Nothing)) =
Range mAlias t (unqualify mAlias k) Nothing (Just (VInt v, False))
rewriteNode _ (Filter (Lt (Col k) (LitStr v)) (Scan mAlias t Nothing)) =
Range mAlias t (unqualify mAlias k) Nothing (Just (VStr v, False))
rewriteNode db (Filter p (Join kind l r c))
| not (hasDivision p || planHasDivision (Join kind l r c)) =
case kind of
InnerJoin -> pushJoin db p l r c
LeftJoin -> pushLeftJoin db p l r c
rewriteNode db (Filter p x) = case foldConstants p of
LitBool True -> x
p' -> pushOne db (Filter p' x)
rewriteNode _ (Project ["*"] x) = x
rewriteNode _ (Compute items x) = Compute [(label, foldConstants e) | (label, e) <- items] x
rewriteNode db op = pushOne db op
-- | 自底向上遍历一次
@@ -139,9 +218,11 @@ optimizeRelOp db = rewriteNode db . descend
where
descend (Filter p x) = Filter p (optimizeRelOp db x)
descend (Project cols x) = Project cols (optimizeRelOp db x)
descend (Compute items x) = Compute items (optimizeRelOp db x)
descend (Aggregate keys aggs x) = Aggregate keys aggs (optimizeRelOp db x)
descend (Sort spec x) = Sort spec (optimizeRelOp db x)
descend (Limit n x) = Limit n (optimizeRelOp db x)
descend (Join l r c) = Join (optimizeRelOp db l) (optimizeRelOp db r) c
descend (Join kind l r c) = Join kind (optimizeRelOp db l) (optimizeRelOp db r) c
descend x = x
-- | 反复跑到不动点
+67 -15
View File
@@ -1,5 +1,6 @@
module ChuSQL.Algebra.Planner (translate) where
import ChuSQL.Algebra.Expr (aggregatesIn)
import ChuSQL.Algebra.Op
import ChuSQL.Syntax.AST
@@ -7,29 +8,80 @@ import ChuSQL.Syntax.AST
-- | 把 FROM 子句翻成 Scan / Join
fromToRelOp :: FromClause -> RelOp
fromToRelOp (FromTable mAlias tbl) = Scan mAlias tbl
fromToRelOp (FromJoin left mAlias tbl cond) =
Join (fromToRelOp left) (Scan mAlias tbl) cond
fromToRelOp FromUnit = Unit
fromToRelOp (FromTable mAlias tbl) = Scan mAlias tbl Nothing
fromToRelOp (FromJoin kind left mAlias tbl cond) =
Join kind (fromToRelOp left) (Scan mAlias tbl Nothing) cond
-- | 翻成:过滤 -> 排序 -> 截断 -> 取列
-- | 带上 WHERE 的来源
source :: FromClause -> Maybe Expr -> RelOp
source fromC mWhere = case mWhere of
Nothing -> fromToRelOp fromC
Just w -> Filter w (fromToRelOp fromC)
-- | 给用到的聚合起内部列名,同一个聚合只算一次
nameAggs :: [(String, Expr)] -> [(String, Expr)]
nameAggs items = go (1 :: Int) [] (concatMap (aggregatesIn . snd) items)
where
go :: Int -> [(String, Expr)] -> [Expr] -> [(String, Expr)]
go _ acc [] = reverse acc
go n acc (e : es)
| any ((== e) . snd) acc = go n acc es
| otherwise = go (n + 1) (("agg" ++ show n, e) : acc) es
-- | 把聚合调用换成对聚合结果的引用
rewriteAggs :: [(String, Expr)] -> Expr -> Expr
rewriteAggs aggs e = case [label | (label, agg) <- aggs, agg == e] of
(label : _) -> Col label
[] -> case e of
Add a b -> Add (go a) (go b)
Sub a b -> Sub (go a) (go b)
Mul a b -> Mul (go a) (go b)
Div a b -> Div (go a) (go b)
Neg a -> Neg (go a)
Gt a b -> Gt (go a) (go b)
Lt a b -> Lt (go a) (go b)
Eq a b -> Eq (go a) (go b)
And a b -> And (go a) (go b)
Or a b -> Or (go a) (go b)
IsNull a -> IsNull (go a)
IsNotNull a -> IsNotNull (go a)
other -> other
where
go = rewriteAggs aggs
-- | 过滤 + 分组:没有分组也没有聚合时原样返回
grouped :: [String] -> [(String, Expr)] -> RelOp -> (RelOp, [(String, Expr)])
grouped keys items base
| null keys && null aggs = (base, items)
| otherwise = (Aggregate keys aggs base, [(label, rewriteAggs aggs e) | (label, e) <- items])
where
aggs = nameAggs items
-- | 排序 + 截断
limited :: [(String, SortDir)] -> Maybe Int -> RelOp -> RelOp
limited orderBy mLimit op = case mLimit of
Nothing -> sorted
Just n -> Limit n sorted
where
sorted = case orderBy of
[] -> op
spec -> Sort spec op
-- | 翻成:过滤 -> 分组 -> 排序 -> 截断 -> 取列
translate :: Statement -> Either String RelOp
translate q = case q of
Select
{ selectCols = cols
, selectFrom = fromC
, selectWhere = mWhere
, selectGroupBy = groupBy
, selectOrderBy = orderBy
, selectLimit = mLimit
} -> do
let base = fromToRelOp fromC
filtered = case mWhere of
Nothing -> base
Just w -> Filter w base
sorted = case orderBy of
[] -> filtered
spec -> Sort spec filtered
limited = case mLimit of
Nothing -> sorted
Just n -> Limit n sorted
Right (Project cols limited)
let (plan, _) = grouped groupBy [] (source fromC mWhere)
Right (Project cols (limited orderBy mLimit plan))
SelectExpr items fromC mWhere groupBy orderBy mLimit -> do
let (plan, items') = grouped groupBy items (source fromC mWhere)
Right (Compute items' (limited orderBy mLimit plan))
_ -> Left "only SELECT is supported by translate"
+8 -2
View File
@@ -1,4 +1,4 @@
module ChuSQL.Algebra.Sort (sortRows) where
module ChuSQL.Algebra.Sort (sortRows, compareValue) where
import ChuSQL.Model
import ChuSQL.Syntax.AST
@@ -12,9 +12,15 @@ flipOrdering LT = GT
flipOrdering GT = LT
flipOrdering EQ = EQ
-- | 比较两个值
-- | 比较两个值:NULL 最小,数值跨整数与小数比较
compareValue :: Value -> Value -> Ordering
compareValue VNull VNull = EQ
compareValue VNull _ = LT
compareValue _ VNull = GT
compareValue (VInt a) (VInt b) = compare a b
compareValue (VFloat a) (VFloat b) = compare a b
compareValue (VInt a) (VFloat b) = compare (fromIntegral a) b
compareValue (VFloat a) (VInt b) = compare a (fromIntegral b)
compareValue (VStr a) (VStr b) = compare a b
compareValue (VBool a) (VBool b) = compare a b
compareValue _ _ = EQ
+297 -67
View File
@@ -1,14 +1,16 @@
module ChuSQL.Engine (runStatement, runStatementM, rowsOf) where
import ChuSQL.Algebra.Eval (evalRelOpM)
import ChuSQL.Algebra.Eval (evalCondForRowM, evalExprM, evalRelOpM)
import ChuSQL.Algebra.Expr (evalCondForRow, evalExpr)
import ChuSQL.Algebra.Optimize (optimize)
import ChuSQL.Algebra.Planner (translate)
import ChuSQL.Model
import ChuSQL.Semantic (check)
import ChuSQL.Semantic (prepare)
import ChuSQL.Storage (MonadStorage (..))
import ChuSQL.Storage.Memory (MemoryStorage (runMemoryStorage))
import ChuSQL.Syntax.AST
import ChuSQL.Syntax.Parser (parseExpression)
import Data.Maybe (mapMaybe)
-- 引擎入口:语义检查 + 分发执行,以及内存实现与泛型版本。
@@ -25,71 +27,71 @@ rowsOf = fmap snd
-- | 泛型入口:先检查再执行
runStatementM :: (MonadStorage m) => Statement -> m (Either String [Row])
runStatementM (CreateDatabase name) = fmap (fmap (const [])) (createDatabase name)
runStatementM (DropDatabase name) = fmap (fmap (const [])) (dropDatabase name)
runStatementM (UseDatabase name) = fmap (fmap (const [])) (useDatabase name)
runStatementM ShowDatabases = fmap (fmap (map (\name -> [("database", VStr name)]))) listDatabases
runStatementM q = do
db <- schema
case check db q of
case prepare db q of
Left err -> pure (Left err)
Right _ -> runStatementUncheckedM db q
Right resolved -> runStatementUncheckedM db resolved
-- | 按语句类型分发(已检查过)
runStatementUncheckedM :: (MonadStorage m) => Database -> Statement -> m (Either String [Row])
runStatementUncheckedM db q@Select{} =
case translate q of
Left e -> pure (Left e)
Right relOp -> evalRelOpM (optimize db relOp)
runStatementUncheckedM _ (Insert tbl cols rows) =
case mapM toRow rows of
Left err -> pure (Left err)
Right rs -> do
result <- insertMany tbl rs
pure (result >> Right [])
where
-- \| 一行字面量求值成一行数据
toRow :: [Expr] -> Either String Row
toRow vals = do
values <- mapM (\e -> evalExpr e []) vals
if length cols /= length values
then Left "column count does not match value count"
else Right (zip cols values)
runStatementUncheckedM _ (Delete tbl mWhere) = do
rowsResult <- scan tbl
case rowsResult of
Left err -> pure (Left err)
Right rows -> case splitByCondition mWhere rows of
Left err -> pure (Left err)
Right (doomed, kept) ->
case mapM rowId doomed of
Right ids -> do
result <- deleteKeys tbl ids
pure (result >> Right [])
Left _ -> do
result <- replaceAll tbl kept
pure (result >> Right [])
where
-- \| 这一行的 id(要拿来当删除键)
rowId :: Row -> Either String Int
rowId r = case lookup "id" r of
Just (VInt k) -> Right k
_ -> Left "row has no integer id"
runStatementUncheckedM _ (Update tbl assigns mWhere) = do
Right relOp -> evalRelOpM db (optimize db relOp)
runStatementUncheckedM db q@SelectExpr{} =
case translate q of
Left e -> pure (Left e)
Right relOp -> evalRelOpM db (optimize db relOp)
runStatementUncheckedM db (Insert tbl cols rows) =
case lookupTable db tbl of
Left e -> pure (Left e)
Right table -> do
current <- existingRows tbl table
case current >>= \current' -> buildRows table current' cols rows of
Left err -> pure (Left err)
Right rs -> do
result <- insertMany tbl rs
pure (result >> Right [])
runStatementUncheckedM db (Delete tbl mWhere) = do
rowsResult <- scan tbl
case rowsResult of
Left err -> pure (Left err)
Right rows -> do
let newRows = mapM (updateRow mWhere assigns) rows
case newRows of
split <- splitByConditionM db mWhere rows
case split of
Left err -> pure (Left err)
Right rs -> do
result <- replaceAll tbl rs
pure (result >> Right [])
Right (doomed, kept) ->
case mapM rowId doomed of
Right ids -> do
result <- deleteKeys tbl ids
pure (result >> Right [])
Left _ -> do
result <- replaceAll tbl kept
pure (result >> Right [])
where
-- \| 命中条件就套用赋值
updateRow :: Maybe Expr -> [(String, Expr)] -> Row -> Either String Row
updateRow cond asgns row = do
keep <- case cond of
Nothing -> Right True
Just e -> evalCondForRow e row
if keep then applyUpdates asgns row else Right row
rowId :: Row -> Either String Int
rowId r = case lookup "id" r of
Just (VInt k) -> Right k
_ -> Left "row has no integer id"
runStatementUncheckedM db (Update tbl assigns mWhere) =
case lookupTable db tbl of
Left e -> pure (Left e)
Right table -> do
rowsResult <- scan tbl
case rowsResult of
Left err -> pure (Left err)
Right rows -> do
newRows <- mapM (updateRowM db table mWhere assigns) rows
case sequence newRows of
Left err -> pure (Left err)
Right rs -> do
result <- replaceAll tbl rs
pure (result >> Right [])
runStatementUncheckedM _ (CreateTable name cols) = do
result <- createTable name cols
pure (result >> Right [])
@@ -105,23 +107,251 @@ runStatementUncheckedM _ (DropIndex tbl col) = do
runStatementUncheckedM _ (DropColumn tbl col) = do
result <- dropColumn tbl col
pure (result >> Right [])
runStatementUncheckedM db (AddColumn tbl def) =
alterTable db tbl (\table -> addColumnTo def (tableCols table) (tableRows table))
runStatementUncheckedM db (RenameColumn tbl old new) =
alterTable db tbl (\table -> renameColumnIn old new (tableCols table) (tableRows table))
runStatementUncheckedM db (AlterColumnType tbl col ty) =
alterTable db tbl (\table -> retypeColumn col ty (tableCols table) (tableRows table))
runStatementUncheckedM db (AlterColumnDefault tbl col def) =
alterTable db tbl (\table -> setColumnDefault col def (tableCols table) (tableRows table))
runStatementUncheckedM db (AlterColumnNull tbl col nullable) =
alterTable db tbl (\table -> setColumnNullable col nullable (tableCols table) (tableRows table))
runStatementUncheckedM _ CreateUser{} = pure (Left "CREATE USER is executed by the account service")
runStatementUncheckedM _ AlterUser{} = pure (Left "ALTER USER is executed by the account service")
runStatementUncheckedM _ DropUser{} = pure (Left "DROP USER is executed by the account service")
runStatementUncheckedM _ CreateRole{} = pure (Left "CREATE ROLE is executed by the privilege service")
runStatementUncheckedM _ DropRole{} = pure (Left "DROP ROLE is executed by the privilege service")
runStatementUncheckedM _ GrantPrivileges{} = pure (Left "GRANT is executed by the privilege service")
runStatementUncheckedM _ RevokePrivileges{} = pure (Left "REVOKE is executed by the privilege service")
runStatementUncheckedM _ GrantRole{} = pure (Left "GRANT is executed by the privilege service")
runStatementUncheckedM _ RevokeRole{} = pure (Left "REVOKE is executed by the privilege service")
runStatementUncheckedM _ q@CreateDatabase{} = runStatementM q
runStatementUncheckedM _ q@DropDatabase{} = runStatementM q
runStatementUncheckedM _ q@UseDatabase{} = runStatementM q
runStatementUncheckedM _ ShowDatabases = runStatementM ShowDatabases
-- | 按条件把行分成待删和保留两拨
splitByCondition :: Maybe Expr -> [Row] -> Either String ([Row], [Row])
splitByCondition cond = go [] []
-- | 只有需要读全表才能判断约束时才扫表
existingRows :: (MonadStorage m) => String -> Table -> m (Either String [Row])
existingRows tbl table
| needsRows table = scan tbl
| otherwise = pure (Right [])
where
-- \| 逐行过,保持原顺序
go doomed kept [] = Right (reverse doomed, reverse kept)
needsRows t = any (constraintColumn . snd) (tableCols t)
constraintColumn c = columnAutoIncrement c || columnUnique c || columnPrimaryKey c
-- | 插一行:补齐缺列、给自增值、收类型、查约束
buildRows :: Table -> [Row] -> [String] -> [[Expr]] -> Either String [Row]
buildRows table current cols rows = do
values <- mapM (mapM (\e -> evalExpr e [])) rows
if any (\vals -> length vals /= length cols) values
then Left "column count does not match value count"
else do
let auto = autoColumn table
go auto (autoStart auto current) values []
where
go _ _ [] acc = do
let built = reverse acc
mapM_ (runChecks table) built
enforceNotNull (tableCols table) built
enforceUnique (tableCols table) (current ++ built)
Right built
go auto counter (vals : rest) acc = do
(row, counter') <- buildRow table auto cols counter vals
go auto counter' rest (row : acc)
-- | 按列定义补出一行
buildRow :: Table -> Maybe (String, Column) -> [String] -> Int -> [Value] -> Either String (Row, Int)
buildRow table auto cols counter vals = go (tableCols table) counter []
where
provided = zip cols vals
go [] c acc = Right (reverse acc, c)
go ((name, col) : rest) c acc = do
(v, c') <- pick name col c
v' <- coerceValue (columnType col) v
go rest c' ((name, v') : acc)
pick name col c
| Just (autoName, _) <- auto
, name == autoName
, maybe True (== VNull) (lookup name provided) = Right (VInt c, c + 1)
| Just v <- lookup name provided = Right (v, bump name col c v)
| Just d <- columnDefault col = Right (d, c)
| columnNullable col = Right (VNull, c)
| otherwise = Left ("INSERT: column " ++ name ++ " is NOT NULL and has no default")
bump name _ c v
| Just (autoName, _) <- auto
, name == autoName = max (c + 1) (valueInt v + 1)
| otherwise = c
valueInt :: Value -> Int
valueInt (VInt n) = n
valueInt _ = 0
-- | 自增列(至多一个)
autoColumn :: Table -> Maybe (String, Column)
autoColumn table = case [(n, c) | (n, c) <- tableCols table, columnAutoIncrement c] of
(entry : _) -> Just entry
[] -> Nothing
-- | 下一个自增值
autoStart :: Maybe (String, Column) -> [Row] -> Int
autoStart Nothing _ = 1
autoStart (Just (name, _)) rows = case [n | r <- rows, Just (VInt n) <- [lookup name r]] of
[] -> 1
ns -> maximum ns + 1
-- | 执行 CHECK 约束
runChecks :: Table -> Row -> Either String ()
runChecks table row = mapM_ check (tableCols table)
where
check (name, col) = case columnCheck col of
Nothing -> Right ()
Just text -> case parseExpression text of
Left err -> Left ("CHECK on " ++ name ++ ": " ++ firstLine err)
Right e -> case evalCondForRow e row of
Left err -> Left ("CHECK on " ++ name ++ ": " ++ err)
Right True -> Right ()
Right False -> Left ("CHECK on " ++ name ++ " failed")
firstLine :: String -> String
firstLine = takeWhile (/= '\n')
-- | NOT NULL 检查
enforceNotNull :: [(String, Column)] -> [Row] -> Either String ()
enforceNotNull cols rows = mapM_ check cols
where
check (name, col)
| columnNullable col = Right ()
| any (\r -> lookup name r == Just VNull) rows = Left ("NOT NULL: column " ++ name ++ " cannot be null")
| otherwise = Right ()
-- | 唯一性检查(NULL 之间不算冲突)
enforceUnique :: [(String, Column)] -> [Row] -> Either String ()
enforceUnique cols rows = mapM_ check targets
where
targets = [(n, c) | (n, c) <- cols, columnUnique c || columnPrimaryKey c]
check (name, _) = go name [] (mapMaybe (lookup name) rows)
go _ _ [] = Right ()
go name seen (VNull : rest) = go name seen rest
go name seen (v : rest)
| v `elem` seen = Left ("UNIQUE: duplicate value in column " ++ name)
| otherwise = go name (v : seen) rest
-- | UPDATE 的一行(条件与赋值都可能带子查询)
updateRowM :: (MonadStorage m) => Database -> Table -> Maybe Expr -> [(String, Expr)] -> Row -> m (Either String Row)
updateRowM db table cond asgns row = do
keep <- case cond of
Nothing -> pure (Right True)
Just e -> evalCondForRowM db e row
case keep of
Left err -> pure (Left err)
Right False -> pure (Right row)
Right True -> do
row' <- applyUpdatesM db table asgns row
pure (row' >>= \r -> runChecks table r >> Right r)
-- | 依次求值、收类型并覆盖列(表达式可以带子查询)
applyUpdatesM :: (MonadStorage m) => Database -> Table -> [(String, Expr)] -> Row -> m (Either String Row)
applyUpdatesM _ _ [] row = pure (Right row)
applyUpdatesM db table ((col, e) : rest) row = do
v <- evalExprM db e row
case v of
Left err -> pure (Left err)
Right val -> case colType table col of
Just def -> case coerceValue (columnType def) val of
Left err -> pure (Left err)
Right v' -> applyUpdatesM db table rest (setColumn col v' row)
Nothing -> applyUpdatesM db table rest (setColumn col val row)
-- | 覆盖一列
setColumn :: String -> Value -> Row -> Row
setColumn col v = map (\(k, val) -> if k == col then (k, v) else (k, val))
-- | 按条件把行分成待删和保留两拨(条件可以带子查询)
splitByConditionM :: (MonadStorage m) => Database -> Maybe Expr -> [Row] -> m (Either String ([Row], [Row]))
splitByConditionM db cond = go [] []
where
go doomed kept [] = pure (Right (reverse doomed, reverse kept))
go doomed kept (r : rs) = do
hit <- case cond of
Nothing -> Right True
Just e -> evalCondForRow e r
go (if hit then r : doomed else doomed) (if hit then kept else r : kept) rs
Nothing -> pure (Right True)
Just e -> evalCondForRowM db e r
case hit of
Left err -> pure (Left err)
Right h -> go (if h then r : doomed else doomed) (if h then kept else r : kept) rs
-- | 依次求值并覆盖列
applyUpdates :: [(String, Expr)] -> Row -> Either String Row
applyUpdates [] row = Right row
applyUpdates ((col, e) : rest) row = do
v <- evalExpr e row
let row' = map (\(k, val) -> if k == col then (k, v) else (k, val)) row
applyUpdates rest row'
-- | 表结构变更的统一入口:扫全表,改完整体回写
alterTable ::
(MonadStorage m) =>
Database ->
String ->
(Table -> Either String ([(String, Column)], [Row])) ->
m (Either String [Row])
alterTable db tbl change = case lookupTable db tbl of
Left e -> pure (Left e)
Right table -> do
rowsResult <- scan tbl
case rowsResult of
Left e -> pure (Left e)
Right rows -> case change table{tableRows = rows} >>= checkAltered of
Left e -> pure (Left e)
Right (cols, newRows) -> do
result <- replaceSchema tbl cols newRows
pure (result >> Right [])
where
checkAltered (cols, rows) = do
let table = Table tbl cols rows
mapM_ (runChecks table) rows
enforceNotNull cols rows
enforceUnique cols rows
Right (cols, rows)
-- | ADD COLUMN:老行补默认值或 NULL,自增列从 1 开始
addColumnTo :: (String, Column) -> [(String, Column)] -> [Row] -> Either String ([(String, Column)], [Row])
addColumnTo (name, col) cols rows
| name `elem` map fst cols = Left ("ADD COLUMN: column already exists: " ++ name)
| otherwise = do
values <- fill
Right (cols ++ [(name, col)], [r ++ [(name, v)] | (r, v) <- zip rows values])
where
fill
| columnAutoIncrement col = mapM (coerceValue (columnType col) . VInt) [1 .. length rows]
| Just d <- columnDefault col = mapM (\_ -> coerceValue (columnType col) d) rows
| columnNullable col = mapM (\_ -> coerceValue (columnType col) VNull) rows
| null rows = Right []
| otherwise = Left ("ADD COLUMN: column " ++ name ++ " is NOT NULL and has no default")
-- | RENAME COLUMN:列定义与每一行的键一起改
renameColumnIn :: String -> String -> [(String, Column)] -> [Row] -> Either String ([(String, Column)], [Row])
renameColumnIn old new cols rows
| old `notElem` map fst cols = Left ("RENAME COLUMN: unknown column: " ++ old)
| new `elem` map fst cols = Left ("RENAME COLUMN: column already exists: " ++ new)
| otherwise = Right (renameKeys cols, map renameKeys rows)
where
renameKeys = map (\(k, v) -> if k == old then (new, v) else (k, v))
-- | ALTER COLUMN TYPE:老值收进新类型
retypeColumn :: String -> ColumnType -> [(String, Column)] -> [Row] -> Either String ([(String, Column)], [Row])
retypeColumn col ty cols rows
| col `notElem` map fst cols = Left ("ALTER COLUMN: unknown column: " ++ col)
| otherwise = do
rows' <- mapM convert rows
Right (map (\entry@(n, c) -> if n == col then (n, c{columnType = ty}) else entry) cols, rows')
where
convert row = case lookup col row of
Nothing -> Right row
Just v -> do
v' <- coerceValue ty v
Right (map (\(k, x) -> if k == col then (k, v') else (k, x)) row)
-- | ALTER COLUMN SET/DROP DEFAULT
setColumnDefault :: String -> Maybe Value -> [(String, Column)] -> [Row] -> Either String ([(String, Column)], [Row])
setColumnDefault col def cols rows
| col `notElem` map fst cols = Left ("ALTER COLUMN: unknown column: " ++ col)
| otherwise = Right (map (\entry@(n, c) -> if n == col then (n, c{columnDefault = def}) else entry) cols, rows)
-- | ALTER COLUMN SET/DROP NOT NULL
setColumnNullable :: String -> Bool -> [(String, Column)] -> [Row] -> Either String ([(String, Column)], [Row])
setColumnNullable col nullable cols rows
| col `notElem` map fst cols = Left ("ALTER COLUMN: unknown column: " ++ col)
| otherwise = Right (map (\entry@(n, c) -> if n == col then (n, c{columnNullable = nullable}) else entry) cols, rows)
+348 -12
View File
@@ -1,27 +1,354 @@
module ChuSQL.Model (Column (..), Value (..), Row, Table (..), Database, colNames, colType, lookupTable, allColumns, qualify, unqualify) where
{-# LANGUAGE PatternSynonyms #-}
module ChuSQL.Model
( ColumnType (..)
, Column (..)
, pattern TInt
, pattern TStr
, pattern TBool
, TypeClass (..)
, Value (..)
, Row
, Table (..)
, Database
, colNames
, colType
, lookupTable
, allColumns
, qualify
, unqualify
, resolveColumn
, plainColumn
, typeLabel
, typeName
, parseColumnType
, typeClassOf
, assignable
, integerType
, numericType
, valueFits
, coerceValue
) where
import Data.Char (isDigit, isSpace, toLower)
import Data.Hashable (Hashable (..))
import Data.List (stripPrefix)
import Data.List (isSuffixOf, stripPrefix)
import Data.Maybe (fromMaybe)
-- 数据模型:列类型、值、行、表、数据库,外加几个查表小工具。
-- 数据模型:列类型、列约束、值、行、表、数据库,外加几个查表小工具。
data Column
= TInt
| TStr
| TBool
-- | 列的基础类型
data ColumnType
= CInt
| CBigInt
| CSmallInt
| CStr
| CVarchar Int
| CChar Int
| CBool
| CFloat
| CDouble
| CDecimal Int Int
| CDate
| CTimestamp
| CBlob
deriving (Show, Eq)
-- | 列定义:类型 + 约束;约束全部取默认值就是普通可空列。
data Column = Column
{ columnType :: ColumnType
, columnNullable :: Bool
, columnDefault :: Maybe Value
, columnAutoIncrement :: Bool
, columnUnique :: Bool
, columnPrimaryKey :: Bool
, columnCheck :: Maybe String
}
deriving (Eq)
-- | 普通可空列
plainColumn :: ColumnType -> Column
plainColumn t = Column t True Nothing False False False Nothing
pattern TInt :: Column
pattern TInt <- Column CInt _ _ _ _ _ _
where
TInt = plainColumn CInt
pattern TStr :: Column
pattern TStr <- Column CStr _ _ _ _ _ _
where
TStr = plainColumn CStr
pattern TBool :: Column
pattern TBool <- Column CBool _ _ _ _ _ _
where
TBool = plainColumn CBool
-- | 错误消息里用的类型名
typeLabel :: ColumnType -> String
typeLabel CInt = "TInt"
typeLabel CBigInt = "TBigInt"
typeLabel CSmallInt = "TSmallInt"
typeLabel CStr = "TStr"
typeLabel (CVarchar n) = "TVarchar " ++ show n
typeLabel (CChar n) = "TChar " ++ show n
typeLabel CBool = "TBool"
typeLabel CFloat = "TFloat"
typeLabel CDouble = "TDouble"
typeLabel (CDecimal p s) = "TDecimal " ++ show p ++ " " ++ show s
typeLabel CDate = "TDate"
typeLabel CTimestamp = "TTimestamp"
typeLabel CBlob = "TBlob"
-- | 线上/磁盘上的类型名
typeName :: ColumnType -> String
typeName CInt = "int"
typeName CBigInt = "bigint"
typeName CSmallInt = "smallint"
typeName CStr = "str"
typeName (CVarchar n) = "varchar(" ++ show n ++ ")"
typeName (CChar n) = "char(" ++ show n ++ ")"
typeName CBool = "bool"
typeName CFloat = "float"
typeName CDouble = "double"
typeName (CDecimal p s) = "decimal(" ++ show p ++ "," ++ show s ++ ")"
typeName CDate = "date"
typeName CTimestamp = "timestamp"
typeName CBlob = "blob"
-- | 解析类型名,可带长度/精度参数;不认识的给 Nothing
parseColumnType :: String -> Maybe ColumnType
parseColumnType raw = case (base, args) of
("int", []) -> Just CInt
("integer", []) -> Just CInt
("bigint", []) -> Just CBigInt
("smallint", []) -> Just CSmallInt
("str", []) -> Just CStr
("text", []) -> Just CStr
("varchar", []) -> Just CStr
("varchar", [n]) -> CVarchar <$> positive n
("char", []) -> Just (CChar 1)
("char", [n]) -> CChar <$> positive n
("bool", []) -> Just CBool
("boolean", []) -> Just CBool
("float", []) -> Just CFloat
("real", []) -> Just CFloat
("double", []) -> Just CDouble
("decimal", []) -> Just (CDecimal 10 0)
("numeric", []) -> Just (CDecimal 10 0)
("decimal", [p]) -> flip CDecimal 0 <$> positive p
("numeric", [p]) -> flip CDecimal 0 <$> positive p
("decimal", [p, s]) -> CDecimal <$> positive p <*> plain s
("numeric", [p, s]) -> CDecimal <$> positive p <*> plain s
("date", []) -> Just CDate
("timestamp", []) -> Just CTimestamp
("blob", []) -> Just CBlob
_ -> Nothing
where
cleaned = map toLower (filter (not . isSpace) raw)
(base, rest) = span (/= '(') cleaned
args = case stripSuffixMaybe ")" (drop 1 rest) of
Nothing -> []
Just inner -> map (filter (not . isSpace)) (splitOn ',' inner)
-- | 去掉结尾
stripSuffixMaybe :: String -> String -> Maybe String
stripSuffixMaybe suffix s
| suffix `isSuffixOf` s = Just (take (length s - length suffix) s)
| otherwise = Nothing
-- | 按逗号切开
splitOn :: Char -> String -> [String]
splitOn sep s = case break (== sep) s of
(part, []) -> [part]
(part, _ : rest) -> part : splitOn sep rest
-- | 正数参数
positive :: String -> Maybe Int
positive s
| not (null s) && all isDigit s = Just (read s)
| otherwise = Nothing
-- | 非负整数参数
plain :: String -> Maybe Int
plain = positive
-- | 写出普通列就只说类型名,带约束才补在后面
instance Show Column where
show c = typeLabel (columnType c) ++ notNull ++ def ++ auto ++ uni ++ key ++ check
where
notNull = if columnNullable c then "" else " NOT NULL"
def = maybe "" (\v -> " DEFAULT " ++ show v) (columnDefault c)
auto = if columnAutoIncrement c then " AUTO_INCREMENT" else ""
uni = if columnUnique c then " UNIQUE" else ""
key = if columnPrimaryKey c then " PRIMARY KEY" else ""
check = maybe "" (\s -> " CHECK (" ++ s ++ ")") (columnCheck c)
-- | 类型大类
data TypeClass
= NumericClass
| TextClass
| TemporalClass
| BooleanClass
deriving (Show, Eq)
typeClassOf :: ColumnType -> TypeClass
typeClassOf CInt = NumericClass
typeClassOf CBigInt = NumericClass
typeClassOf CSmallInt = NumericClass
typeClassOf CFloat = NumericClass
typeClassOf CDouble = NumericClass
typeClassOf CDecimal{} = NumericClass
typeClassOf CStr = TextClass
typeClassOf CVarchar{} = TextClass
typeClassOf CChar{} = TextClass
typeClassOf CBlob = TextClass
typeClassOf CDate = TemporalClass
typeClassOf CTimestamp = TemporalClass
typeClassOf CBool = BooleanClass
-- | 同大类可赋值;日期/时间列接受字符串字面量
assignable :: ColumnType -> ColumnType -> Bool
assignable target source
| target == source = True
| typeClassOf target == typeClassOf source = True
| typeClassOf target == TemporalClass && typeClassOf source == TextClass = True
| otherwise = False
integerType :: ColumnType -> Bool
integerType CInt = True
integerType CBigInt = True
integerType CSmallInt = True
integerType _ = False
numericType :: ColumnType -> Bool
numericType t = typeClassOf t == NumericClass
-- | 值能不能放进这一列(先不看可空)
valueFits :: ColumnType -> Value -> Bool
valueFits _ VNull = True
valueFits CInt (VInt _) = True
valueFits CBigInt (VInt _) = True
valueFits CSmallInt (VInt _) = True
valueFits CFloat (VInt _) = True
valueFits CFloat (VFloat _) = True
valueFits CDouble (VInt _) = True
valueFits CDouble (VFloat _) = True
valueFits CDecimal{} (VInt _) = True
valueFits CDecimal{} (VFloat _) = True
valueFits CStr (VStr _) = True
valueFits CVarchar{} (VStr _) = True
valueFits CChar{} (VStr _) = True
valueFits CBool (VBool _) = True
valueFits CDate (VStr _) = True
valueFits CTimestamp (VStr _) = True
valueFits CBlob (VStr _) = True
valueFits _ _ = False
-- | 把值收进列类型:NULL 原样通过,类型不符报错
coerceValue :: ColumnType -> Value -> Either String Value
coerceValue _ VNull = Right VNull
coerceValue t v = case t of
CInt -> toInt t v
CBigInt -> toInt t v
CSmallInt -> toInt t v
CFloat -> toFloat t v
CDouble -> toFloat t v
CDecimal p s -> toDecimal p s v
CStr -> toText t v
CVarchar n -> toSized n v
CChar n -> toSized n v
CBool -> toBool t v
CDate -> toDate v
CTimestamp -> toTimestamp v
CBlob -> toText t v
toInt :: ColumnType -> Value -> Either String Value
toInt _ (VInt n) = Right (VInt n)
toInt t (VFloat d) = case properFraction d :: (Int, Double) of
(n, frac)
| frac == 0 -> Right (VInt n)
| otherwise -> Left ("cannot put a fractional number into " ++ typeLabel t)
toInt t _ = Left ("cannot put this value into " ++ typeLabel t)
toFloat :: ColumnType -> Value -> Either String Value
toFloat _ (VInt n) = Right (VFloat (fromIntegral n))
toFloat _ (VFloat d) = Right (VFloat d)
toFloat t _ = Left ("cannot put this value into " ++ typeLabel t)
-- | DECIMAL 按小数位四舍五入
toDecimal :: Int -> Int -> Value -> Either String Value
toDecimal p s v = case v of
VInt n -> rounded (fromIntegral n)
VFloat d -> rounded d
_ -> Left ("cannot put this value into " ++ typeLabel (CDecimal p s))
where
scale = (10 :: Double) ^ max 0 s
rounded d = Right (VFloat (fromIntegral (round (d * scale) :: Integer) / scale))
toText :: ColumnType -> Value -> Either String Value
toText _ (VStr s) = Right (VStr s)
toText t _ = Left ("cannot put this value into " ++ typeLabel t)
toSized :: Int -> Value -> Either String Value
toSized n (VStr s)
| length s <= n = Right (VStr s)
| otherwise = Left ("value is longer than " ++ show n ++ " characters")
toSized _ _ = Left "cannot put this value into a character column"
toBool :: ColumnType -> Value -> Either String Value
toBool _ (VBool b) = Right (VBool b)
toBool t _ = Left ("cannot put this value into " ++ typeLabel t)
toDate :: Value -> Either String Value
toDate (VStr s)
| validDateText s = Right (VStr s)
| otherwise = Left "date must look like YYYY-MM-DD"
toDate _ = Left "date must be a string"
toTimestamp :: Value -> Either String Value
toTimestamp (VStr s)
| validTimestampText s = Right (VStr s)
| otherwise = Left "timestamp must look like YYYY-MM-DD HH:MM:SS"
toTimestamp _ = Left "timestamp must be a string"
-- | YYYY-MM-DD
validDateText :: String -> Bool
validDateText s = case splitOn '-' s of
[y, m, d] -> allOf 4 y && allOf 2 m && allOf 2 d
_ -> False
-- | YYYY-MM-DD HH:MM:SS,日期与时间之间允许空格或 T
validTimestampText :: String -> Bool
validTimestampText s = case break (\c -> c == ' ' || c == 'T') s of
(d, _ : tm) -> validDateText d && validTimeText tm
_ -> False
-- | HH:MM:SS
validTimeText :: String -> Bool
validTimeText t = case splitOn ':' t of
[h, m, sec] -> allOf 2 h && allOf 2 m && allOf 2 sec
_ -> False
allOf :: Int -> String -> Bool
allOf n s = length s == n && all isDigit s
data Value
= VInt Int
= VNull
| VInt Int
| VFloat Double
| 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
hashWithSalt s VNull = hashWithSalt (hashWithSalt s (0 :: Int)) ()
hashWithSalt s (VInt n) = hashWithSalt (hashWithSalt s (1 :: Int)) n
hashWithSalt s (VFloat d) = hashWithSalt (hashWithSalt s (2 :: Int)) d
hashWithSalt s (VStr t) = hashWithSalt (hashWithSalt s (3 :: Int)) t
hashWithSalt s (VBool b) = hashWithSalt (hashWithSalt s (4 :: Int)) b
type Row = [(String, Value)]
@@ -38,7 +365,7 @@ type Database = [(String, Table)]
colNames :: Table -> [String]
colNames = map fst . tableCols
-- | 查某列的类型
-- | 查某列的定义
colType :: Table -> String -> Maybe Column
colType t c = lookup c (tableCols t)
@@ -58,3 +385,12 @@ qualify (Just a) c = a ++ "." ++ c
unqualify :: Maybe String -> String -> String
unqualify Nothing c = c
unqualify (Just a) c = fromMaybe c (stripPrefix (a ++ ".") c)
-- | 裸列仅允许唯一匹配,限定名必须精确匹配
resolveColumn :: String -> [(String, a)] -> Either String (String, a)
resolveColumn name env = case [(k, v) | (k, v) <- env, matches k] of
[entry] -> Right entry
[] -> Left ("unknown column: " ++ name)
_ -> Left ("ambiguous column: " ++ name)
where
matches k = k == name || ('.' : name) `isSuffixOf` k
+623 -69
View File
@@ -1,30 +1,65 @@
module ChuSQL.Semantic (check) where
module ChuSQL.Semantic (check, prepare) where
import ChuSQL.Algebra.Expr (colsInExpr)
import ChuSQL.Algebra.Expr (bareColumns, colsInExpr, hasAggregate)
import ChuSQL.Model
import ChuSQL.Syntax.AST
import Data.List (intercalate, nub)
import ChuSQL.Syntax.Parser (parseExpression)
import Data.Char (isAlphaNum, isAscii, toLower)
import Data.List (intercalate, isInfixOf, isPrefixOf, nub)
import Data.Maybe (fromMaybe)
-- 语义检查:表和列在不在、类型对不对,全部在执行前查。
-- | 推导出来的类型:NULL 没有类型,可以放进任何列
data InferredType
= InferNull
| InferType ColumnType
deriving (Show, Eq)
-- | 给列名加上别名前缀
prefixColumns :: Maybe String -> [(String, Column)] -> [(String, Column)]
prefixColumns mAlias cols = [(prefix ++ c, ty) | (c, ty) <- cols]
where
prefix = maybe "" (++ ".") mAlias
-- | 收集 FROM 能提供的列
checkFrom :: Database -> FromClause -> Either String [(String, Column)]
checkFrom db (FromTable mAlias tbl) = do
-- | 检查期的作用域:本层优先,外层兜底。
-- 不能把两层平铺成一个列表:同名裸列会出现两次,被误报成 ambiguous。
data Scope = Scope
{ scopeInner :: [(String, Column)]
, scopeOuter :: [(String, Column)]
}
-- | 本层先查,查不到再落外层;本层同名引起歧义时不往外层让
scopeAt :: String -> Scope -> String -> Either String (String, Column)
scopeAt place scope c
| null (scopeOuter scope) = columnAt place (scopeInner scope) c
| otherwise = case columnAt place (scopeInner scope) c of
Right entry -> Right entry
Left err
| "ambiguous column:" `isInfixOf` err -> Left err
| otherwise -> columnAt place (scopeOuter scope) c
-- | 收集 FROM 能提供的列;outer 是外层作用域,给 ON 里的相关子查询用
checkFrom :: Database -> [(String, Column)] -> FromClause -> Either String [(String, Column)]
checkFrom _ _ FromUnit = Right []
checkFrom db _ (FromTable mAlias tbl) = do
t <- lookupTable db tbl
Right (prefixColumns mAlias (tableCols t))
checkFrom db (FromJoin left mAlias tbl cond) = do
lcols <- checkFrom db left
Right (prefixColumns (Just (fromMaybe tbl mAlias)) (tableCols t))
checkFrom db outer (FromJoin _ left mAlias tbl cond) = do
lcols <- checkFrom db outer left
t <- lookupTable db tbl
let env = lcols ++ prefixColumns mAlias (tableCols t)
checkBool "ON" env cond
let env = lcols ++ prefixColumns (Just (fromMaybe tbl mAlias)) (tableCols t)
checkBool db "ON" (Scope env outer) cond
Right env
-- | 本层作用域优先:外层已有的同名键不再重复堆进来(否则同一个键被数两次,误报 ambiguous)
scopedEnv :: [(String, Column)] -> [(String, Column)] -> [(String, Column)]
scopedEnv env outer = env ++ [o | o <- outer, o `notElem` env]
-- | 作用域摊平成一层(只用于继续往外传,查找仍走 scopeAt 的本层优先)
scopeFlat :: Scope -> [(String, Column)]
scopeFlat scope = scopedEnv (scopeInner scope) (scopeOuter scope)
-- | 拼“列不存在”的报错
missingColumn :: String -> String -> [(String, Column)] -> String
missingColumn place c env =
@@ -32,94 +67,287 @@ missingColumn place c env =
where
hint
| null env = ""
| otherwise = " (available: " ++ intercalate ", " (map fst env) ++ ")"
| otherwise = " (available: " ++ intercalate ", " (map (reverse . takeWhile (/= '.') . reverse . fst) env) ++ "; qualified: " ++ intercalate ", " (map fst env) ++ ")"
-- | 检查列是否都在环境里
checkColumns :: String -> [(String, Column)] -> [String] -> Either String ()
checkColumns place env requested =
case [c | c <- requested, c /= allColumns, c `notElem` map fst env] of
[] -> Right ()
(c : _) -> Left (missingColumn place c env)
checkColumns :: String -> Scope -> [String] -> Either String ()
checkColumns place env = mapM_ (\c -> if c == allColumns then Right () else () <$ scopeAt place env c)
-- | 推导表达式的类型
inferExpr :: String -> [(String, Column)] -> Expr -> Either String Column
inferExpr place env (Col c) = maybe (Left (missingColumn place c env)) Right (lookup c env)
inferExpr _ _ (LitInt _) = Right TInt
inferExpr _ _ (LitStr _) = Right TStr
inferExpr _ _ (LitBool _) = Right TBool
inferExpr place env (Gt a b) = operands place env TInt a b
inferExpr place env (Lt a b) = operands place env TInt a b
inferExpr place env (And a b) = operands place env TBool a b
inferExpr place env (Or a b) = operands place env TBool a b
inferExpr place env (Eq a b) = do
ta <- inferExpr place env a
tb <- inferExpr place env b
if ta == tb
then Right TBool
else Left (place ++ ": both sides of = must have the same type, got " ++ show ta ++ " and " ++ show tb)
-- | 保留子句名与可用列提示
columnAt :: String -> [(String, Column)] -> String -> Either String (String, Column)
columnAt place env c = case resolveColumn c env of
Right entry -> Right entry
Left err | "ambiguous column:" `isPrefixOf` err -> Left (place ++ ": " ++ err)
Left _ -> Left (missingColumn place c env)
-- | 检查二元运算两边的类型
operands :: String -> [(String, Column)] -> Column -> Expr -> Expr -> Either String Column
operands place env want a b = do
ta <- inferExpr place env a
tb <- inferExpr place env b
if ta == want && tb == want
then Right TBool
else Left (place ++ ": operator needs " ++ show want ++ " on both sides, got " ++ show ta ++ " and " ++ show tb)
-- | 聚合不能出现在 WHERE 里
checkNoAggregate :: String -> Expr -> Either String ()
checkNoAggregate place e
| hasAggregate e = Left (place ++ ": aggregate functions are not allowed here")
| otherwise = Right ()
-- | 分组查询里,没被聚合的列必须出现在 GROUP BY 里
checkGrouping :: Scope -> [String] -> [(String, Expr)] -> Either String ()
checkGrouping env keys items
| null keys && not (any (hasAggregate . snd) items) = Right ()
| otherwise = do
checkColumns "GROUP BY" env keys
mapM_ okCol (nub (concatMap (bareColumns . snd) items))
where
okCol c
| c == allColumns = Left "SELECT: * cannot be selected together with GROUP BY"
| c `elem` keys = Right ()
| otherwise = Left ("SELECT: column " ++ c ++ " must appear in GROUP BY or be aggregated")
-- | 渲染一个推导类型
renderType :: InferredType -> String
renderType InferNull = "NULL"
renderType (InferType t) = typeLabel t
-- | 推导表达式的类型(子查询也在这里整条查一遍)
inferExpr :: Database -> String -> Scope -> Expr -> Either String InferredType
inferExpr _ place env (Col c) = InferType . columnType . snd <$> scopeAt place env c
inferExpr _ _ _ LitNull = Right InferNull
inferExpr _ _ _ (LitInt _) = Right (InferType CInt)
inferExpr _ _ _ (LitFloat _) = Right (InferType CFloat)
inferExpr _ _ _ (LitStr _) = Right (InferType CStr)
inferExpr _ _ _ (LitBool _) = Right (InferType CBool)
inferExpr _ _ _ (LitDate _) = Right (InferType CDate)
inferExpr _ _ _ (LitTimestamp _) = Right (InferType CTimestamp)
inferExpr _ _ _ (LitBlob _) = Right (InferType CBlob)
inferExpr db place env (Add a b) = arithmetic db place env a b
inferExpr db place env (Sub a b) = arithmetic db place env a b
inferExpr db place env (Mul a b) = arithmetic db place env a b
inferExpr db place env (Div a b) = arithmetic db place env a b
inferExpr db place env (Neg a) = do
t <- inferExpr db place env a
case t of
InferNull -> Right InferNull
InferType x
| numericType x -> Right t
| otherwise -> Left (place ++ ": operator needs TInt on both sides, got TInt and " ++ typeLabel x)
inferExpr db place env (Gt a b) = comparison db place env a b
inferExpr db place env (Lt a b) = comparison db place env a b
inferExpr db place env (Eq a b) = comparison db place env a b
inferExpr db place env (And a b) = boolean db place env a b
inferExpr db place env (Or a b) = boolean db place env a b
inferExpr db place env (IsNull a) = inferExpr db place env a >> Right (InferType CBool)
inferExpr db place env (IsNotNull a) = inferExpr db place env a >> Right (InferType CBool)
inferExpr _ _ _ CountAll = Right (InferType CInt)
inferExpr db place env (CountOf a) = do
_ <- aggregateArg db place env a
Right (InferType CInt)
inferExpr db place env (SumOf a) = aggregateArg db place env a >>= numericResult "SUM" place
inferExpr db place env (AvgOf a) = do
t <- aggregateArg db place env a
case t of
InferNull -> Right InferNull
InferType x
| numericType x -> Right (InferType CFloat)
| otherwise -> Left (place ++ ": AVG needs a number, got " ++ typeLabel x)
inferExpr db place env (MinOf a) = aggregateArg db place env a
inferExpr db place env (MaxOf a) = aggregateArg db place env a
inferExpr db place env (ScalarSub sq) = do
cols <- inferSubqueryOutput db env sq
case cols of
[(_, t)] -> Right t
_ -> Left (place ++ ": scalar subquery must return exactly one column")
inferExpr db place env (InSub a sq _) = do
ta <- inferExpr db place env a
cols <- inferSubqueryOutput db env sq
case cols of
[(_, t)]
| compatible ta t -> Right (InferType CBool)
| otherwise -> Left (place ++ ": IN subquery column has type " ++ renderType t ++ ", expected " ++ renderType ta)
_ -> Left (place ++ ": IN subquery must return exactly one column")
inferExpr db place env (InList a es _) = do
ta <- inferExpr db place env a
ts <- mapM (inferExpr db place env) es
mapM_ (itemType place ta) ts
Right (InferType CBool)
inferExpr _ _ _ (ExistsSub _ _) = Right (InferType CBool)
-- | IN 列表里的每一项都要跟左边的类型对得上
itemType :: String -> InferredType -> InferredType -> Either String ()
itemType place want got
| compatible want got = Right ()
| otherwise = Left (place ++ ": IN list item has type " ++ renderType got ++ ", expected " ++ renderType want)
-- | 子查询的输出列与类型(外层作用域 outerScope 可做相关引用,内层整条查一遍)
inferSubqueryOutput :: Database -> Scope -> Subquery -> Either String [(String, InferredType)]
inferSubqueryOutput db outerScope sq = case subqueryStatement sq of
q@Select { selectCols = cols } -> do
env <- checkFrom db enclosing (selectFrom q)
checkResolvedWith db enclosing q
mapM (columnTypeOf env) cols
q@SelectExpr { selectItems = items } -> do
env <- checkFrom db enclosing (selectFrom q)
checkResolvedWith db enclosing q
mapM (\(l, e) -> (\t -> (l, t)) <$> inferExpr db "SELECT" (Scope env enclosing) e) items
_ -> Left "subquery must be a SELECT"
where
enclosing = scopeFlat outerScope
columnTypeOf env c = do
(_, col) <- scopeAt "SELECT" (Scope env enclosing) c
Right (c, InferType (columnType col))
-- | 聚合的参数:列要在,且不能嵌套聚合
aggregateArg :: Database -> String -> Scope -> Expr -> Either String InferredType
aggregateArg db place env a
| hasAggregate a = Left (place ++ ": nested aggregates are not allowed")
| otherwise = inferExpr db place env a
-- | 数值聚合的结果类型:整数进整数出,其它往浮点靠
numericResult :: String -> String -> InferredType -> Either String InferredType
numericResult fn place t = case t of
InferNull -> Right InferNull
InferType x
| numericType x -> Right (if integerType x then InferType x else InferType CFloat)
| otherwise -> Left (place ++ ": " ++ fn ++ " needs a number, got " ++ typeLabel x)
-- | 算术结果:有小数就出小数,否则是整数
arithResult :: InferredType -> InferredType -> InferredType
arithResult InferNull _ = InferNull
arithResult _ InferNull = InferNull
arithResult (InferType a) (InferType b)
| integerType a && integerType b = InferType CInt
| otherwise = InferType CFloat
-- | 算术两边的类型检查
arithmetic :: Database -> String -> Scope -> Expr -> Expr -> Either String InferredType
arithmetic db place env a b = do
ta <- inferExpr db place env a
tb <- inferExpr db place env b
let want = if any isFloatSide [ta, tb] then CFloat else CInt
mapM_ (numericOperand place (InferType want) (InferType want)) [ta, tb]
Right (arithResult ta tb)
where
isFloatSide (InferType x) = numericType x && not (integerType x)
isFloatSide _ = False
-- | 一侧必须是数
numericOperand :: String -> InferredType -> InferredType -> InferredType -> Either String ()
numericOperand place want other t = case t of
InferNull -> Right ()
InferType x | numericType x -> Right ()
_ -> Left (place ++ ": operator needs " ++ renderType want ++ " on both sides, got " ++ renderType other ++ " and " ++ renderType t)
-- | 比较两边的类型检查
comparison :: Database -> String -> Scope -> Expr -> Expr -> Either String InferredType
comparison db place env a b = do
ta <- inferExpr db place env a
tb <- inferExpr db place env b
if compatible ta tb
then Right (InferType CBool)
else Left (place ++ ": both sides of = must have the same type, got " ++ renderType ta ++ " and " ++ renderType tb)
-- | 两类能不能比
compatible :: InferredType -> InferredType -> Bool
compatible InferNull _ = True
compatible _ InferNull = True
compatible (InferType a) (InferType b)
| numericType a && numericType b = True
| typeClassOf a == typeClassOf b = True
| typeClassOf a == TemporalClass && typeClassOf b == TextClass = True
| typeClassOf b == TemporalClass && typeClassOf a == TextClass = True
| otherwise = False
-- | 逻辑运算两边的类型检查
boolean :: Database -> String -> Scope -> Expr -> Expr -> Either String InferredType
boolean db place env a b = do
ta <- inferExpr db place env a
tb <- inferExpr db place env b
mapM_ (boolOperand place ta) [ta, tb]
Right (InferType CBool)
where
boolOperand _ _ InferNull = Right ()
boolOperand _ _ (InferType CBool) = Right ()
boolOperand p other t = Left (p ++ ": operator needs TBool on both sides, got " ++ renderType other ++ " and " ++ renderType t)
-- | 要求条件能算出布尔
checkBool :: String -> [(String, Column)] -> Expr -> Either String ()
checkBool place env e = do
checkBool :: Database -> String -> Scope -> Expr -> Either String ()
checkBool db place env e = do
checkColumns place env (colsInExpr e)
t <- inferExpr place env e
if t == TBool
then Right ()
else Left (place ++ ": condition must be a boolean, got " ++ show t)
t <- inferExpr db place env e
case t of
InferNull -> Right ()
InferType CBool -> Right ()
InferType x -> Left (place ++ ": condition must be a boolean, got " ++ typeLabel x)
-- | 检查赋值列和类型
checkTyped :: String -> [(String, Column)] -> Table -> (String, Expr) -> Either String ()
checkTyped place env t (c, e) = do
checkTyped :: Database -> String -> Scope -> Table -> (String, Expr) -> Either String ()
checkTyped db place env t (c, e) = do
want <- maybe (Left (missingColumn place c (tableCols t))) Right (colType t c)
got <- inferExpr place env e
if want == got
then Right ()
else Left (place ++ ": column " ++ c ++ " needs " ++ show want ++ ", got " ++ show got)
got <- inferExpr db place env e
case got of
InferNull -> Right ()
InferType x
| assignable (columnType want) x -> Right ()
| otherwise -> Left (place ++ ": column " ++ c ++ " needs " ++ show want ++ ", got " ++ typeLabel x)
-- | 检查插入值的类型
checkValue :: Table -> (String, Expr) -> Either String ()
checkValue = checkTyped "INSERT" []
checkValue :: Database -> Table -> (String, Expr) -> Either String ()
checkValue db = checkTyped db "INSERT" (Scope [] [])
-- | 检查 UPDATE 的赋值
checkAssign :: Table -> (String, Expr) -> Either String ()
checkAssign t = checkTyped "UPDATE" (tableCols t) t
checkAssign :: Database -> Table -> (String, Expr) -> Either String ()
checkAssign db t = checkTyped db "UPDATE" (Scope (tableCols t) []) t
-- | 按语句类型分派检查
check :: Database -> Statement -> Either String ()
check db q = case q of
check db q = () <$ prepare db q
-- | 执行前解析列名并检查类型
prepare :: Database -> Statement -> Either String Statement
prepare db q = do
resolved <- resolveStatement db q
checkResolved db resolved
pure resolved
-- | 检查已解析的语句
checkResolved :: Database -> Statement -> Either String ()
checkResolved db = checkResolvedWith db []
-- | 检查已解析的语句;outer 是相关子查询能看见的外层列
checkResolvedWith :: Database -> [(String, Column)] -> Statement -> Either String ()
checkResolvedWith db outer q = case q of
Select
{ selectCols = cols
, selectFrom = fromC
, selectWhere = mWhere
, selectGroupBy = groupBy
, selectOrderBy = orderBy
} -> do
env <- checkFrom db fromC
checkColumns "SELECT" env cols
checkColumns "ORDER BY" env (map fst orderBy)
mapM_ (checkBool "WHERE" env) mWhere
env <- checkFrom db outer fromC
let full = Scope env outer
checkColumns "SELECT" full cols
checkGrouping full groupBy (map (\c -> (c, Col c)) cols)
checkColumns "ORDER BY" full (map fst orderBy)
mapM_ (checkNoAggregate "WHERE") mWhere
mapM_ (checkBool db "WHERE" full) mWhere
SelectExpr items fromC mWhere groupBy orderBy _ -> do
env <- checkFrom db outer fromC
let full = Scope env outer
mapM_ (\item -> () <$ inferExpr db "SELECT" full (snd item)) items
checkGrouping full groupBy items
checkColumns "ORDER BY" full (map fst orderBy)
mapM_ (checkNoAggregate "WHERE") mWhere
mapM_ (checkBool db "WHERE" full) mWhere
Insert tbl cols rows -> do
t <- lookupTable db tbl
checkColumns "INSERT" (tableCols t) cols
checkColumns "INSERT" (Scope (tableCols t) []) cols
if null rows
then Left "INSERT: no values"
else mapM_ (mapM_ (checkValue t) . zip cols) rows
else mapM_ (mapM_ (checkValue db t) . zip cols) rows
checkInsertTargets t cols
Delete tbl mWhere -> do
t <- lookupTable db tbl
mapM_ (checkBool "WHERE" (tableCols t)) mWhere
mapM_ (checkBool db "WHERE" (Scope (tableCols t) [])) mWhere
Update tbl assigns mWhere -> do
t <- lookupTable db tbl
checkColumns "UPDATE" (tableCols t) (map fst assigns)
mapM_ (checkAssign t) assigns
mapM_ (checkBool "WHERE" (tableCols t)) mWhere
checkColumns "UPDATE" (Scope (tableCols t) []) (map fst assigns)
mapM_ (checkAssign db t) assigns
mapM_ (checkBool db "WHERE" (Scope (tableCols t) [])) mWhere
CreateTable name cols -> do
if null name
then Left "CREATE TABLE: empty table name"
@@ -128,6 +356,7 @@ check db q = case q of
if length names /= length (nub names)
then Left ("CREATE TABLE: duplicate column names in " ++ name)
else Right ()
checkColumnDefs (map fst cols) cols
DropTable name ->
if null name
then Left "DROP TABLE: empty table name"
@@ -150,9 +379,334 @@ check db q = case q of
if col == idColumn
then Left "DROP COLUMN: the built-in id column cannot be dropped"
else Right ()
AddColumn tbl def -> do
t <- lookupTable db tbl
if fst def `elem` tableCols' t
then Left ("ADD COLUMN: column already exists: " ++ fst def)
else Right ()
checkColumnDefs (tableCols' t ++ [fst def]) [def]
RenameColumn tbl old new -> do
t <- lookupTable db tbl
if old `notElem` tableCols' t
then Left ("RENAME COLUMN: unknown column: " ++ old)
else Right ()
if old == idColumn
then Left "RENAME COLUMN: the built-in id column cannot be renamed"
else
if new `elem` tableCols' t
then Left ("RENAME COLUMN: column already exists: " ++ new)
else Right ()
AlterColumnType tbl col ty -> do
t <- lookupTable db tbl
c <- requireColumn "ALTER COLUMN" t col
if columnAutoIncrement c && not (integerType ty)
then Left "ALTER COLUMN: AUTO_INCREMENT column must stay an integer type"
else Right ()
AlterColumnDefault tbl col def -> do
t <- lookupTable db tbl
c <- requireColumn "ALTER COLUMN" t col
mapM_ (\v -> checkDefault c v) def
AlterColumnNull tbl col nullable -> do
t <- lookupTable db tbl
c <- requireColumn "ALTER COLUMN" t col
if not nullable && columnAutoIncrement c
then Left "ALTER COLUMN: AUTO_INCREMENT column cannot be nullable"
else Right ()
CreateUser name password -> checkAccount name password
AlterUser name password -> checkAccount name password
DropUser name -> checkUserName name
CreateRole name -> checkRoleName name
DropRole name -> checkRoleName name
GrantPrivileges privs obj role -> do
mapM_ checkPrivilege privs
checkGrantObject obj
checkRoleName role
RevokePrivileges privs obj role -> do
mapM_ checkPrivilege privs
checkGrantObject obj
checkRoleName role
GrantRole role members -> do
checkRoleName role
mapM_ checkUserName members
RevokeRole role members -> do
checkRoleName role
mapM_ checkUserName members
CreateDatabase name -> checkDatabaseName name
DropDatabase name -> checkDatabaseName name
UseDatabase name -> checkDatabaseName name
ShowDatabases -> Right ()
where
-- \| 表的列名清单
tableCols' = map fst . tableCols
-- | 查一列,没有就报错
requireColumn :: String -> Table -> String -> Either String Column
requireColumn place t c = maybe (Left (place ++ ": unknown column: " ++ c)) Right (colType t c)
-- | 插入时没提到的列能不能自己补上
checkInsertTargets :: Table -> [String] -> Either String ()
checkInsertTargets t cols = mapM_ checkTarget (tableCols t)
where
checkTarget (name, c)
| name `elem` cols = Right ()
| columnAutoIncrement c = Right ()
| columnNullable c = Right ()
| Just _ <- columnDefault c = Right ()
| otherwise = Left ("INSERT: column " ++ name ++ " is NOT NULL and has no default")
-- | 一列定义是否合法;names 是校验 CHECK 列引用时可用的全部列名
checkColumnDefs :: [String] -> [(String, Column)] -> Either String ()
checkColumnDefs names cols = do
mapM_ (\(_, c) -> mapM_ (checkDefault c) (columnDefault c)) cols
mapM_ (\(_, c) -> checkAutoIncrement c) cols
if length [() | (_, c) <- cols, columnAutoIncrement c] > 1
then Left "CREATE TABLE: at most one AUTO_INCREMENT column is allowed"
else Right ()
checkChecks names cols
-- | 自增列必须是整数列
checkAutoIncrement :: Column -> Either String ()
checkAutoIncrement c
| columnAutoIncrement c && not (integerType (columnType c)) = Left "AUTO_INCREMENT: column must be an integer type"
| otherwise = Right ()
-- | 默认值必须能放进这一列
checkDefault :: Column -> Value -> Either String ()
checkDefault c v
| VNull <- v = Right ()
| valueFits (columnType c) v = Right ()
| otherwise = Left ("DEFAULT: value does not fit column type " ++ show c)
-- | CHECK 里的列必须存在
checkChecks :: [String] -> [(String, Column)] -> Either String ()
checkChecks names = mapM_ checkOne
where
checkOne (_, c) = case columnCheck c of
Nothing -> Right ()
Just text -> case parseExpression text of
Left err -> Left ("CHECK: " ++ firstLine err)
Right e -> mapM_ (known e) (colsInExpr e)
known _ col
| col `elem` names = Right ()
| otherwise = Left ("CHECK: unknown column: " ++ col)
firstLine = takeWhile (/= '\n')
-- | 账号名上限
accountNameLimit :: Int
accountNameLimit = 64
checkDatabaseName :: String -> Either String ()
checkDatabaseName name
| null name || length name > 64 || not (all (\c -> isAscii c && (isAlphaNum c || c == '_')) name) = Left "invalid database name"
| otherwise = Right ()
-- | 口令上限
passwordLimit :: Int
passwordLimit = 256
-- | 账号名:ASCII 字母数字加点、短横线、下划线
checkUserName :: String -> Either String ()
checkUserName name
| null name = Left "USER: empty account name"
| length name > accountNameLimit = Left "USER: account name is too long"
| not (all allowed name) = Left "USER: account name may only contain ASCII letters, digits, dot, dash or underscore"
| otherwise = Right ()
where
allowed c = isAscii c && (isAlphaNum c || c `elem` ("_.-" :: String))
-- | 建号 / 改口令:名字合法且口令不空
checkAccount :: String -> String -> Either String ()
checkAccount name password = do
checkUserName name
if null password
then Left "USER: password must not be empty"
else
if length password > passwordLimit
then Left "USER: password is too long"
else Right ()
-- | 角色名:字符规则同账号名,另外不许拿权限关键字当名字(否则 GRANT 读不出来)
checkRoleName :: String -> Either String ()
checkRoleName name
| null name = Left "ROLE: empty role name"
| length name > accountNameLimit = Left "ROLE: role name is too long"
| not (all allowed name) = Left "ROLE: role name may only contain ASCII letters, digits, dot, dash or underscore"
| map toLower name `elem` privilegeNames = Left "ROLE: role name must not be a privilege keyword"
| otherwise = Right ()
where
allowed c = isAscii c && (isAlphaNum c || c `elem` ("_.-" :: String))
-- | 权限关键字(小写,只用于比较)
privilegeNames :: [String]
privilegeNames = ["select", "insert", "update", "delete", "all"]
-- | 权限名只能是这几个
checkPrivilege :: String -> Either String ()
checkPrivilege name
| map toLower name `elem` privilegeNames = Right ()
| otherwise = Left ("GRANT: unknown privilege: " ++ name)
-- | 授权对象:* 表示全部表,否则是一张表(可带一个库前缀)
checkGrantObject :: String -> Either String ()
checkGrantObject "*" = Right ()
checkGrantObject obj
| null obj = Left "GRANT: object name must not be empty"
| length obj > 128 = Left "GRANT: object name is too long"
| not (all allowed obj) = Left "GRANT: object name may only contain ASCII letters, digits, dot, dash or underscore"
| otherwise = Right ()
where
allowed c = isAscii c && (isAlphaNum c || c `elem` ("_.-" :: String))
idColumn :: String
idColumn = "id"
-- | 把列引用统一为执行期名字(子查询一起解析)
resolveExpr ::
Database ->
[(String, Column)] ->
[(String, Column)] ->
(String -> Either String String) ->
Expr ->
Either String Expr
resolveExpr db env outer f e = case e of
Col c -> Col <$> f c
Add a b -> binary Add a b
Sub a b -> binary Sub a b
Mul a b -> binary Mul a b
Div a b -> binary Div a b
Neg a -> Neg <$> resolveExpr db env outer f a
Gt a b -> binary Gt a b
Lt a b -> binary Lt a b
Eq a b -> binary Eq a b
And a b -> binary And a b
Or a b -> binary Or a b
IsNull a -> IsNull <$> resolveExpr db env outer f a
IsNotNull a -> IsNotNull <$> resolveExpr db env outer f a
CountAll -> Right CountAll
CountOf a -> CountOf <$> resolveExpr db env outer f a
SumOf a -> SumOf <$> resolveExpr db env outer f a
AvgOf a -> AvgOf <$> resolveExpr db env outer f a
MinOf a -> MinOf <$> resolveExpr db env outer f a
MaxOf a -> MaxOf <$> resolveExpr db env outer f a
ScalarSub sq -> ScalarSub <$> resolveSubquery db (env ++ outer) sq
InSub a sq negated -> InSub <$> resolveExpr db env outer f a <*> resolveSubquery db (env ++ outer) sq <*> pure negated
InList a es negated -> InList <$> resolveExpr db env outer f a <*> mapM (resolveExpr db env outer f) es <*> pure negated
ExistsSub sq negated -> ExistsSub <$> resolveSubquery db (env ++ outer) sq <*> pure negated
_ -> Right e
where
binary ctor a b = ctor <$> resolveExpr db env outer f a <*> resolveExpr db env outer f b
-- | 子查询:内层整条解析一遍,外层列参与解析(相关引用)
-- 解析完把“引用了哪些外层列”记进 subqueryRefs:投影下推要靠它保住这些列
resolveSubquery :: Database -> [(String, Column)] -> Subquery -> Either String Subquery
resolveSubquery db outer sq = do
stmt <- resolveSubqueryStatement db outer (subqueryStatement sq)
pure (Subquery stmt (outerRefsOf db stmt))
-- | 子查询里指向外层作用域的列(内层查不到的列就是外层引用)
outerRefsOf :: Database -> Statement -> [String]
outerRefsOf db stmt = nub [c | c <- statementColumns stmt, not (inInner c)]
where
inner = case checkFrom db [] (statementFrom stmt) of
Right env -> env
Left _ -> []
inInner c = case resolveColumn c inner of
Right _ -> True
Left _ -> False
-- | 一条语句里出现的所有列引用(含 JOIN 的 ON 和嵌套子查询的外层引用)
statementColumns :: Statement -> [String]
statementColumns stmt = case stmt of
Select cols fromC mWhere groupBy orderBy _ ->
cols ++ fromColumns fromC ++ concatMap colsInExpr mWhere ++ groupBy ++ map fst orderBy
SelectExpr items fromC mWhere groupBy orderBy _ ->
concatMap (colsInExpr . snd) items ++ fromColumns fromC ++ concatMap colsInExpr mWhere ++ groupBy ++ map fst orderBy
_ -> []
where
fromColumns FromUnit = []
fromColumns (FromTable _ _) = []
fromColumns (FromJoin _ l _ _ c) = fromColumns l ++ colsInExpr c
statementFrom :: Statement -> FromClause
statementFrom Select { selectFrom = fromC } = fromC
statementFrom SelectExpr { selectFrom = fromC } = fromC
statementFrom _ = FromUnit
resolveSubqueryStatement :: Database -> [(String, Column)] -> Statement -> Either String Statement
resolveSubqueryStatement db outer q@Select{} = resolveQuery db outer q (map (\c -> (c, Col c)) (selectCols q))
resolveSubqueryStatement db outer q@SelectExpr{} = resolveQuery db outer q (selectItems q)
resolveSubqueryStatement _ _ _ = Left "subquery must be a SELECT"
-- | 列名解析入口:查询走连接解析,改数据的语句按本表列解析
resolveStatement :: Database -> Statement -> Either String Statement
resolveStatement db q = case q of
Select{} -> resolveSelect db q
SelectExpr{} -> resolveSelect db q
Update tbl assigns mWhere -> do
t <- lookupTable db tbl
let env = tableCols t
name = nameIn env "UPDATE"
assigns' <- mapM (\(c, e) -> (\e' -> (c, e')) <$> resolveExpr db env [] name e) assigns
w <- mapM (resolveExpr db env [] (nameIn env "WHERE")) mWhere
pure (Update tbl assigns' w)
Delete tbl mWhere -> do
t <- lookupTable db tbl
w <- mapM (resolveExpr db (tableCols t) [] (nameIn (tableCols t) "WHERE")) mWhere
pure (Delete tbl w)
Insert tbl cols rows -> do
t <- lookupTable db tbl
let env = tableCols t
rows' <- mapM (mapM (resolveExpr db env [] (nameIn env "INSERT"))) rows
pure (Insert tbl cols rows')
other -> Right other
-- | 查一列的物理名
nameIn :: [(String, Column)] -> String -> String -> Either String String
nameIn env place c = fst <$> columnAt place env c
-- | 内层先查,查不到再落到外层;内层同名引起的歧义不往外层让
scopedColumnAt :: String -> [(String, Column)] -> [(String, Column)] -> String -> Either String (String, Column)
scopedColumnAt place inner outer c = scopeAt place (Scope inner outer) c
-- | 单表保留原表头,连接使用唯一前缀
resolveSelect :: Database -> Statement -> Either String Statement
resolveSelect db q@Select{} = resolveQuery db [] q (map (\c -> (c, Col c)) (selectCols q))
resolveSelect db q@SelectExpr{} = resolveQuery db [] q (selectItems q)
resolveSelect _ q = Right q
-- | 解析投影、条件、排序与连接引用;outer 是相关子查询能看见的外层列
resolveQuery :: Database -> [(String, Column)] -> Statement -> [(String, Expr)] -> Either String Statement
resolveQuery db outer q items = do
env <- checkFrom db outer (selectFrom q)
let physical c = case selectFrom q of
FromTable Nothing tbl -> unqualify (Just tbl) c
_ -> c
name place c = physical . fst <$> scopedColumnAt place env outer c
output (_, Col c) = do
k <- name "SELECT" c
pure (k, Col k)
output (label, e) = do
e' <- resolveExpr db env outer (name "SELECT") e
pure (label, e')
projected <- if items == [("*", Col "*")]
then if null env && selectFrom q == FromUnit
then Left "SELECT: * requires FROM"
else Right [(physical c, Col (physical c)) | (c, _) <- env]
else mapM output items
condition <- mapM (resolveExpr db env outer (name "WHERE")) (selectWhere q)
grouping <- mapM (name "GROUP BY") (selectGroupBy q)
ordering <- mapM (\(c, d) -> (\k -> (k, d)) <$> name "ORDER BY" c) (selectOrderBy q)
source <- case selectFrom q of
single@FromTable{} -> Right single
other -> resolveFrom db outer other
pure $ case q of
Select{} -> Select (map fst projected) source condition grouping ordering (selectLimit q)
_ -> SelectExpr projected source condition grouping ordering (selectLimit q)
-- | 连接两侧默认取表名作别名;ON 里的子查询也能引用外层列
resolveFrom :: Database -> [(String, Column)] -> FromClause -> Either String FromClause
resolveFrom _ _ FromUnit = Right FromUnit
resolveFrom _ _ (FromTable alias tbl) = Right (FromTable (Just (fromMaybe tbl alias)) tbl)
resolveFrom db outer fromC@(FromJoin kind left alias tbl cond) = do
env <- checkFrom db outer fromC
l <- resolveFrom db outer left
c <- resolveExpr db env outer (fmap fst . columnAt "ON" env) cond
pure (FromJoin kind l (Just (fromMaybe tbl alias)) tbl c)
+30 -6
View File
@@ -8,14 +8,29 @@ import ChuSQL.Model (Column, Database, Row, Table (..), Value (..))
-- 存储层抽象:上层只认 domain 类型,不关心底层实现。
data IndexResult
= IndexRow (Maybe Row)
= IndexRows [Row]
| NoIndex
deriving (Show, Eq)
class (Monad m) => MonadStorage m where
createDatabase :: String -> m (Either String ())
createDatabase _ = pure (Left "database management is unavailable in this storage")
dropDatabase :: String -> m (Either String ())
dropDatabase _ = pure (Left "database management is unavailable in this storage")
useDatabase :: String -> m (Either String ())
useDatabase _ = pure (Left "database selection is unavailable in this storage")
listDatabases :: m (Either String [String])
listDatabases = pure (Right [])
-- \| 全表扫描
scan :: String -> m (Either String [Row])
-- \| 只返回指定列;空清单保留每行的存在性。
scanColumns :: String -> [String] -> m (Either String [Row])
scanColumns t cols = do
result <- scan t
pure (fmap (map (\row -> [(c, v) | c <- cols, Just v <- [lookup c row]])) result)
-- \| 追加一行
insert :: String -> Row -> m (Either String ())
@@ -23,7 +38,6 @@ class (Monad m) => MonadStorage m where
insertMany :: String -> [Row] -> m (Either String ())
insertMany t = go
where
-- \| 一行一行来,遇到错就停
go [] = pure (Right ())
go (r : rs) = do
x <- insert t r
@@ -39,7 +53,6 @@ class (Monad m) => MonadStorage m where
Left e -> pure (Left e)
Right rs -> replaceAll t [r | r <- rs, not (doomed r)]
where
-- \| 这一行的 id 在待删清单里吗
doomed r = case lookup "id" r of
Just (VInt k) -> k `elem` ks
_ -> False
@@ -47,10 +60,19 @@ class (Monad m) => MonadStorage m where
-- \| 整表替换
replaceAll :: String -> [Row] -> m (Either String ())
-- \| 按某一列的索引取一行
lookupByColumn :: String -> String -> Int -> m (Either String IndexResult)
-- \| 按某一列的索引取一行(键可以是整数或字符串)
lookupByColumn :: String -> String -> Value -> m (Either String IndexResult)
lookupByColumn _ _ _ = pure (Right NoIndex)
-- \| 按某一列的索引做范围扫描;没有可用索引就回 Nothing
scanRange ::
String ->
String ->
Maybe (Value, Bool) ->
Maybe (Value, Bool) ->
m (Either String (Maybe [Row]))
scanRange _ _ _ _ = pure (Right Nothing)
-- \| 建一张空表
createTable :: String -> [(String, Column)] -> m (Either String ())
@@ -68,6 +90,9 @@ class (Monad m) => MonadStorage m where
-- \| 删一列,连它的索引与数据一起去掉
dropColumn :: String -> String -> m (Either String ())
-- \| ALTER 用:整表换列定义与全部行
replaceSchema :: String -> [(String, Column)] -> [Row] -> m (Either String ())
-- \| 取全库快照(表名 + 列 + 全部行)
snapshot :: m Database
@@ -75,5 +100,4 @@ class (Monad m) => MonadStorage m where
schema :: m Database
schema = fmap (map withoutRows) snapshot
where
-- \| 抹掉行,只留表名与列定义
withoutRows (name, t) = (name, t {tableRows = []})
+232 -31
View File
@@ -1,3 +1,4 @@
{-# LANGUAGE CPP #-}
{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE OverloadedStrings #-}
@@ -5,10 +6,14 @@ module ChuSQL.Storage.IPC (
IPCStorage (..),
Request (..),
Response (..),
Account (..),
SchemaColumn (..),
TableInfo (..),
sendRequest,
closeConnection,
defaultPipeName,
setPipeName,
getPipeName,
doListTables,
doLookupByColumn,
doInsert,
@@ -21,8 +26,11 @@ module ChuSQL.Storage.IPC (
import ChuSQL.Model
import ChuSQL.Storage
import Control.Concurrent.MVar (MVar, modifyMVar, modifyMVar_, newMVar)
import Control.Concurrent.MVar (MVar, modifyMVar, modifyMVar_, newMVar, readMVar)
import Control.Exception (IOException, try)
#if !defined(mingw32_HOST_OS)
import Control.Exception (onException)
#endif
import Data.Aeson (FromJSON (..), ToJSON (..), eitherDecodeStrict, encode, object, withObject, (.:), (.:?), (.!=), (.=))
import Data.Aeson qualified as A
import Data.Aeson.Key qualified as K
@@ -30,13 +38,24 @@ import Data.Aeson.KeyMap qualified as KM
import Data.Aeson.Types (Parser)
import Data.ByteString.Char8 qualified as BSC
import Data.ByteString.Lazy qualified as BL
import Data.Scientific (floatingOrInteger)
import Data.Maybe (fromMaybe)
import Data.Scientific (floatingOrInteger, fromFloatDigits)
import Data.Text qualified as T
import System.Environment (lookupEnv)
import System.IO
import System.IO.Unsafe (unsafePerformIO)
#if !defined(mingw32_HOST_OS)
import Network.Socket (Family (AF_UNIX), SockAddr (SockAddrUnix), SocketType (Stream), close, connect, defaultProtocol, socket, socketToHandle)
import System.Environment (lookupEnv)
#endif
-- IPC 存储实现:每个存储方法翻成管道上的一条 JSON 请求。
-- 端点:Windows 是具名管道 \\.\pipe\<name>,Unix 是文件系统套接字 <dir>/<name>.sock。
#if !defined(mingw32_HOST_OS)
-- | Unix 套接字路径的最大长度(Linux 108 含结尾 NUL,BSD 104,这里留余量)
maxSocketPathLength :: Int
maxSocketPathLength = 100
#endif
newtype IPCStorage a = IPCStorage
{ runIPCStorage :: IO a
@@ -58,13 +77,37 @@ instance Monad IPCStorage where
connectionRef :: MVar (Maybe (FilePath, Handle))
connectionRef = unsafePerformIO (newMVar Nothing)
-- | 打开管道
-- | 打开端点连接:Windows 把管道当文件打开,Unix 连一个 AF_UNIX 套接字
openPipe :: FilePath -> IO Handle
#if defined(mingw32_HOST_OS)
openPipe path = do
h <- openFile path ReadWriteMode
hSetBuffering h LineBuffering
hSetNewlineMode h noNewlineTranslation
pure h
#else
openPipe path = do
if length path > maxSocketPathLength
then ioError (userError ("socket path too long (" ++ show (length path) ++ " bytes): " ++ path))
else pure ()
sock <- socket AF_UNIX Stream defaultProtocol
connect sock (SockAddrUnix path) `onException` close sock
h <- socketToHandle sock ReadWriteMode
hSetBuffering h LineBuffering
hSetNewlineMode h noNewlineTranslation
pure h
-- | Unix 套接字目录:$XDG_RUNTIME_DIR → $TMPDIR → /tmp(与 Rust 侧同一规则)
socketDir :: IO String
socketDir = do
runtime <- fmap nonEmpty (lookupEnv "XDG_RUNTIME_DIR")
tmp <- fmap nonEmpty (lookupEnv "TMPDIR")
pure (fromMaybe "/tmp" (runtime <> tmp))
where
nonEmpty v = case v of
Just s | not (null s) -> Just s
_ -> Nothing
#endif
-- | 关掉缓存里的句柄(没有就什么都不做)
closeCached :: Maybe (FilePath, Handle) -> IO ()
@@ -91,11 +134,34 @@ dropConnection = modifyMVar_ connectionRef $ \cached -> do
closeConnection :: IO ()
closeConnection = dropConnection
-- | 管道路径
-- | 没配置管名时用的端点,与 chusql.toml 的 [server] pipe_name 默认值一致
defaultPipeName :: String
defaultPipeName = "chusql-joint"
{-# NOINLINE pipeNameRef #-}
pipeNameRef :: MVar String
pipeNameRef = unsafePerformIO (newMVar defaultPipeName)
-- | 设定本进程要连的端点;配置从哪来由调用方决定(本层不读环境变量)
setPipeName :: String -> IO ()
setPipeName name = modifyMVar_ pipeNameRef (\_ -> pure name)
-- | 当前端点
getPipeName :: IO String
getPipeName = readMVar pipeNameRef
-- | 端点的完整路径
pipePath :: IO String
#if defined(mingw32_HOST_OS)
pipePath = do
name <- maybe "chusql-storage" id <$> lookupEnv "CHUSQL_PIPE"
name <- getPipeName
pure ("\\\\.\\pipe\\" ++ name)
#else
pipePath = do
name <- getPipeName
dir <- socketDir
pure (dir ++ "/" ++ name ++ ".sock")
#endif
-- | 发一条请求,管道不通就重连
sendRequest :: Request -> IO Response
@@ -124,38 +190,83 @@ roundTrip req path = do
data SchemaColumn = SchemaColumn
{ scName :: String
, scType :: String
, scNullable :: Bool
, scDefault :: Maybe Value
, scAutoIncrement :: Bool
, scPrimaryKey :: Bool
, scUnique :: Bool
, scCheck :: Maybe String
}
deriving (Show, Eq)
instance ToJSON SchemaColumn where
toJSON (SchemaColumn n t) = object ["name" .= n, "ty" .= t]
toJSON sc =
object
[ "name" .= scName sc
, "ty" .= scType sc
, "nullable" .= scNullable sc
, "default" .= fmap valueToJSON (scDefault sc)
, "auto_increment" .= scAutoIncrement sc
, "primary_key" .= scPrimaryKey sc
, "unique" .= scUnique sc
, "check" .= scCheck sc
]
instance FromJSON SchemaColumn where
parseJSON = withObject "SchemaColumn" $ \o -> do
n <- o .: "name"
t <- o .: "ty"
pure (SchemaColumn n t)
nullable <- o .:? "nullable" .!= True
defJson <- o .:? "default"
def <- mapM valueFromJSON defJson
auto <- o .:? "auto_increment" .!= False
pk <- o .:? "primary_key" .!= False
uniq <- o .:? "unique" .!= False
chk <- o .:? "check"
pure (SchemaColumn n t nullable def auto pk uniq chk)
data Request
= ReqPing
| ReqDatabase String String
| ReqAllCatalog
| ReqInDatabase String Request
| ReqAccountsList
| ReqAccountCreate T.Text T.Text
| ReqAccountReset T.Text T.Text
| ReqAccountLogin T.Text (Maybe T.Text)
| ReqAccountDrop T.Text
| ReqScan String
| ReqScanColumns String [String]
| ReqInsert String Row
| ReqInsertBatch String [Row]
| ReqDeleteKeys String [Int]
| ReqReplaceAll String [Row]
| ReqListTables
| ReqLookupByColumn String String Int
| ReqLookupByColumn String String Value
| ReqRangeByIndex String String (Maybe (Value, Bool)) (Maybe (Value, Bool))
| ReqDescribeTable String
| ReqCreateTable String [SchemaColumn]
| ReqDropTable String
| ReqCreateIndex String String
| ReqDropIndex String String
| ReqDropColumn String String
| ReqReplaceSchema String [SchemaColumn] [Row]
| ReqListCatalog
instance ToJSON Request where
toJSON (ReqDatabase method name) = object ["method" .= method, "database" .= name]
toJSON ReqAllCatalog = object ["method" .= ("all_catalogs" :: T.Text)]
toJSON (ReqInDatabase name req) = case toJSON req of
A.Object fields -> A.Object (KM.insert "database" (A.String (T.pack name)) fields)
other -> other
toJSON ReqAccountsList = object ["method" .= ("accounts_list" :: T.Text)]
toJSON (ReqAccountCreate u h) = object ["method" .= ("account_create" :: T.Text), "user" .= u, "password_hash" .= h]
toJSON (ReqAccountReset u h) = object ["method" .= ("account_reset" :: T.Text), "user" .= u, "password_hash" .= h]
toJSON (ReqAccountLogin u at) = object ["method" .= ("account_login" :: T.Text), "user" .= u, "at" .= at]
toJSON (ReqAccountDrop u) = object ["method" .= ("account_drop" :: T.Text), "user" .= u]
toJSON ReqPing = object ["method" .= ("ping" :: T.Text)]
toJSON (ReqScan t) = object ["method" .= ("scan" :: T.Text), "table" .= t]
toJSON (ReqScanColumns t cols) = object ["method" .= ("scan" :: T.Text), "table" .= t, "columns" .= cols]
toJSON (ReqInsert t r) =
object
[ "method" .= ("insert" :: T.Text)
@@ -186,7 +297,17 @@ instance ToJSON Request where
[ "method" .= ("lookup_by_index" :: T.Text)
, "table" .= t
, "column" .= c
, "key" .= k
, "key" .= valueToJSON k
]
toJSON (ReqRangeByIndex t c lo hi) =
object
[ "method" .= ("range_by_index" :: T.Text)
, "table" .= t
, "column" .= c
, "lo" .= fmap (valueToJSON . fst) lo
, "lo_inclusive" .= maybe True snd lo
, "hi" .= fmap (valueToJSON . fst) hi
, "hi_inclusive" .= maybe True snd hi
]
toJSON (ReqDescribeTable t) =
object
@@ -219,6 +340,13 @@ instance ToJSON Request where
, "table" .= t
, "column" .= c
]
toJSON (ReqReplaceSchema t cols rs) =
object
[ "method" .= ("replace_schema" :: T.Text)
, "table" .= t
, "columns" .= cols
, "rows" .= map rowToJSON rs
]
toJSON ReqListCatalog = object ["method" .= ("list_catalog" :: T.Text)]
data TableInfo = TableInfo
@@ -230,8 +358,31 @@ data TableInfo = TableInfo
}
deriving (Show, Eq)
data Account = Account
{ accountId :: Integer
, accountUser :: T.Text
, accountHash :: T.Text
, accountRevision :: Integer
, accountRegisteredAt :: T.Text
, accountLastLoginAt :: Maybe T.Text
} deriving (Eq)
instance Show Account where
show a = "Account " ++ show (accountUser a) ++ " revision=" ++ show (accountRevision a)
instance FromJSON Account where
parseJSON = withObject "Account" $ \o -> Account <$> o .: "id" <*> o .: "user"
<*> o .: "password_hash" <*> o .: "revision"
<*> o .:? "registered_at" .!= "" <*> o .:? "last_login_at"
instance ToJSON Account where
toJSON a = object ["id" .= accountId a, "user" .= accountUser a,
"password_hash" .= accountHash a, "revision" .= accountRevision a,
"registered_at" .= accountRegisteredAt a, "last_login_at" .= accountLastLoginAt a]
data Response
= RespPong
| RespAccounts [Account]
| RespRows [Row]
| RespTables [String]
| RespSchema TableInfo
@@ -244,6 +395,7 @@ instance FromJSON Response where
parseJSON = withObject "Response" $ \o -> do
status <- o .: "status"
case status :: T.Text of
"accounts" -> RespAccounts <$> o .: "accounts"
"pong" -> pure RespPong
"ok" -> pure RespOk
"no_index" -> pure RespNoIndex
@@ -256,7 +408,6 @@ instance FromJSON Response where
RespCatalog <$> mapM parseTable xs
other -> fail ("unknown status: " ++ T.unpack other)
where
-- \| 解一张表的 schema
parseTable :: A.Value -> Parser TableInfo
parseTable = withObject "TableInfo" $ \o -> do
t <- o .: "table"
@@ -268,7 +419,6 @@ instance FromJSON Response where
stats <- mapM parseStat (sts :: [A.Value])
pure (TableInfo t cols cnt idxCols stats)
-- \| 解一条列统计
parseStat :: A.Value -> Parser (String, Int, Bool)
parseStat = withObject "ColumnStat" $ \o -> do
name <- o .: "name"
@@ -278,16 +428,19 @@ instance FromJSON Response where
-- | 值编码成 JSON
valueToJSON :: Value -> A.Value
valueToJSON VNull = A.Null
valueToJSON (VInt n) = A.Number (fromIntegral n)
valueToJSON (VFloat d) = A.Number (fromFloatDigits d)
valueToJSON (VStr s) = A.String (T.pack s)
valueToJSON (VBool b) = A.Bool b
-- | JSON 解回值
valueFromJSON :: A.Value -> Parser Value
valueFromJSON A.Null = pure VNull
valueFromJSON (A.Number n) =
case floatingOrInteger n :: Either Double Integer of
Right i -> pure (VInt (fromIntegral i))
Left _ -> fail "non-integer number"
Left d -> pure (VFloat d)
valueFromJSON (A.String s) = pure (VStr (T.unpack s))
valueFromJSON (A.Bool b) = pure (VBool b)
valueFromJSON _ = fail "unsupported value type"
@@ -301,7 +454,6 @@ rowToJSON r =
rowFromJSON :: A.Value -> Parser Row
rowFromJSON (A.Object o) = mapM toPair (KM.toList o)
where
-- \| 解一个键值对
toPair (k, v) = do
val <- valueFromJSON v
pure (K.toString k, val)
@@ -356,14 +508,25 @@ doListCatalog = ask "list_catalog" ReqListCatalog $ \resp -> case resp of
RespCatalog xs -> Just xs
_ -> Nothing
-- | 按某一列的索引取一行
doLookupByColumn :: String -> String -> Int -> IO (Either String IndexResult)
-- | 按某一列的索引取一行(键可以是整数或字符串)
doLookupByColumn :: String -> String -> Value -> IO (Either String IndexResult)
doLookupByColumn t c k = ask "lookup_by_index" (ReqLookupByColumn t c k) $ \resp -> case resp of
RespRows [] -> Just (IndexRow Nothing)
RespRows (r : _) -> Just (IndexRow (Just r))
RespRows rows -> Just (IndexRows rows)
RespNoIndex -> Just NoIndex
_ -> Nothing
-- | 范围扫描:没有可用索引就回 Nothing,让上层退回扫描
doScanRange ::
String ->
String ->
Maybe (Value, Bool) ->
Maybe (Value, Bool) ->
IO (Either String (Maybe [Row]))
doScanRange t c lo hi = ask "range_by_index" (ReqRangeByIndex t c lo hi) $ \resp -> case resp of
RespRows rows -> Just (Just rows)
RespNoIndex -> Just Nothing
_ -> Nothing
-- | 发 DescribeTable
doDescribeTable :: String -> IO (Either String TableInfo)
doDescribeTable t = ask "describe_table" (ReqDescribeTable t) $ \resp -> case resp of
@@ -382,6 +545,10 @@ 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
-- | 发 ReplaceSchema(ALTER 用)
doReplaceSchema :: String -> [SchemaColumn] -> [Row] -> IO (Either String ())
doReplaceSchema t cols rs = ask "replace_schema" (ReqReplaceSchema t cols rs) okOnly
-- | 发 CreateTable
doCreateTable :: String -> [SchemaColumn] -> IO (Either String ())
doCreateTable t cols = ask "create_table" (ReqCreateTable t cols) okOnly
@@ -392,16 +559,48 @@ doDropTable t = ask "drop_table" (ReqDropTable t) okOnly
-- | 线上 schema 转本地列
schemaToColumns :: [SchemaColumn] -> [(String, Column)]
schemaToColumns = map go
where
go (SchemaColumn n "int") = (n, TInt)
go (SchemaColumn n "str") = (n, TStr)
go (SchemaColumn n "bool") = (n, TBool)
go (SchemaColumn n _) = (n, TStr)
schemaToColumns = map toColumn
toColumn :: SchemaColumn -> (String, Column)
toColumn sc =
( scName sc
, Column
{ columnType = fromMaybe CStr (parseColumnType (scType sc))
, columnNullable = scNullable sc
, columnDefault = scDefault sc
, columnAutoIncrement = scAutoIncrement sc
, columnUnique = scUnique sc
, columnPrimaryKey = scPrimaryKey sc
, columnCheck = scCheck sc
}
)
-- | 本地列转线上 schema
toWire :: (String, Column) -> SchemaColumn
toWire (n, c) =
SchemaColumn
{ scName = n
, scType = typeName (columnType c)
, scNullable = columnNullable c
, scDefault = columnDefault c
, scAutoIncrement = columnAutoIncrement c
, scPrimaryKey = columnPrimaryKey c
, scUnique = columnUnique c
, scCheck = columnCheck c
}
instance MonadStorage IPCStorage where
createDatabase name = IPCStorage (ask "create_database" (ReqDatabase "create_database" name) okOnly)
dropDatabase name = IPCStorage (ask "drop_database" (ReqDatabase "drop_database" name) okOnly)
useDatabase name = IPCStorage (ask "use_database" (ReqDatabase "use_database" name) okOnly)
listDatabases = IPCStorage $ ask "list_databases" (ReqDatabase "list_databases" "") $ \resp -> case resp of
RespTables names -> Just names
_ -> Nothing
-- \| 发 Scan
scan t = IPCStorage (doScan t)
scanColumns t cols = IPCStorage $ ask "scan" (ReqScanColumns t cols) $ \resp -> case resp of
RespRows rows -> Just [[(c, v) | c <- cols, Just v <- [lookup c row]] | row <- rows]
_ -> Nothing
-- \| 发 Insert(索引由存储层自己维护)
insert t r = IPCStorage (doInsert t r)
@@ -418,13 +617,14 @@ instance MonadStorage IPCStorage where
-- \| 按某一列的索引取一行(没有索引就回 NoIndex)
lookupByColumn t c k = IPCStorage (doLookupByColumn t c k)
-- \| 范围扫描(没有索引就回 Nothing)
scanRange t c lo hi = IPCStorage (doScanRange t c lo hi)
-- \| 发 CreateTable
createTable name cols = IPCStorage (doCreateTable name (map toWire cols))
where
-- \| 本地列类型转线上字符串
toWire (n, TInt) = SchemaColumn n "int"
toWire (n, TStr) = SchemaColumn n "str"
toWire (n, TBool) = SchemaColumn n "bool"
-- \| ALTER:整表换列定义与全部行
replaceSchema name cols rows = IPCStorage (doReplaceSchema name (map toWire cols) rows)
-- \| 发 DropTable
dropTable name = IPCStorage (doDropTable name)
@@ -445,7 +645,6 @@ instance MonadStorage IPCStorage where
Left _ -> pure []
Right xs -> mapM loadEntry xs
where
-- \| 加载一张表,行来自 scan
loadEntry info = do
rows <- doScan (tiTable info)
pure
@@ -455,7 +654,9 @@ instance MonadStorage IPCStorage where
-- \| 只问数据字典要结构,一行数据都不拉过来
schema = IPCStorage $ do
result <- doListCatalog
result <- ask "all_catalogs" ReqAllCatalog $ \resp -> case resp of
RespCatalog infos -> Just infos
_ -> Nothing
pure $ case result of
Left _ -> []
Right xs ->
@@ -74,6 +74,11 @@ instance MonadStorage MemoryStorage where
-- \| 原样返回当前库
snapshot = MemoryStorage $ \db -> Right (db, db)
-- \| 改表结构:列定义与行一起换
replaceSchema name cols rows = MemoryStorage $ \db -> case lookup name db of
Nothing -> Right (Left ("unknown table: " ++ name), db)
Just tbl -> Right (Right (), replaceTable name tbl{tableCols = cols, tableRows = rows} db)
-- | 用给定表替换同名表
replaceTable :: String -> Table -> Database -> Database
replaceTable name table =
+73 -3
View File
@@ -1,6 +1,6 @@
module ChuSQL.Syntax.AST (Statement (..), FromClause (..), Expr (..), SortDir (..), makeSelect) where
module ChuSQL.Syntax.AST (Statement (..), FromClause (..), JoinKind (..), Expr (..), Subquery (..), SortDir (..), makeSelect) where
import ChuSQL.Model (Column (..))
import ChuSQL.Model (Column (..), ColumnType, Value (..))
-- 语法树:语句、数据来源、条件表达式、排序方向。
@@ -9,6 +9,15 @@ data Statement
{ selectCols :: [String]
, selectFrom :: FromClause
, selectWhere :: Maybe Expr
, selectGroupBy :: [String]
, selectOrderBy :: [(String, SortDir)]
, selectLimit :: Maybe Int
}
| SelectExpr
{ selectItems :: [(String, Expr)]
, selectFrom :: FromClause
, selectWhere :: Maybe Expr
, selectGroupBy :: [String]
, selectOrderBy :: [(String, SortDir)]
, selectLimit :: Maybe Int
}
@@ -20,23 +29,83 @@ data Statement
| CreateIndex String String
| DropIndex String String
| DropColumn String String
| AddColumn String (String, Column)
| RenameColumn String String String
| AlterColumnType String String ColumnType
| AlterColumnDefault String String (Maybe Value)
| AlterColumnNull String String Bool
| CreateUser String String
| AlterUser String String
| DropUser String
-- | 新角色:权限的载体,用户靠成员资格继承它的权限
| CreateRole String
-- | 删角色:连同它的授权与成员一起删
| DropRole String
-- | GRANT <权限>[, ...] ON <表|*> TO <角色>
| GrantPrivileges [String] String String
-- | REVOKE <权限>[, ...] ON <表|*> FROM <角色>
| RevokePrivileges [String] String String
-- | GRANT <角色> TO <用户>[, ...]:给用户加角色
| GrantRole String [String]
-- | REVOKE <角色> FROM <用户>[, ...]
| RevokeRole String [String]
| CreateDatabase String
| DropDatabase String
| UseDatabase String
| ShowDatabases
deriving (Show, Eq)
-- | 连接类型:内连接与左外连接
data JoinKind
= InnerJoin
| LeftJoin
deriving (Show, Eq)
data FromClause
= FromTable (Maybe String) String
| FromJoin FromClause (Maybe String) String Expr
| FromJoin JoinKind FromClause (Maybe String) String Expr
| FromUnit
deriving (Show, Eq)
-- | 子查询:语句,加上它对外层列的引用(自由变量,语义检查时填)
data Subquery = Subquery
{ subqueryStatement :: Statement
, subqueryRefs :: [String]
}
deriving (Show, Eq)
data Expr
= Col String
| LitNull
| LitInt Int
| LitFloat Double
| LitStr String
| LitBool Bool
| LitDate String
| LitTimestamp String
| LitBlob String
| Add Expr Expr
| Sub Expr Expr
| Mul Expr Expr
| Div Expr Expr
| Neg Expr
| Gt Expr Expr
| Lt Expr Expr
| Eq Expr Expr
| And Expr Expr
| Or Expr Expr
| IsNull Expr
| IsNotNull Expr
| CountAll
| CountOf Expr
| SumOf Expr
| AvgOf Expr
| MinOf Expr
| MaxOf Expr
| ScalarSub Subquery
| InSub Expr Subquery Bool
| InList Expr [Expr] Bool
| ExistsSub Subquery Bool
deriving (Show, Eq)
data SortDir
@@ -51,6 +120,7 @@ makeSelect cols tbl w =
{ selectCols = cols
, selectFrom = FromTable Nothing tbl
, selectWhere = w
, selectGroupBy = []
, selectOrderBy = []
, selectLimit = Nothing
}
+369 -58
View File
@@ -1,11 +1,12 @@
{-# LANGUAGE ImportQualifiedPost #-}
module ChuSQL.Syntax.Parser (parseStatement) where
module ChuSQL.Syntax.Parser (parseStatement, parseExpression) where
import ChuSQL.Model (Column (..))
import ChuSQL.Model (Column (..), ColumnType (..), Value (..), plainColumn)
import ChuSQL.Syntax.AST
import Control.Monad (void)
import Control.Monad.Combinators.Expr (Operator (..), makeExprParser)
import Data.Char (isAlpha, isAlphaNum, toLower)
import Data.Char (isAlpha, isAlphaNum, isHexDigit, isSpace, toLower, toUpper)
import Data.List (intercalate)
import Data.Maybe (fromMaybe)
import Data.Void (Void)
@@ -19,7 +20,7 @@ type Parser = Parsec Void String
-- | 跳过空白
sc :: Parser ()
sc = Lxr.space space1 empty empty
sc = Lxr.space space1 (Lxr.skipLineComment "--") (Lxr.skipBlockComment "/*" "*/")
-- | 词后面吃掉空白
lexeme :: Parser a -> Parser a
@@ -42,6 +43,10 @@ identifier = lexeme $ do
rest <- many (satisfy isIdentChar)
return (first : rest)
-- | 账号名:裸名字或单引号字符串(账号名允许点与短横线,字符串形式才写得出来)
userName :: Parser String
userName = identifier <|> stringLit
-- | 名字里允许的字符
isIdentChar :: Char -> Bool
isIdentChar ch = isAlphaNum ch || ch == '_'
@@ -50,6 +55,10 @@ isIdentChar ch = isAlphaNum ch || ch == '_'
integer :: Parser Int
integer = lexeme Lxr.decimal
-- | 读一个浮点数(必须有小数点或指数)
floatLit :: Parser Double
floatLit = lexeme Lxr.float
-- | 读一个字符串字面量
stringLit :: Parser String
stringLit = lexeme $ do
@@ -66,6 +75,15 @@ stringChar =
, satisfy (/= '\'')
]
-- | X'ABCD' 形式的二进制字面量,统一存成大写十六进制文本
blobLit :: Parser String
blobLit = lexeme $ do
_ <- char 'x' <|> char 'X'
_ <- char '\''
s <- many (satisfy isHexDigit)
_ <- char '\''
return (map toUpper s)
-- | 升序或降序
sortDir :: Parser SortDir
sortDir = (Desc <$ keyword "desc") <|> (Asc <$ keyword "asc") <|> pure Asc
@@ -83,7 +101,6 @@ qualifiedName = do
rest <- many (symbol "." *> identifier)
return (intercalate "." (first : rest))
-- | 读一个表达式
expr :: Parser Expr
expr = makeExprParser atom operatorTable
@@ -91,11 +108,17 @@ expr = makeExprParser atom operatorTable
-- | 运算符优先级(从紧到松)
operatorTable :: [[Operator Parser Expr]]
operatorTable =
[
[ InfixN (Gt <$ symbol ">")
[ [Prefix (Neg <$ symbol "-")]
, [InfixL (Mul <$ symbol "*"), InfixL (Div <$ symbol "/")]
, [InfixL (Add <$ symbol "+"), InfixL (Sub <$ symbol "-")]
, [ InfixN (Gt <$ symbol ">")
, InfixN (Lt <$ symbol "<")
, InfixN (Eq <$ symbol "=")
]
, [ Postfix inPredicate
, Postfix (IsNotNull <$ try (keyword "is" *> keyword "not" *> keyword "null"))
, Postfix (IsNull <$ (keyword "is" *> keyword "null"))
]
, [InfixL (And <$ keyword "AND")]
, [InfixL (Or <$ keyword "OR")]
]
@@ -104,14 +127,79 @@ operatorTable =
atom :: Parser Expr
atom =
choice
[ LitBool True <$ keyword "TRUE"
[ LitNull <$ keyword "null"
, LitBool True <$ keyword "TRUE"
, LitBool False <$ keyword "FALSE"
, try (LitDate <$> (keyword "date" *> stringLit))
, try (LitTimestamp <$> (keyword "timestamp" *> stringLit))
, try (LitBlob <$> blobLit)
, LitFloat <$> try floatLit
, LitInt <$> integer
, LitStr <$> stringLit
, try aggregateCall
, try existsPredicate
, Col <$> qualifiedName
, try scalarSubquery
, between (symbol "(") (symbol ")") expr
]
-- | [NOT] EXISTS (SELECT ...)
existsPredicate :: Parser Expr
existsPredicate = do
negated <- (True <$ try (keyword "not" *> keyword "exists")) <|> (False <$ keyword "exists")
sq <- between (symbol "(") (symbol ")") subquerySelect
return (ExistsSub sq negated)
-- | 标量子查询:(SELECT ...)
scalarSubquery :: Parser Expr
scalarSubquery = ScalarSub <$> between (symbol "(") (symbol ")") subquerySelect
-- | [NOT] IN (SELECT ...) 或 [NOT] IN (值清单)
inPredicate :: Parser (Expr -> Expr)
inPredicate = do
negated <- (True <$ try (keyword "not" *> keyword "in")) <|> (False <$ keyword "in")
body <-
between (symbol "(") (symbol ")") $
choice
[ Left <$> try subquerySelect
, Right <$> sepBy1 expr (symbol ",")
]
return $ \e -> case body of
Left sq -> InSub e sq negated
Right es -> InList e es negated
-- | 括号里的子查询;对外层列的引用留给语义检查填
subquerySelect :: Parser Subquery
subquerySelect = Subquery <$> selectStatement <*> pure []
-- | 聚合调用:COUNT(*) / COUNT(x) / SUM(x) / AVG(x) / MIN(x) / MAX(x)
aggregateCall :: Parser Expr
aggregateCall =
choice
[ CountAll <$ try (keyword "count" *> between (symbol "(") (symbol ")") (symbol "*"))
, CountOf <$> aggArg "count"
, SumOf <$> aggArg "sum"
, AvgOf <$> aggArg "avg"
, MinOf <$> aggArg "min"
, MaxOf <$> aggArg "max"
]
where
aggArg name = try (keyword name *> between (symbol "(") (symbol ")") expr)
-- | 只认字面量,给 DEFAULT 用
literalValue :: Parser Value
literalValue =
choice
[ VNull <$ keyword "null"
, VBool True <$ keyword "TRUE"
, VBool False <$ keyword "FALSE"
, try (negateLit <$> floatLit)
, VInt <$> integer
, VFloat <$> try floatLit
, VStr <$> stringLit
]
where
negateLit d = VFloat (negate d)
-- | 一个排序列
orderItem :: Parser (String, SortDir)
@@ -127,6 +215,13 @@ orderByClause = do
keyword "by"
sepBy1 orderItem (symbol ",")
-- | GROUP BY 的列表
groupByClause :: Parser [String]
groupByClause = do
keyword "group"
keyword "by"
sepBy1 qualifiedName (symbol ",")
-- | SET 里的一条赋值
assignment :: Parser (String, Expr)
assignment = do
@@ -135,16 +230,21 @@ assignment = do
e <- expr
return (col, e)
reservedWords :: [String]
reservedWords =
[ "select"
, "as"
, "from"
, "where"
, "order"
, "by"
, "limit"
, "join"
, "left"
, "outer"
, "inner"
, "exists"
, "in"
, "on"
, "and"
, "or"
@@ -161,7 +261,23 @@ reservedWords =
, "index"
, "drop"
, "alter"
, "add"
, "rename"
, "to"
, "column"
, "user"
, "identified"
, "null"
, "is"
, "not"
, "default"
, "check"
, "unique"
, "primary"
, "key"
, "auto_increment"
, "type"
, "group"
]
-- | 别名(不许用保留字)
@@ -173,41 +289,53 @@ aliasName = try $ do
-- | 表名 + 可选别名
tableRef :: Parser (Maybe String, String)
tableRef = do
tbl <- identifier
mAlias <- optional aliasName
tbl <- tableName
mAlias <- optional ((keyword "as" *> aliasName) <|> aliasName)
return (mAlias, tbl)
-- | 连接类型:内连接 / 左外连接
joinKind :: Parser JoinKind
joinKind =
try (keyword "left" *> optional (keyword "outer") *> keyword "join" *> return LeftJoin)
<|> try (keyword "inner" *> keyword "join" *> return InnerJoin)
<|> (keyword "join" *> return InnerJoin)
-- | 一个 JOIN ... ON
joinClause :: Parser (Maybe String, String, Expr)
joinClause :: Parser (JoinKind, Maybe String, String, Expr)
joinClause = do
keyword "join"
kind <- joinKind
(mAlias, tbl) <- tableRef
keyword "on"
cond <- expr
return (mAlias, tbl, cond)
return (kind, mAlias, tbl, cond)
-- | FROM 子句(可含多个 JOIN)
fromClause :: Parser FromClause
fromClause = do
(mAlias, tbl) <- tableRef
joins <- many joinClause
return (foldl (\acc (a, t, c) -> FromJoin acc a t c) (FromTable mAlias tbl) joins)
return (foldl (\acc (k, a, t, c) -> FromJoin k acc a t c) (FromTable mAlias tbl) joins)
-- | SELECT 的列清单
selectList :: Parser [String]
selectList :: Parser [(String, Expr)]
selectList =
choice
[ ["*"] <$ symbol "*"
, (:) <$> qualifiedName <*> many (symbol "," *> qualifiedName)
[ [("*", Col "*")] <$ symbol "*"
, sepBy1 selectItem (symbol ",")
]
-- | 投影标签保留表达式原文
selectItem :: Parser (String, Expr)
selectItem = do
(source, e) <- match expr
pure (case e of Col c -> (c, e); _ -> (reverse (dropWhile isSpace (reverse source)), e))
-- | 读 CREATE TABLE
createTableStatement :: Parser Statement
createTableStatement = do
keyword "create"
keyword "table"
name <- identifier
name <- tableName
cols <- between (symbol "(") (symbol ")") (sepBy1 columnDef (symbol ","))
return (CreateTable name cols)
@@ -216,7 +344,7 @@ dropTableStatement :: Parser Statement
dropTableStatement = do
keyword "drop"
keyword "table"
name <- identifier
name <- tableName
return (DropTable name)
-- | 读 CREATE INDEX(列名即索引名)
@@ -225,7 +353,7 @@ createIndexStatement = do
keyword "create"
keyword "index"
keyword "on"
tbl <- identifier
tbl <- tableName
col <- between (symbol "(") (symbol ")") identifier
return (CreateIndex tbl col)
@@ -235,64 +363,223 @@ dropIndexStatement = do
keyword "drop"
keyword "index"
keyword "on"
tbl <- identifier
tbl <- tableName
col <- between (symbol "(") (symbol ")") identifier
return (DropIndex tbl col)
-- | 读 ALTER TABLE ... DROP COLUMN
alterDropColumnStatement :: Parser Statement
alterDropColumnStatement = do
-- | 读 ALTER TABLE:加列、删列、改名、改类型、改默认值、改可空
alterTableStatement :: Parser Statement
alterTableStatement = do
keyword "alter"
keyword "table"
tbl <- identifier
keyword "drop"
keyword "column"
col <- identifier
return (DropColumn tbl col)
tbl <- tableName
choice
[ do
keyword "add"
void (optional (keyword "column"))
(name, col) <- columnDef
pure (AddColumn tbl (name, col))
, do
keyword "drop"
void (optional (keyword "column"))
DropColumn tbl <$> identifier
, do
keyword "rename"
void (optional (keyword "column"))
old <- identifier
keyword "to"
RenameColumn tbl old <$> identifier
, do
keyword "alter"
void (optional (keyword "column"))
col <- identifier
choice
[ AlterColumnType tbl col <$> (keyword "type" *> columnTypeP)
, try (AlterColumnNull tbl col False <$ (keyword "set" *> keyword "not" *> keyword "null"))
, try (AlterColumnNull tbl col True <$ (keyword "drop" *> keyword "not" *> keyword "null"))
, try (AlterColumnDefault tbl col . Just <$> (keyword "set" *> keyword "default" *> literalValue))
, AlterColumnDefault tbl col Nothing <$ (keyword "drop" *> keyword "default")
]
]
-- | 建表时的一列
-- | 读 CREATE USER(口令是明文,交给账号服务加盐派生)
createUserStatement :: Parser Statement
createUserStatement = do
keyword "create"
keyword "user"
name <- userName
pw <- identifiedBy
return (CreateUser name pw)
-- | 读 IDENTIFIED BY '口令'
identifiedBy :: Parser String
identifiedBy = do
keyword "identified"
keyword "by"
stringLit
-- | 读 ALTER USER ... IDENTIFIED BY(改一个普通账号的口令)
alterUserStatement :: Parser Statement
alterUserStatement = do
keyword "alter"
keyword "user"
name <- userName
pw <- identifiedBy
return (AlterUser name pw)
-- | 读 DROP USER
dropUserStatement :: Parser Statement
dropUserStatement = do
keyword "drop"
keyword "user"
name <- userName
return (DropUser name)
-- | 读 CREATE ROLE
createRoleStatement :: Parser Statement
createRoleStatement = do
keyword "create"
keyword "role"
CreateRole <$> userName
-- | 读 DROP ROLE
dropRoleStatement :: Parser Statement
dropRoleStatement = do
keyword "drop"
keyword "role"
DropRole <$> userName
-- | 权限名,统一收成大写
privilegeName :: Parser String
privilegeName =
choice
[ "SELECT" <$ keyword "select"
, "INSERT" <$ keyword "insert"
, "UPDATE" <$ keyword "update"
, "DELETE" <$ keyword "delete"
, "ALL" <$ keyword "all"
]
-- | ON 后面的授权对象:* 表示全部表,否则是一张表(可带库前缀)
grantObject :: Parser String
grantObject = ("*" <$ symbol "*") <|> tableName
-- | 读 GRANT:带 ON 的是给角色授权限,不带的是给用户加角色
grantStatement :: Parser Statement
grantStatement = do
keyword "grant"
choice
[ try $ do
privs <- sepBy1 privilegeName (symbol ",")
keyword "on"
obj <- grantObject
keyword "to"
GrantPrivileges privs obj <$> userName
, GrantRole <$> userName <*> (keyword "to" *> sepBy1 userName (symbol ","))
]
-- | 读 REVOKE:与 GRANT 对称
revokeStatement :: Parser Statement
revokeStatement = do
keyword "revoke"
choice
[ try $ do
privs <- sepBy1 privilegeName (symbol ",")
keyword "on"
obj <- grantObject
keyword "from"
RevokePrivileges privs obj <$> userName
, RevokeRole <$> userName <*> (keyword "from" *> sepBy1 userName (symbol ","))
]
-- | 建表时的一列:类型后面跟若干约束
columnDef :: Parser (String, Column)
columnDef = do
name <- identifier
ty <- columnType
return (name, ty)
ty <- columnTypeP
mods <- many columnConstraint
return (name, foldl (\c f -> f c) (plainColumn ty) mods)
-- | 一条列约束
columnConstraint :: Parser (Column -> Column)
columnConstraint =
choice
[ try (keyword "not" *> keyword "null" *> pure (\c -> c{columnNullable = False}))
, keyword "null" *> pure (\c -> c{columnNullable = True})
, keyword "auto_increment" *> pure (\c -> c{columnAutoIncrement = True, columnNullable = False})
, try (keyword "primary" *> keyword "key" *> pure (\c -> c{columnPrimaryKey = True, columnUnique = True, columnNullable = False}))
, keyword "unique" *> pure (\c -> c{columnUnique = True})
, keyword "default" *> ((\v c -> c{columnDefault = Just v}) <$> literalValue)
, keyword "check" *> ((\s c -> c{columnCheck = Just s}) <$> checkText)
]
-- | CHECK 后面括号里的表达式原文,交给执行期再解析
checkText :: Parser String
checkText = between (symbol "(") (symbol ")") (match expr) >>= \(source, _) -> pure source
-- | 括号里的长度参数
sizeParen :: Parser Int
sizeParen = try (between (symbol "(") (symbol ")") integer)
-- | 列类型关键字
columnType :: Parser Column
columnType =
(keyword "integer" >> pure TInt)
<|> (keyword "int" >> pure TInt)
<|> (keyword "varchar" >> pure TStr)
<|> (keyword "text" >> pure TStr)
<|> (keyword "str" >> pure TStr)
<|> (keyword "boolean" >> pure TBool)
<|> (keyword "bool" >> pure TBool)
columnTypeP :: Parser ColumnType
columnTypeP =
choice
[ keyword "integer" >> pure CInt
, keyword "int" >> pure CInt
, keyword "bigint" >> pure CBigInt
, keyword "smallint" >> pure CSmallInt
, (keyword "varchar" >>) (maybe CStr CVarchar <$> optional sizeParen)
, (keyword "char" >>) (maybe (CChar 1) CChar <$> optional sizeParen)
, keyword "text" >> pure CStr
, keyword "str" >> pure CStr
, keyword "boolean" >> pure CBool
, keyword "bool" >> pure CBool
, keyword "float" >> pure CFloat
, keyword "real" >> pure CFloat
, try (keyword "double" *> keyword "precision" *> pure CDouble)
, keyword "double" >> pure CDouble
, (keyword "decimal" >>) decimalType
, (keyword "numeric" >>) decimalType
, keyword "date" >> pure CDate
, keyword "timestamp" >> pure CTimestamp
, keyword "blob" >> pure CBlob
]
-- | DECIMAL 的精度与小数位
decimalType :: Parser ColumnType
decimalType = do
args <- optional (try (between (symbol "(") (symbol ")") decimalArgs))
pure $ case args of
Nothing -> CDecimal 10 0
Just (p, s) -> CDecimal p s
decimalArgs :: Parser (Int, Int)
decimalArgs = do
p <- integer
s <- fromMaybe 0 <$> optional (symbol "," *> integer)
pure (p, s)
-- | 读 SELECT
selectStatement :: Parser Statement
selectStatement = do
keyword "select"
cols <- selectList
keyword "from"
fromC <- fromClause
fromC <- fromMaybe FromUnit <$> optional (keyword "from" *> fromClause)
mWhere <- optional (keyword "where" *> expr)
groupBy <- fromMaybe [] <$> optional groupByClause
orderBy <- fromMaybe [] <$> optional orderByClause
mLimit <- optional limitClause
return
Select
{ selectCols = cols
, selectFrom = fromC
, selectWhere = mWhere
, selectOrderBy = orderBy
, selectLimit = mLimit
}
pure $ if all (\(name, e) -> e == Col name) cols
then Select (map fst cols) fromC mWhere groupBy orderBy mLimit
else SelectExpr cols fromC mWhere groupBy orderBy mLimit
-- | 读 INSERT,可一次插多行
insertStatement :: Parser Statement
insertStatement = do
keyword "INSERT"
keyword "INTO"
tbl <- identifier
tbl <- tableName
cols <-
between
(symbol "(")
@@ -304,14 +591,14 @@ insertStatement = do
-- | 一行值:一对括号里的若干字面量
valueRow :: Parser [Expr]
valueRow = between (symbol "(") (symbol ")") (sepBy1 atom (symbol ","))
valueRow = between (symbol "(") (symbol ")") (sepBy1 expr (symbol ","))
-- | 读 DELETE
deleteStatement :: Parser Statement
deleteStatement = do
keyword "DELETE"
keyword "FROM"
tbl <- identifier
tbl <- tableName
mWhere <- optional (keyword "WHERE" *> expr)
return (Delete tbl mWhere)
@@ -319,7 +606,7 @@ deleteStatement = do
updateStatement :: Parser Statement
updateStatement = do
keyword "update"
tbl <- identifier
tbl <- tableName
keyword "set"
assigns <- sepBy1 assignment (symbol ",")
mWhere <- optional (keyword "where" *> expr)
@@ -332,14 +619,38 @@ parseStatement input =
Left err -> Left (errorBundlePretty err)
Right q -> Right q
where
-- \| 依次尝试各种语句
statementP =
selectStatement
<|> try (CreateDatabase <$> (keyword "create" *> keyword "database" *> identifier))
<|> try (DropDatabase <$> (keyword "drop" *> keyword "database" *> identifier))
<|> (UseDatabase <$> (keyword "use" *> identifier))
<|> (ShowDatabases <$ (keyword "show" *> keyword "databases"))
<|> insertStatement
<|> deleteStatement
<|> updateStatement
<|> try createUserStatement
<|> try alterUserStatement
<|> try dropUserStatement
<|> try createRoleStatement
<|> try dropRoleStatement
<|> try grantStatement
<|> try revokeStatement
<|> try createIndexStatement
<|> try createTableStatement
<|> try dropIndexStatement
<|> try alterDropColumnStatement
<|> try alterTableStatement
<|> dropTableStatement
-- | 解析一个裸表达式(CHECK 约束原文用)
parseExpression :: String -> Either String Expr
parseExpression input =
case runParser (sc *> expr <* sc <* eof) "<expr>" input of
Left err -> Left (errorBundlePretty err)
Right e -> Right e
-- | 表名至多有一个库前缀。
tableName :: Parser String
tableName = do
first <- identifier
databaseTable <- optional (symbol "." *> identifier)
pure (maybe first (\t -> map toLower first ++ "." ++ t) databaseTable)
+861 -389
View File
File diff suppressed because it is too large. Load diff
+14 -12
View File
@@ -1,40 +1,42 @@
# ChuSQL 存储服务配置示例
#
# 用法:把本文件复制成 chusql-storage.toml(放在启动 server 时的当前目录下),
# 或者让环境变量 CHUSQL_CONFIG 指向任意路径的这个文件。
# 用法:安装时把本文件放到固定的用户配置目录(Windows 是 %APPDATA%\ChuSQL\chusql.toml,
# 其它平台是 ${XDG_CONFIG_HOME:-$HOME/.config}/ChuSQL/chusql.toml);
# 临时换一份就用命令行参数 --config <path>(没有环境变量通道了)。
#
# 每一项都可以删掉:没写的项回退到内置默认值(下面注释里的值就是默认值)。
# 优先级:环境变量 > 本文件 > 内置默认值。
# 优先级:本文件 > 内置默认值(没有配置键走环境变量了)。
# 启动时 server 会把"读了哪个文件、每个选项从哪来"打进日志([INFO] 开头那几行)。
[page]
# 页大小(字节):512 ~ 65536 之间的 2 的幂。
# 注意:改它会让已有数据文件(.db)读不出来(文件长度对不上页大小会直接报错)。
# 环境变量:CHUSQL_PAGE_SIZE
size = 4096
[btree]
# B+ 树一个节点最多几个孩子(>= 3,且必须能放进一页:12 + (order-1)*8 + order*8 <= size)。
# 注意:改它会让已有索引文件(.idx)对不上文件头里记的值,打开时报错。
# 环境变量:CHUSQL_BTREE_ORDER
order = 4
[buffer]
# 缓冲池里最多放几页(>= 1)。
pool_size = 1024
[storage]
# 数据目录:一张表一个 <表名>.db,一个索引一个 <表名>.idx。
# 环境变量:CHUSQL_DATA_DIR
data_dir = "../localdata"
# 不写就跟随平台惯例(Windows %LOCALAPPDATA%\ChuSQL\data,
# Unix ${XDG_DATA_HOME:-$HOME/.local/share}/chusql/data,跟安装脚本装的位置一致);
# 要固定就写绝对路径,例如:
# data_dir = "/srv/chusql/data"
[server]
# 命名管道名(Windows 上实际是 \\.\pipe\<这个名字>)。
# 环境变量:CHUSQL_PIPE
pipe_name = "chusql-storage"
pipe_name = "chusql-joint"
[log]
# 日志等级:off / error / warn / info / debug。debug 会多打每个请求和连接。
# 环境变量:CHUSQL_LOG
level = "info"
# 颜色不用写在文件里,用环境变量控制:CHUSQL_LOG_COLOR = auto(默认,stderr 是终端才着色)
# / always / never
# 颜色没有配置项也没有环境变量:要写的那条流是终端就着色,重定向到文件就不着色。
# 行格式:[等级 本地日期时间] [来源] 消息;来源 core=服务自身、pipe=由管道请求触发
# ERROR 加粗红、WARN 加粗橙、DEBUG 加粗不变色、INFO 不加样式
File diff suppressed because it is too large. Load diff
+314 -303
View File
@@ -3,84 +3,77 @@ use std::path::Path;
use crate::page::{Page, PageFile, PageId};
// 磁盘 B+ 树:节点占一页,文件头记页大小与 order。
// 磁盘 B+ 树:键是变长字节串,允许重复键,叶子链支持范围扫描。
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 META_MAGIC: &[u8; 4] = b"CBK2";
const OFF_PAGE_SIZE: usize = 4;
const OFF_MAX_KEY: usize = 8;
const OFF_ROOT: usize = 10;
const OFF_ORDER: usize = 18;
const NODE_LEAF: u8 = 0;
const NODE_INTERNAL: u8 = 1;
const OFF_TYPE: usize = 0;
const OFF_KIND: usize = 0;
const OFF_COUNT: usize = 1;
const OFF_NEXT: usize = 3;
const OFF_KEYS: usize = 12;
const HEADER: usize = 11;
const SLOT: usize = 2;
const CELL_FIXED: usize = 2 + 8;
/// 一个节点占多少字节
fn node_bytes(order: usize) -> usize {
OFF_KEYS + max_keys(order) * 8 + order * 8
}
/// 键长上限:节点里的格子必须放得进一页
pub const MAX_KEY_BYTES: usize = 64;
/// 最多几个键
/// 一个节点最多几个键
fn max_keys(order: usize) -> usize {
order - 1
}
/// 值区域的起点
fn off_payload(order: usize) -> usize {
OFF_KEYS + max_keys(order) * 8
/// 一个格子最多占多少字节
fn max_cell_bytes() -> usize {
CELL_FIXED + MAX_KEY_BYTES
}
/// 这个 order 放得进一页吗
pub fn fits_in_page(page_size: usize, order: usize) -> bool {
order >= 2 && node_bytes(order) <= page_size
order >= 2 && HEADER + max_keys(order) * (SLOT + max_cell_bytes()) <= page_size
}
/// 一页最多几个孩子
pub fn max_order(page_size: usize) -> usize {
page_size.saturating_sub(4) / 16
page_size.saturating_sub(HEADER) / (SLOT + max_cell_bytes()) + 1
}
/// 读一个 u8
fn read_u8(p: &Page, off: usize) -> u8 {
p.data[off]
}
/// 写一个 u8
fn write_u8(p: &mut Page, off: usize, v: u8) {
p.data[off] = v;
}
/// 读一个小端 u16
fn read_u16(p: &Page, off: usize) -> u16 {
u16::from_le_bytes([p.data[off], p.data[off + 1]])
}
/// 写一个小端 u16
fn write_u16(p: &mut Page, off: usize, v: u16) {
p.data[off..off + 2].copy_from_slice(&v.to_le_bytes());
}
/// 读一个小端 u32
fn read_u32(p: &Page, off: usize) -> u32 {
u32::from_le_bytes([p.data[off], p.data[off + 1], p.data[off + 2], p.data[off + 3]])
}
/// 写一个小端 u32
fn write_u32(p: &mut Page, off: usize, v: u32) {
p.data[off..off + 4].copy_from_slice(&v.to_le_bytes());
}
/// 读一个小端 u64
fn read_u64(p: &Page, off: usize) -> u64 {
let mut b = [0u8; 8];
b.copy_from_slice(&p.data[off..off + 8]);
u64::from_le_bytes(b)
}
/// 写一个小端 u64
fn write_u64(p: &mut Page, off: usize, v: u64) {
p.data[off..off + 8].copy_from_slice(&v.to_le_bytes());
}
@@ -89,9 +82,10 @@ fn write_u64(p: &mut Page, off: usize, v: u64) {
fn write_meta(page: &mut Page, page_size: usize, order: usize, root: PageId) {
page.zero();
page.data[0..4].copy_from_slice(META_MAGIC);
write_u32(page, OFF_META_PAGE_SIZE, page_size as u32);
write_u16(page, OFF_META_ORDER, order as u16);
write_u64(page, OFF_META_ROOT, root);
write_u32(page, OFF_PAGE_SIZE, page_size as u32);
write_u16(page, OFF_MAX_KEY, MAX_KEY_BYTES as u16);
write_u64(page, OFF_ROOT, root);
write_u16(page, OFF_ORDER, order as u16);
}
/// 校验文件头与配置
@@ -102,8 +96,8 @@ fn check_meta(page: &Page, page_size: usize, order: usize) -> io::Result<()> {
"not a chusql index file (bad magic)",
));
}
let stored_page = read_u32(page, OFF_META_PAGE_SIZE) as usize;
let stored_order = read_u16(page, OFF_META_ORDER) as usize;
let stored_page = read_u32(page, OFF_PAGE_SIZE) as usize;
let stored_order = read_u16(page, OFF_ORDER) as usize;
if stored_page != page_size || stored_order != order {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
@@ -116,89 +110,114 @@ fn check_meta(page: &Page, page_size: usize, order: usize) -> io::Result<()> {
Ok(())
}
enum NodeData {
Leaf {
keys: Vec<i64>,
values: Vec<u64>,
next: PageId,
},
Internal {
keys: Vec<i64>,
children: Vec<PageId>,
},
/// 一个节点:叶子存行位置,内部节点存孩子页号
struct Node {
kind: u8,
next: PageId,
keys: Vec<Vec<u8>>,
values: Vec<u64>,
}
/// 一个格子占多少字节
fn cell_bytes(key: &[u8]) -> usize {
CELL_FIXED + key.len()
}
/// 节点占多少字节
fn node_bytes(node: &Node) -> usize {
HEADER + node.keys.len() * SLOT + node.keys.iter().map(|k| cell_bytes(k)).sum::<usize>()
}
/// 把节点写回一页(格子从页尾往前摆,槽位从头部往后长)
fn put_node(p: &mut Page, node: &Node) -> io::Result<()> {
if node.keys.len() > u16::MAX as usize || node_bytes(node) > p.data.len() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"index node does not fit in a page",
));
}
p.zero();
write_u8(p, OFF_KIND, node.kind);
write_u16(p, OFF_COUNT, node.keys.len() as u16);
// 内部节点比键多一个孩子,最后一个放在 next 槽位里
let trailer = if node.kind == NODE_INTERNAL {
node.values.last().copied().unwrap_or(0)
} else {
node.next
};
write_u64(p, OFF_NEXT, trailer);
let mut cursor = p.data.len();
for (i, key) in node.keys.iter().enumerate() {
let size = cell_bytes(key);
cursor -= size;
write_u16(p, cursor, key.len() as u16);
p.data[cursor + 2..cursor + 2 + key.len()].copy_from_slice(key);
write_u64(p, cursor + 2 + key.len(), node.values[i]);
write_u16(p, HEADER + i * SLOT, cursor as u16);
}
Ok(())
}
/// 从一页读出节点
fn read_node(p: &Page, order: usize) -> NodeData {
let ty = read_u8(p, OFF_TYPE);
let n = read_u16(p, OFF_COUNT) as usize;
let payload = off_payload(order);
if ty == NODE_LEAF {
let next = read_u64(p, OFF_NEXT);
let mut keys = Vec::with_capacity(n);
let mut values = Vec::with_capacity(n);
for i in 0..n {
keys.push(read_u64(p, OFF_KEYS + i * 8) as i64);
values.push(read_u64(p, payload + i * 8));
}
NodeData::Leaf { keys, values, next }
fn get_node(p: &Page) -> Node {
let kind = read_u8(p, OFF_KIND);
let count = read_u16(p, OFF_COUNT) as usize;
let trailer = read_u64(p, OFF_NEXT);
let mut keys = Vec::with_capacity(count);
let mut values = Vec::with_capacity(count + 1);
for i in 0..count {
let off = read_u16(p, HEADER + i * SLOT) as usize;
let klen = read_u16(p, off) as usize;
keys.push(p.data[off + 2..off + 2 + klen].to_vec());
values.push(read_u64(p, off + 2 + klen));
}
if kind == NODE_INTERNAL {
values.push(trailer);
Node { kind, next: 0, keys, values }
} else {
let mut keys = Vec::with_capacity(n);
let mut children = Vec::with_capacity(n + 1);
for i in 0..n {
keys.push(read_u64(p, OFF_KEYS + i * 8) as i64);
}
for i in 0..(n + 1) {
children.push(read_u64(p, payload + i * 8));
}
NodeData::Internal { keys, children }
Node { kind, next: trailer, keys, values }
}
}
/// 把节点写回一页
fn write_node(p: &mut Page, node: &NodeData, order: usize) {
p.zero();
let payload = off_payload(order);
match node {
NodeData::Leaf { keys, values, next } => {
write_u8(p, OFF_TYPE, NODE_LEAF);
write_u16(p, OFF_COUNT, keys.len() as u16);
write_u64(p, OFF_NEXT, *next);
for (i, k) in keys.iter().enumerate() {
write_u64(p, OFF_KEYS + i * 8, *k as u64);
}
for (i, v) in values.iter().enumerate() {
write_u64(p, payload + i * 8, *v);
}
}
NodeData::Internal { keys, children } => {
write_u8(p, OFF_TYPE, NODE_INTERNAL);
write_u16(p, OFF_COUNT, keys.len() as u16);
for (i, k) in keys.iter().enumerate() {
write_u64(p, OFF_KEYS + i * 8, *k as u64);
}
for (i, c) in children.iter().enumerate() {
write_u64(p, payload + i * 8, *c);
}
}
/// 叶子里的插入位置:同键按 value 排,保证顺序稳定
fn leaf_slot(keys: &[Vec<u8>], values: &[u64], key: &[u8], value: u64) -> usize {
let mut i = keys.partition_point(|k| k.as_slice() < key);
while i < keys.len() && keys[i].as_slice() == key && values[i] < value {
i += 1;
}
i
}
/// 内部节点里选哪个孩子
fn child_slot(keys: &[Vec<u8>], key: &[u8]) -> usize {
keys.partition_point(|k| k.as_slice() <= key)
}
/// 切开一个节点:返回上提的键与右半节点
fn split_node(node: &mut Node) -> (Vec<u8>, Node) {
let mid = node.keys.len() / 2;
if node.kind == NODE_LEAF {
let sep = node.keys[mid].clone();
let right = Node {
kind: NODE_LEAF,
next: node.next,
keys: node.keys.split_off(mid),
values: node.values.split_off(mid),
};
(sep, right)
} else {
let sep = node.keys[mid].clone();
let right_keys = node.keys.split_off(mid + 1);
let right_values = node.values.split_off(mid + 1);
node.keys.pop();
(
sep,
Node { kind: NODE_INTERNAL, next: 0, keys: right_keys, values: right_values },
)
}
}
/// 二分选孩子
fn child_index(keys: &[i64], key: i64) -> usize {
let mut lo = 0;
let mut hi = keys.len();
while lo < hi {
let mid = (lo + hi) / 2;
if keys[mid] <= key {
lo = mid + 1;
} else {
hi = mid;
}
}
lo
}
/// 磁盘 B+ 树
pub struct DiskBTree {
file: PageFile,
order: usize,
@@ -207,11 +226,11 @@ pub struct DiskBTree {
impl DiskBTree {
/// 打开或新建索引文件
pub fn open<P: AsRef<Path>>(
path: P,
page_size: usize,
order: usize,
pool_size: usize,
) -> io::Result<Self> {
path: P,
page_size: usize,
order: usize,
pool_size: usize,
) -> io::Result<Self> {
if !fits_in_page(page_size, order) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
@@ -235,21 +254,44 @@ impl DiskBTree {
Ok(DiskBTree { file, order })
}
/// 读根页号
/// 键太长直接报错,避免写坏节点
fn check_key(&self, key: &[u8]) -> io::Result<()> {
if key.len() > MAX_KEY_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"index key is {} bytes, at most {} are supported",
key.len(),
MAX_KEY_BYTES
),
));
}
Ok(())
}
fn root(&mut self) -> io::Result<Option<PageId>> {
let r = self.file.with_page(META_PAGE, |p| read_u64(p, OFF_META_ROOT))?;
let r = self.file.with_page(META_PAGE, |p| read_u64(p, OFF_ROOT))?;
Ok(if r == 0 { None } else { Some(r) })
}
/// 写根页号
fn set_root(&mut self, r: Option<PageId>) -> io::Result<()> {
self.file
.update_page(META_PAGE, |p| write_u64(p, OFF_META_ROOT, r.unwrap_or(0)))
.update_page(META_PAGE, |p| write_u64(p, OFF_ROOT, r.unwrap_or(0)))
}
/// 分配新页
fn alloc(&mut self) -> io::Result<PageId> {
Ok(self.file.append_page()?.id)
fn alloc(&mut self, node: &Node) -> io::Result<PageId> {
let page = self.file.append_page()?;
let id = page.id;
self.file.update_page(id, |p| put_node(p, node))??;
Ok(id)
}
fn load(&mut self, id: PageId) -> io::Result<Node> {
self.file.with_page(id, get_node)
}
fn store(&mut self, id: PageId, node: &Node) -> io::Result<()> {
self.file.update_page(id, |p| put_node(p, node))?
}
/// 清空整棵树
@@ -262,221 +304,190 @@ impl DiskBTree {
Ok(())
}
/// 空页,用来拼节点
fn new_page(&self, id: PageId) -> Page {
Page::new(id, self.file.page_size())
}
/// 按 key 找 value
pub fn get(&mut self, key: i64) -> io::Result<Option<u64>> {
match self.root()? {
None => Ok(None),
Some(r) => self.get_rec(r, key),
/// 树是空的吗(只看最左叶子,不用整树遍历)
pub fn is_empty(&mut self) -> io::Result<bool> {
let Some(mut id) = self.root()? else {
return Ok(true);
};
loop {
let node = self.load(id)?;
if node.kind == NODE_LEAF {
return Ok(node.keys.is_empty());
}
id = node.values[0];
}
}
/// 递归查找
fn get_rec(&mut self, page_id: PageId, key: i64) -> io::Result<Option<u64>> {
let order = self.order;
match self.file.with_page(page_id, |p| read_node(p, order))? {
NodeData::Leaf { keys, values, .. } => {
Ok(keys.binary_search(&key).ok().map(|i| values[i]))
}
NodeData::Internal { keys, children } => {
let i = child_index(&keys, key);
self.get_rec(children[i], key)
/// 按 key 取第一个位置
pub fn get(&mut self, key: &[u8]) -> io::Result<Option<u64>> {
Ok(self.get_all(key)?.into_iter().next())
}
/// 按 key 取全部位置(重复键按值升序)
pub fn get_all(&mut self, key: &[u8]) -> io::Result<Vec<u64>> {
let Some(mut id) = self.root()? else {
return Ok(Vec::new());
};
loop {
let node = self.load(id)?;
if node.kind == NODE_LEAF {
let start = node.keys.partition_point(|k| k.as_slice() < key);
let mut out = Vec::new();
for i in start..node.keys.len() {
if node.keys[i].as_slice() != key {
break;
}
out.push(node.values[i]);
}
return Ok(out);
}
id = node.values[child_slot(&node.keys, key)];
}
}
/// 插入或覆盖
pub fn insert(&mut self, key: i64, value: u64) -> io::Result<()> {
/// 插入一个键值对(键可重复)
pub fn insert(&mut self, key: &[u8], value: u64) -> io::Result<()> {
self.check_key(key)?;
match self.root()? {
None => {
let id = self.alloc()?;
let node = NodeData::Leaf {
keys: vec![key],
values: vec![value],
let node = Node {
kind: NODE_LEAF,
next: 0,
keys: vec![key.to_vec()],
values: vec![value],
};
let mut p = self.new_page(id);
write_node(&mut p, &node, self.order);
self.file.write_page(&p)?;
let id = self.alloc(&node)?;
self.set_root(Some(id))
}
Some(r) => {
let (_, split) = self.insert_rec(r, key, value)?;
if let Some((sep, right_id)) = split {
let root_id = self.alloc()?;
let node = NodeData::Internal {
Some(root) => match self.insert_rec(root, key, value)? {
None => Ok(()),
Some((sep, right_id)) => {
let node = Node {
kind: NODE_INTERNAL,
next: 0,
keys: vec![sep],
children: vec![r, right_id],
values: vec![root, right_id],
};
let mut p = self.new_page(root_id);
write_node(&mut p, &node, self.order);
self.file.write_page(&p)?;
self.set_root(Some(root_id))
} else {
Ok(())
}
}
}
}
/// 递归插入,可能上提
fn insert_rec(
&mut self,
page_id: PageId,
key: i64,
value: u64,
) -> io::Result<(PageId, Option<(i64, PageId)>)> {
let order = self.order;
let node = self.file.with_page(page_id, |p| read_node(p, order))?;
let limit = max_keys(order);
match node {
NodeData::Leaf { mut keys, mut values, next } => {
match keys.binary_search(&key) {
Ok(i) => values[i] = value,
Err(i) => {
keys.insert(i, key);
values.insert(i, value);
}
}
if keys.len() <= limit {
self.file.update_page(page_id, |p| {
write_node(p, &NodeData::Leaf { keys, values, next }, order)
})?;
Ok((page_id, None))
} else {
let mid = keys.len() / 2;
let rkeys = keys.split_off(mid);
let rvalues = values.split_off(mid);
let sep = rkeys[0];
let right_id = self.alloc()?;
let mut rp = self.new_page(right_id);
write_node(
&mut rp,
&NodeData::Leaf { keys: rkeys, values: rvalues, next },
order,
);
self.file.write_page(&rp)?;
self.file.update_page(page_id, |p| {
write_node(p, &NodeData::Leaf { keys, values, next: right_id }, order)
})?;
Ok((page_id, Some((sep, right_id))))
}
}
NodeData::Internal { mut keys, mut children } => {
let i = child_index(&keys, key);
let (new_child, split) = self.insert_rec(children[i], key, value)?;
children[i] = new_child;
match split {
None => {
self.file.update_page(page_id, |p| {
write_node(p, &NodeData::Internal { keys, children }, order)
})?;
Ok((page_id, None))
}
Some((sep, right_id)) => {
keys.insert(i, sep);
children.insert(i + 1, right_id);
if keys.len() <= limit {
self.file.update_page(page_id, |p| {
write_node(p, &NodeData::Internal { keys, children }, order)
})?;
Ok((page_id, None))
} else {
let mid = keys.len() / 2;
let up = keys[mid];
let rkeys = keys.split_off(mid + 1);
keys.truncate(mid);
let rchildren = children.split_off(mid + 1);
let right_id = self.alloc()?;
let mut rp = self.new_page(right_id);
write_node(
&mut rp,
&NodeData::Internal { keys: rkeys, children: rchildren },
order,
);
self.file.write_page(&rp)?;
self.file.update_page(page_id, |p| {
write_node(p, &NodeData::Internal { keys, children }, order)
})?;
Ok((page_id, Some((up, right_id))))
}
}
}
}
}
}
/// 删掉一个键,键不存在返回 false
pub fn delete(&mut self, key: i64) -> io::Result<bool> {
match self.root()? {
None => Ok(false),
Some(r) => self.delete_rec(r, key),
}
}
/// 递归删除:一路走到叶子,删掉那一对
fn delete_rec(&mut self, page_id: PageId, key: i64) -> io::Result<bool> {
let order = self.order;
match self.file.with_page(page_id, |p| read_node(p, order))? {
NodeData::Leaf { mut keys, mut values, next } => match keys.binary_search(&key) {
Err(_) => Ok(false),
Ok(i) => {
keys.remove(i);
values.remove(i);
self.file.update_page(page_id, |p| {
write_node(p, &NodeData::Leaf { keys, values, next }, order)
})?;
Ok(true)
let id = self.alloc(&node)?;
self.set_root(Some(id))
}
},
NodeData::Internal { keys, children } => {
let i = child_index(&keys, key);
self.delete_rec(children[i], key)
}
}
}
/// 按键升序返回全部
pub fn iter_all(&mut self) -> io::Result<Vec<(i64, u64)>> {
/// 递归插入,溢出就把右半节点与上提键交给上层
fn insert_rec(
&mut self,
id: PageId,
key: &[u8],
value: u64,
) -> io::Result<Option<(Vec<u8>, PageId)>> {
let mut node = self.load(id)?;
if node.kind == NODE_LEAF {
let at = leaf_slot(&node.keys, &node.values, key, value);
node.keys.insert(at, key.to_vec());
node.values.insert(at, value);
} else {
let child = node.values[child_slot(&node.keys, key)];
if let Some((sep, right_id)) = self.insert_rec(child, key, value)? {
let at = node.keys.partition_point(|k| k.as_slice() <= sep.as_slice());
node.keys.insert(at, sep);
node.values.insert(at + 1, right_id);
}
}
if node.keys.len() <= max_keys(self.order) {
self.store(id, &node)?;
return Ok(None);
}
let (sep, mut right) = split_node(&mut node);
let right_id = self.alloc(&right)?;
if node.kind == NODE_LEAF {
right.next = node.next;
node.next = right_id;
}
self.store(id, &node)?;
Ok(Some((sep, right_id)))
}
/// 删掉一个 (key, value)
pub fn delete(&mut self, key: &[u8], value: u64) -> io::Result<bool> {
let Some(root) = self.root()? else {
return Ok(false);
};
self.delete_rec(root, key, value)
}
fn delete_rec(&mut self, id: PageId, key: &[u8], value: u64) -> io::Result<bool> {
let mut node = self.load(id)?;
if node.kind == NODE_LEAF {
let at = leaf_slot(&node.keys, &node.values, key, value);
if at < node.keys.len() && node.keys[at].as_slice() == key && node.values[at] == value {
node.keys.remove(at);
node.values.remove(at);
self.store(id, &node)?;
return Ok(true);
}
return Ok(false);
}
let child = node.values[child_slot(&node.keys, key)];
self.delete_rec(child, key, value)
}
/// 叶子链上按顺序取全部 (键, 值)
pub fn iter_all(&mut self) -> io::Result<Vec<(Vec<u8>, u64)>> {
self.scan(None, None)
}
/// 范围扫描:lo / hi 都是闭区间,None 表示这一侧不限
pub fn scan(
&mut self,
lo: Option<&[u8]>,
hi: Option<&[u8]>,
) -> io::Result<Vec<(Vec<u8>, u64)>> {
let Some(mut id) = self.root()? else {
return Ok(Vec::new());
};
loop {
let node = self.load(id)?;
if node.kind == NODE_LEAF {
id = match lo {
None => id,
Some(start) => {
let at = node.keys.partition_point(|k| k.as_slice() < start);
if at < node.keys.len() { id } else { node.next }
}
};
break;
}
id = match lo {
None => node.values[0],
Some(start) => node.values[child_slot(&node.keys, start)],
};
}
let mut out = Vec::new();
if let Some(r) = self.root()? {
self.walk(r, &mut out)?;
while id != 0 {
let node = self.load(id)?;
for i in 0..node.keys.len() {
if lo.is_some_and(|start| node.keys[i].as_slice() < start) {
continue;
}
if hi.is_some_and(|end| node.keys[i].as_slice() > end) {
return Ok(out);
}
out.push((node.keys[i].clone(), node.values[i]));
}
id = node.next;
}
Ok(out)
}
/// 中序遍历
fn walk(&mut self, page_id: PageId, out: &mut Vec<(i64, u64)>) -> io::Result<()> {
let order = self.order;
match self.file.with_page(page_id, |p| read_node(p, order))? {
NodeData::Leaf { keys, values, .. } => {
for i in 0..keys.len() {
out.push((keys[i], values[i]));
}
}
NodeData::Internal { children, .. } => {
for c in children {
self.walk(c, out)?;
}
}
}
Ok(())
}
/// 把脏页写回
/// 脏页写回
pub fn flush(&mut self) -> io::Result<()> {
self.file.flush()
}
/// 当前缓存页数
pub fn cached_pages(&self) -> usize {
self.file.cached_pages()
}
}
+121 -11
View File
@@ -1,12 +1,13 @@
use std::collections::hash_map::DefaultHasher;
use std::collections::{BTreeMap, HashSet};
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::hash::{Hash, Hasher};
use std::io;
use std::io::Write;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::protocol::{ColumnType, Row, SchemaColumn};
use crate::protocol::{Row, SchemaColumn};
// 数据字典:记每张表有哪些列、什么类型、多少行、有哪些索引,以及列级统计。
@@ -26,8 +27,14 @@ pub struct ColumnStat {
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TableSchema {
#[serde(default)]
pub system: bool,
pub columns: Vec<SchemaColumn>,
#[serde(default)]
pub dropped_columns: BTreeSet<String>,
#[serde(default)]
pub compacted: bool,
#[serde(default)]
pub row_count: u64,
#[serde(default)]
pub indexes: Vec<IndexSchema>,
@@ -63,7 +70,13 @@ impl Catalog {
/// 写回文件
pub fn save<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
let bytes = serde_json::to_vec_pretty(self).map_err(io::Error::other)?;
std::fs::write(path, bytes)
let pending = path.as_ref().with_extension("json.pending");
let mut file = std::fs::File::create(&pending)?;
file.write_all(&bytes)?;
file.sync_all()?;
drop(file);
std::fs::rename(pending, &path)?;
std::fs::OpenOptions::new().read(true).write(true).open(path)?.sync_all()
}
/// 一张表的 schema
@@ -93,7 +106,10 @@ impl Catalog {
self.tables.insert(
table.to_string(),
TableSchema {
system: false,
columns,
dropped_columns: BTreeSet::new(),
compacted: false,
row_count: 0,
indexes: Vec::new(),
stats: BTreeMap::new(),
@@ -102,6 +118,14 @@ impl Catalog {
Ok(())
}
/// 标记内部账号表
pub fn mark_system(&mut self, table: &str) -> io::Result<()> {
let entry = self.tables.get_mut(table)
.ok_or_else(|| io::Error::other("missing system table"))?;
entry.system = true;
Ok(())
}
/// 记一次插入,返回列有没有变化
pub fn record_insert(&mut self, table: &str, row: &Row) -> bool {
let changed = {
@@ -140,8 +164,11 @@ impl Catalog {
entry.stats.clear();
entry.row_count = 0;
}
rows.iter()
.fold(false, |changed, r| self.record_insert(table, r) || changed)
let mut changed = false;
for row in rows {
changed = self.record_insert(table, row) || changed;
}
changed
}
/// 这一列第一次见到这个值就加一
@@ -164,7 +191,8 @@ impl Catalog {
}
/// 删表
pub fn drop_table(&mut self, table: &str) -> io::Result<()> { if self.tables.remove(table).is_none() {
pub fn drop_table(&mut self, table: &str) -> io::Result<()> {
if self.tables.remove(table).is_none() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("unknown table: {}", table),
@@ -174,6 +202,35 @@ impl Catalog {
Ok(())
}
/// ALTER:整列定义换成新的,并清掉二级索引与统计
pub fn set_columns(&mut self, table: &str, columns: Vec<SchemaColumn>) -> io::Result<()> {
let entry = self
.tables
.get_mut(table)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("unknown table: {}", table)))?;
entry.columns = columns;
entry.dropped_columns.clear();
entry.indexes.clear();
entry.stats.clear();
self.seen.remove(table);
Ok(())
}
/// 老数据的列类型名规范化;返回有没有改动
pub fn normalize_types(&mut self) -> bool {
let mut changed = false;
for entry in self.tables.values_mut() {
for column in entry.columns.iter_mut() {
let fixed = normalize_type(&column.ty);
if fixed != column.ty {
column.ty = fixed;
changed = true;
}
}
}
changed
}
/// 删一列;之前没有也返回成功(重放幂等)
pub fn remove_column(&mut self, table: &str, column: &str) -> io::Result<bool> {
let entry = self
@@ -186,11 +243,33 @@ impl Catalog {
entry.columns.len() != before
};
entry.stats.remove(column);
entry.dropped_columns.insert(column.to_string());
entry.compacted = false;
entry.indexes.retain(|i| i.column != column);
self.seen.remove(table);
Ok(had_column)
}
/// 整理完成:记住这一版已经重写过(隐藏列名要留住,防止被重新写进来)
pub fn mark_compacted(&mut self, table: &str) -> io::Result<()> {
let entry = self
.tables
.get_mut(table)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("unknown table: {}", table)))?;
entry.compacted = true;
self.seen.remove(table);
Ok(())
}
/// 还有隐藏列字节待整理的表
pub fn dropped_tables(&self) -> Vec<String> {
self.tables
.iter()
.filter(|(_, e)| !e.dropped_columns.is_empty() && !e.compacted)
.map(|(t, _)| t.clone())
.collect()
}
/// 记下一条索引定义
pub fn add_index(&mut self, table: &str, column: &str) -> io::Result<()> {
let entry = self
@@ -232,13 +311,14 @@ impl Catalog {
fn add_missing_columns(entry: &mut TableSchema, row: &Row) -> bool {
let mut changed = false;
for (k, v) in row {
if entry.columns.iter().any(|c| &c.name == k) {
if entry.dropped_columns.contains(k) || entry.columns.iter().any(|c| &c.name == k) {
continue;
}
if let Some(ty) = infer_type(v) {
entry.columns.push(SchemaColumn {
name: k.clone(),
ty,
..Default::default()
});
changed = true;
}
@@ -247,15 +327,45 @@ fn add_missing_columns(entry: &mut TableSchema, row: &Row) -> bool {
}
/// 由 JSON 值猜列类型
fn infer_type(v: &serde_json::Value) -> Option<ColumnType> {
fn infer_type(v: &serde_json::Value) -> Option<String> {
match v {
serde_json::Value::Number(_) => Some(ColumnType::Int),
serde_json::Value::String(_) => Some(ColumnType::Str),
serde_json::Value::Bool(_) => Some(ColumnType::Bool),
serde_json::Value::Number(_) => Some("int".to_string()),
serde_json::Value::String(_) => Some("str".to_string()),
serde_json::Value::Bool(_) => Some("bool".to_string()),
_ => None,
}
}
/// 旧写法与新别名都并到规范名字,参数(varchar(20))原样保留;不认识的原样返回
pub fn normalize_type(ty: &str) -> String {
let trimmed = ty.trim();
let (base, args) = match trimmed.find('(') {
Some(i) => (&trimmed[..i], &trimmed[i..]),
None => (trimmed, ""),
};
match base.trim().to_ascii_lowercase().as_str() {
"integer" => format!("int{args}"),
"int" => format!("int{args}"),
"bigint" => format!("bigint{args}"),
"smallint" => format!("smallint{args}"),
"str" => format!("str{args}"),
"text" => format!("str{args}"),
"varchar" => format!("varchar{args}"),
"char" => format!("char{args}"),
"bool" => format!("bool{args}"),
"boolean" => format!("bool{args}"),
"float" => format!("float{args}"),
"real" => format!("float{args}"),
"double" => format!("double{args}"),
"decimal" => format!("decimal{args}"),
"numeric" => format!("decimal{args}"),
"date" => format!("date{args}"),
"timestamp" => format!("timestamp{args}"),
"blob" => format!("blob{args}"),
_ => trimmed.to_string(),
}
}
/// 一个值的哈希,统计不同值个数用
fn value_hash(v: &serde_json::Value) -> u64 {
let mut h = DefaultHasher::new();
+151 -75
View File
@@ -4,21 +4,103 @@ use serde::Deserialize;
use crate::log::Level;
// 配置:环境变量优先于 TOML,最后回退内置默认值。
// 全局配置:只从 TOML 文件读,没写的项回退内置默认值。
//
// 这个文件是整条链路的唯一配置文件,按分区各管各的:
// 存储只看 [page]/[btree]/[buffer]/[storage]/[server]/[log],
// 其他分区(例如 web 的 [web])由对应的层自己读,这里既不校验也不关心。
// 位置是安装时定下的固定路径,只有命令行 --config 能覆盖;没有任何环境变量通道。
// (唯一跟环境变量有关的是路径的默认值:配置文件和默认数据目录都按平台惯例算,
// 见 default_config_path / default_data_dir,它们只是算默认位置,不是配置通道。)
// 读一次就关,不长期占着文件句柄。
pub const DEFAULT_CONFIG_PATH: &str = "chusql-storage.toml";
/// 配置文件名
pub const CONFIG_FILE_NAME: &str = "chusql.toml";
/// 配置目录名(随系统惯例放在用户配置目录下)
pub const APP_DIR_NAME: &str = "ChuSQL";
/// Unix 下的数据目录名(XDG 惯例小写,跟安装脚本的默认安装目录一致)
pub const UNIX_APP_DIR_NAME: &str = "chusql";
/// 数据子目录名
pub const DATA_DIR_NAME: &str = "data";
pub const DEFAULT_POOL_SIZE: usize = 1024;
pub const DEFAULT_PAGE_SIZE: usize = 4096;
pub const DEFAULT_BTREE_ORDER: usize = 4;
pub const DEFAULT_DATA_DIR: &str = "../localdata";
pub const DEFAULT_PIPE_NAME: &str = "chusql-storage";
pub const DEFAULT_PIPE_NAME: &str = "chusql-joint";
pub const DEFAULT_LOG_LEVEL: &str = "info";
const MIN_PAGE_SIZE: usize = 512;
const MAX_PAGE_SIZE: usize = 65536;
const MIN_BTREE_ORDER: usize = 3;
/// 安装后的固定配置路径:Windows 用 %APPDATA%,其它平台用 XDG 惯例。
/// 环境变量缺失时退回相对路径 ChuSQL/chusql.toml。
pub fn default_config_path() -> PathBuf {
#[cfg(windows)]
{
let appdata = std::env::var_os("APPDATA").filter(|v| !v.is_empty());
if let Some(appdata) = appdata {
return PathBuf::from(appdata).join(APP_DIR_NAME).join(CONFIG_FILE_NAME);
}
}
#[cfg(not(windows))]
{
let xdg = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty());
if let Some(xdg) = xdg {
return PathBuf::from(xdg).join(APP_DIR_NAME).join(CONFIG_FILE_NAME);
}
let home = std::env::var_os("HOME").filter(|v| !v.is_empty());
if let Some(home) = home {
return PathBuf::from(home)
.join(".config")
.join(APP_DIR_NAME)
.join(CONFIG_FILE_NAME);
}
}
PathBuf::from(APP_DIR_NAME).join(CONFIG_FILE_NAME)
}
/// [storage] data_dir 没写时的默认数据目录,按各平台惯例自动切换:
///
/// Windows %LOCALAPPDATA%\ChuSQL\data(没设就退 %APPDATA%)
/// Unix $XDG_DATA_HOME/chusql/data(没设就是 ~/.local/share/chusql/data)
///
/// 跟安装脚本的默认数据目录是同一处:install.ps1 默认装到 %LOCALAPPDATA%\ChuSQL、
/// 数据放它下面的 data\;install.sh 默认装到 ~/.local/share/chusql、数据放它下面的 data/。
/// 环境变量都缺时才退回相对路径(跟 default_config_path 的兜底风格一致)。
#[cfg(windows)]
pub fn default_data_dir() -> PathBuf {
let local = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty());
if let Some(local) = local {
return PathBuf::from(local).join(APP_DIR_NAME).join(DATA_DIR_NAME);
}
let roaming = std::env::var_os("APPDATA").filter(|v| !v.is_empty());
if let Some(roaming) = roaming {
return PathBuf::from(roaming).join(APP_DIR_NAME).join(DATA_DIR_NAME);
}
PathBuf::from(APP_DIR_NAME).join(DATA_DIR_NAME)
}
/// 见 Windows 版本的注释:这里走 XDG 惯例。
#[cfg(not(windows))]
pub fn default_data_dir() -> PathBuf {
let xdg = std::env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty());
if let Some(xdg) = xdg {
return PathBuf::from(xdg)
.join(UNIX_APP_DIR_NAME)
.join(DATA_DIR_NAME);
}
let home = std::env::var_os("HOME").filter(|v| !v.is_empty());
if let Some(home) = home {
return PathBuf::from(home)
.join(".local")
.join("share")
.join(UNIX_APP_DIR_NAME)
.join(DATA_DIR_NAME);
}
PathBuf::from(UNIX_APP_DIR_NAME).join(DATA_DIR_NAME)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
pub page_size: usize,
@@ -35,7 +117,7 @@ impl Default for Config {
page_size: DEFAULT_PAGE_SIZE,
btree_order: DEFAULT_BTREE_ORDER,
pool_size: DEFAULT_POOL_SIZE,
data_dir: PathBuf::from(DEFAULT_DATA_DIR),
data_dir: default_data_dir(),
pipe_name: DEFAULT_PIPE_NAME.to_string(),
log_level: Level::Info,
}
@@ -46,7 +128,6 @@ impl Default for Config {
pub enum Origin {
Default,
File(PathBuf),
Env(&'static str),
}
impl Origin {
@@ -55,7 +136,6 @@ impl Origin {
match self {
Origin::Default => "default".to_string(),
Origin::File(p) => format!("file {}", p.display()),
Origin::Env(k) => format!("env {}", k),
}
}
}
@@ -68,7 +148,6 @@ pub struct Loaded {
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct FileConfig {
page: Option<FilePage>,
btree: Option<FileBtree>,
@@ -114,12 +193,19 @@ struct FileLog {
level: Option<String>,
}
/// 按真实环境加载
pub fn load() -> Result<Loaded, String> {
let path = match std::env::var("CHUSQL_CONFIG") {
Ok(p) if !p.trim().is_empty() => Some(PathBuf::from(p)),
_ => {
let p = PathBuf::from(DEFAULT_CONFIG_PATH);
/// 加载配置:explicit 有值就用它(读不到要报错),否则用固定安装路径(存在才读)
pub fn load(explicit: Option<&str>) -> Result<Loaded, String> {
let explicit = explicit.map(str::trim).filter(|p| !p.is_empty());
let path = match explicit {
Some(p) => {
let p = PathBuf::from(p);
if !p.is_file() {
return Err(format!("config file not found: {}", p.display()));
}
Some(p)
}
None => {
let p = default_config_path();
if p.is_file() { Some(p) } else { None }
}
};
@@ -130,15 +216,11 @@ pub fn load() -> Result<Loaded, String> {
),
None => None,
};
resolve(text.as_deref(), path, &|k| std::env::var(k).ok())
resolve(text.as_deref(), path)
}
/// 解析文本并套环境变量
pub fn resolve(
text: Option<&str>,
path: Option<PathBuf>,
env: &dyn Fn(&str) -> Option<String>,
) -> Result<Loaded, String> {
/// 解析文本:文件 -> 默认
pub fn resolve(text: Option<&str>, path: Option<PathBuf>) -> Result<Loaded, String> {
let file: FileConfig = match text {
None => FileConfig::default(),
Some(t) => toml::from_str(t).map_err(|e| match &path {
@@ -148,70 +230,44 @@ pub fn resolve(
};
let file_origin = Origin::File(
path.clone()
.unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_PATH)),
path.clone().unwrap_or_else(default_config_path),
);
let (page_size, page_origin) = pick(
"CHUSQL_PAGE_SIZE",
file.page.and_then(|p| p.size),
DEFAULT_PAGE_SIZE,
&file_origin,
env,
|s| {
s.trim()
.parse::<usize>()
.map_err(|_| format!("not a number: {}", s))
},
)?;
);
let (btree_order, order_origin) = pick(
"CHUSQL_BTREE_ORDER",
file.btree.and_then(|b| b.order),
DEFAULT_BTREE_ORDER,
&file_origin,
env,
|s| {
s.trim()
.parse::<usize>()
.map_err(|_| format!("not a number: {}", s))
},
)?;
);
let (pool_size, pool_origin) = pick(
"CHUSQL_POOL_SIZE",
file.buffer.and_then(|b| b.pool_size),
DEFAULT_POOL_SIZE,
&file_origin,
env,
|s| {
s.trim()
.parse::<usize>()
.map_err(|_| format!("not a number: {}", s))
},
)?;
);
let (data_dir, dir_origin) = pick(
"CHUSQL_DATA_DIR",
file.storage.and_then(|s| s.data_dir),
DEFAULT_DATA_DIR.to_string(),
default_data_dir().to_string_lossy().into_owned(),
&file_origin,
env,
|s| Ok(s.trim().to_string()),
)?;
);
let (pipe_name, pipe_origin) = pick(
"CHUSQL_PIPE",
file.server.and_then(|s| s.pipe_name),
DEFAULT_PIPE_NAME.to_string(),
&file_origin,
env,
|s| Ok(s.trim().to_string()),
)?;
);
let (log_level, log_origin) = pick(
"CHUSQL_LOG",
file.log.and_then(|l| l.level),
DEFAULT_LOG_LEVEL.to_string(),
&file_origin,
env,
|s| Ok(s.trim().to_string()),
)?;
);
let data_dir = data_dir.trim().to_string();
let pipe_name = pipe_name.trim().to_string();
let log_level = log_level.trim().to_string();
let log_level = Level::parse(&log_level)
.ok_or_else(|| format!("log.level is not one of off/error/warn/info/debug: {}", log_level))?;
@@ -248,23 +304,12 @@ pub fn resolve(
})
}
/// 取一项:env -> 文件 -> 默认
fn pick<T: Clone>(
env_key: &'static str,
file_value: Option<T>,
default: T,
file_origin: &Origin,
env: &dyn Fn(&str) -> Option<String>,
parse: impl Fn(&str) -> Result<T, String>,
) -> Result<(T, Origin), String> {
if let Some(raw) = env(env_key) {
let value = parse(&raw).map_err(|e| format!("{}: {}", env_key, e))?;
return Ok((value, Origin::Env(env_key)));
}
/// 取一项:文件 -> 默认
fn pick<T>(file_value: Option<T>, default: T, file_origin: &Origin) -> (T, Origin) {
if let Some(value) = file_value {
return Ok((value, file_origin.clone()));
return (value, file_origin.clone());
}
Ok((default, Origin::Default))
(default, Origin::Default)
}
/// 检查选项是否自洽
@@ -291,3 +336,34 @@ fn validate_layout(page_size: usize, btree_order: usize) -> Result<(), String> {
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// 全局配置里别的层(例如 web)的分区不能被存储层当成错误
#[test]
fn ignores_sections_owned_by_other_layers() {
let text = r#"
[page]
size = 8192
[storage]
data_dir = "../data"
[web]
port = 7777
user = "root"
"#;
let loaded = resolve(Some(text), Some(PathBuf::from("chusql.toml"))).unwrap();
assert_eq!(loaded.config.page_size, 8192);
assert_eq!(loaded.config.data_dir, PathBuf::from("../data"));
}
/// 自己分区里的拼写错误仍然要报错
#[test]
fn still_rejects_typos_inside_its_own_sections() {
let text = "[page]\npage_size = 8192\n";
assert!(resolve(Some(text), None).is_err());
}
}
+99
View File
@@ -0,0 +1,99 @@
// 本地端点:Windows 上是具名管道 `\\.\pipe\<name>`,Unix/macOS 上是文件系统套接字
// `<socket_dir>/<name>.sock`。存储进程、Haskell 前端(见 chusql-engine 的 Storage.IPC)
// 和集成测试都用同一条规则,保证对同一个 `[server] pipe_name` 算出同一个端点。
//
// 用文件系统套接字而不是 Linux 抽象命名空间:抽象命名空间没有文件、跨平台不存在,
// 而带路径的套接字两个方向都能连,Haskell 那边也能用常规 socket 连上。
use interprocess::local_socket::{prelude::*, ListenerOptions};
#[cfg(unix)]
use interprocess::local_socket::GenericFilePath;
#[cfg(windows)]
use interprocess::local_socket::GenericNamespaced;
#[cfg(unix)]
use std::path::{Path, PathBuf};
/// 端点的可读形式:Windows 是 `\\.\pipe\<name>`,Unix 是套接字文件路径。
pub fn display(pipe_name: &str) -> String {
#[cfg(windows)]
{
format!("\\\\.\\pipe\\{}", pipe_name)
}
#[cfg(unix)]
{
socket_path(pipe_name).display().to_string()
}
}
/// Unix 端点路径:`$XDG_RUNTIME_DIR` → `$TMPDIR` → `/tmp`,文件名固定 `<pipe_name>.sock`。
#[cfg(unix)]
pub fn socket_path(pipe_name: &str) -> PathBuf {
let dir = std::env::var_os("XDG_RUNTIME_DIR")
.filter(|v| !v.is_empty())
.or_else(|| std::env::var_os("TMPDIR").filter(|v| !v.is_empty()))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/tmp"));
dir.join(format!("{}.sock", pipe_name))
}
/// 连到正在监听的存储进程。
pub fn connect(pipe_name: &str) -> std::io::Result<LocalSocketStream> {
#[cfg(windows)]
{
let name = pipe_name
.to_ns_name::<GenericNamespaced>()
.map_err(|e| std::io::Error::other(e.to_string()))?;
LocalSocketStream::connect(name)
}
#[cfg(unix)]
{
let path = socket_path(pipe_name);
let name = path
.to_fs_name::<GenericFilePath>()
.map_err(|e| std::io::Error::other(e.to_string()))?;
LocalSocketStream::connect(name)
}
}
/// 监听端点:Windows 直接建具名管道;Unix 先探活,再清掉上次崩溃留下的残留套接字文件。
pub fn listen(pipe_name: &str) -> std::io::Result<LocalSocketListener> {
#[cfg(windows)]
{
let name = pipe_name
.to_ns_name::<GenericNamespaced>()
.map_err(|e| std::io::Error::other(e.to_string()))?;
ListenerOptions::new().name(name).create_sync()
}
#[cfg(unix)]
{
let path = socket_path(pipe_name);
prepare_socket_path(&path)?;
let name = path
.to_fs_name::<GenericFilePath>()
.map_err(|e| std::io::Error::other(e.to_string()))?;
ListenerOptions::new().name(name).create_sync()
}
}
/// Unix:先探活的监听者,再清掉残留的套接字文件。
#[cfg(unix)]
fn prepare_socket_path(path: &Path) -> std::io::Result<()> {
match std::os::unix::net::UnixStream::connect(path) {
Ok(_) => Err(std::io::Error::new(
std::io::ErrorKind::AddrInUse,
format!(
"another storage server already listens on {}",
path.display()
),
)),
Err(_) => {
if path.exists() {
std::fs::remove_file(path)?;
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
Ok(())
}
}
}
+222 -42
View File
@@ -1,6 +1,8 @@
use std::collections::HashSet;
use std::io;
use std::path::Path;
use serde::de::{IgnoredAny, MapAccess, Visitor};
use serde::Deserializer;
use crate::btree::DiskBTree;
use crate::page::PageId;
@@ -13,6 +15,32 @@ const HEADER_SIZE: usize = 2;
const SLOT_SIZE: usize = 8;
const SLOT_DIR_START: usize = HEADER_SIZE;
/// 未选字段只校验并跳过,不分配值。
fn decode_row(bytes: &[u8], columns: Option<&[String]>, hidden: &HashSet<String>) -> io::Result<Row> {
struct RowVisitor<'a>(Option<&'a [String]>, &'a HashSet<String>);
impl<'de> Visitor<'de> for RowVisitor<'_> {
type Value = Row;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a row object")
}
fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<Row, M::Error> {
let mut row = Row::new();
while let Some(key) = map.next_key::<String>()? {
if !self.1.contains(&key) && self.0.is_none_or(|cols| cols.contains(&key)) {
row.insert(key, map.next_value()?);
} else {
map.next_value::<IgnoredAny>()?;
}
}
Ok(row)
}
}
let mut decoder = serde_json::Deserializer::from_slice(bytes);
let row = decoder.deserialize_map(RowVisitor(columns, hidden)).map_err(io::Error::other)?;
decoder.end().map_err(io::Error::other)?;
Ok(row)
}
/// 读一个小端 u16
fn read_u16(buf: &[u8], off: usize) -> u16 {
u16::from_le_bytes([buf[off], buf[off + 1]])
@@ -51,6 +79,26 @@ fn row_int(row: &Row, column: &str) -> Option<i64> {
}
}
/// i64 键 → 8 字节大端(保序,负数也在前)
fn key_of(value: i64) -> [u8; 8] {
(value as u64 ^ (1u64 << 63)).to_be_bytes()
}
/// JSON 值 → 索引键字节(与写索引时同一套编码)
fn value_key(v: &serde_json::Value) -> Option<Vec<u8>> {
match v {
serde_json::Value::Number(n) => n.as_i64().map(|k| key_of(k).to_vec()),
serde_json::Value::String(s) => Some(s.as_bytes().to_vec()),
serde_json::Value::Bool(b) => Some(vec![u8::from(*b)]),
_ => None,
}
}
/// 取行里某一列的索引键;NULL 与缺列不入索引
fn row_key(row: &Row, column: &str) -> Option<Vec<u8>> {
row.get(column).and_then(value_key)
}
pub struct HeapPage;
impl HeapPage {
@@ -146,6 +194,7 @@ struct NamedIndex {
}
pub struct HeapTable {
hidden_columns: HashSet<String>,
file: PageFile,
indexes: Vec<NamedIndex>,
}
@@ -154,6 +203,7 @@ impl HeapTable {
/// 打开表(不带索引)
pub fn open<P: AsRef<Path>>(path: P, page_size: usize, pool_size: usize) -> io::Result<Self> {
Ok(HeapTable {
hidden_columns: HashSet::new(),
file: PageFile::with_options(path, page_size, pool_size)?,
indexes: Vec::new(),
})
@@ -167,12 +217,13 @@ impl HeapTable {
btree_order: usize,
pool_size: usize,
) -> io::Result<Self> {
let mut t = HeapTable {
file: PageFile::with_options(path, page_size, pool_size)?,
indexes: Vec::new(),
};
t.attach_index("id", index_path, page_size, btree_order, pool_size)?;
Ok(t)
Self::open_with_indexes(
path,
&[("id".to_string(), index_path.as_ref().to_path_buf())],
page_size,
btree_order,
pool_size,
)
}
/// 打开表并挂上多组索引
@@ -184,11 +235,29 @@ impl HeapTable {
pool_size: usize,
) -> io::Result<Self> {
let mut t = HeapTable {
hidden_columns: HashSet::new(),
file: PageFile::with_options(path, page_size, pool_size)?,
indexes: Vec::new(),
};
let mut filled = false;
for (col, p) in indexes {
t.attach_index(col, p, page_size, btree_order, pool_size)?;
match t.attach_index(col, p, page_size, btree_order, pool_size) {
Ok(()) => {}
Err(err) if err.kind() == io::ErrorKind::InvalidData => {
// 格式不兼容(例如上一版落的索引文件):删掉重开
let _ = std::fs::remove_file(p);
t.attach_index(col, p, page_size, btree_order, pool_size)?;
}
Err(err) => return Err(err),
}
// 空索引(新文件、被截断、强杀留下)都按堆数据补齐
if t.index_empty(col)? {
t.fill_index(col)?;
filled = true;
}
}
if filled {
t.flush()?;
}
Ok(t)
}
@@ -231,19 +300,17 @@ impl HeapTable {
));
}
let mut seen: HashSet<i64> = HashSet::new();
let mut entries: Vec<(i64, u64)> = Vec::new();
let mut seen: HashSet<Vec<u8>> = HashSet::new();
let mut entries: Vec<(Vec<u8>, u64)> = Vec::new();
for (page_id, slot, r) in self.scan_with_positions()? {
let Some(k) = row_int(&r, column) else {
let Some(k) = row_key(&r, column) else {
continue;
};
if !seen.insert(k) {
// 行身份索引仍然唯一;其余列允许重复值
if column == "id" && !seen.insert(k.clone()) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"column \"{}\" has duplicate value {} -- non-unique indexes are not supported yet",
column, k
),
format!("column \"{}\" has duplicate values", column),
));
}
entries.push((k, pack_position(page_id, slot)));
@@ -253,7 +320,31 @@ impl HeapTable {
let i = self.indexes.len() - 1;
self.indexes[i].tree.clear()?;
for (k, pos) in entries {
self.indexes[i].tree.insert(k, pos)?;
self.indexes[i].tree.insert(&k, pos)?;
}
Ok(())
}
/// 这一列的索引是空的吗
fn index_empty(&mut self, column: &str) -> io::Result<bool> {
match self.indexes.iter_mut().find(|i| i.column == column) {
Some(idx) => idx.tree.is_empty(),
None => Ok(false),
}
}
/// 按堆里的行把某一列索引补齐
fn fill_index(&mut self, column: &str) -> io::Result<()> {
let rows = self.scan_with_positions()?;
let Some(i) = self.indexes.iter().position(|idx| idx.column == column) else {
return Ok(());
};
for (page_id, slot, row) in rows {
if let Some(k) = row_key(&row, column) {
self.indexes[i]
.tree
.insert(&k, pack_position(page_id, slot))?;
}
}
Ok(())
}
@@ -269,6 +360,15 @@ impl HeapTable {
}
}
pub fn set_hidden_columns(&mut self, columns: HashSet<String>) {
self.hidden_columns = columns;
}
pub fn hide_column(&mut self, column: &str) {
self.detach_index(column);
self.hidden_columns.insert(column.to_string());
}
/// 插入一行,并顺手写各索引
pub fn insert_row(&mut self, row: &Row) -> io::Result<()> {
self.insert_row_returning_position(row).map(|_| ())
@@ -276,15 +376,15 @@ impl HeapTable {
/// 插入一行并返回它的位置
pub fn insert_row_returning_position(&mut self, row: &Row) -> io::Result<(PageId, u16)> {
let mut pending: Vec<(usize, i64)> = Vec::new();
let mut pending: Vec<(usize, Vec<u8>)> = Vec::new();
for (i, idx) in self.indexes.iter_mut().enumerate() {
let Some(k) = row_int(row, &idx.column) else {
let Some(k) = row_key(row, &idx.column) else {
continue;
};
if idx.tree.get(k)?.is_some() {
if idx.column == "id" && idx.tree.get(&k)?.is_some() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("duplicate key {} on indexed column \"{}\"", k, idx.column),
"duplicate value on the id index".to_string(),
));
}
pending.push((i, k));
@@ -293,7 +393,7 @@ impl HeapTable {
let (page_id, slot) = self.insert_returning_position(row)?;
let pos = pack_position(page_id, slot);
for (i, k) in pending {
self.indexes[i].tree.insert(k, pos)?;
self.indexes[i].tree.insert(&k, pos)?;
}
Ok((page_id, slot))
}
@@ -306,17 +406,22 @@ impl HeapTable {
Ok(())
}
/// 插入一行,只进堆表、不碰索引
fn insert_returning_position(&mut self, row: &Row) -> io::Result<(PageId, u16)> {
/// 编码并验证一行能放进页
pub fn encode_row(row: &Row, page_size: usize) -> io::Result<Vec<u8>> {
let tuple = serde_json::to_vec(row).map_err(io::Error::other)?;
if HEADER_SIZE + SLOT_SIZE + tuple.len() > self.file.page_size() {
if HEADER_SIZE + SLOT_SIZE + tuple.len() > page_size {
return Err(io::Error::other(format!(
"tuple too large: {} bytes",
tuple.len()
)));
}
Ok(tuple)
}
/// 插入一行,只进堆表、不碰索引
fn insert_returning_position(&mut self, row: &Row) -> io::Result<(PageId, u16)> {
let tuple = Self::encode_row(row, self.file.page_size())?;
let n = self.file.num_pages()?;
if n > 0 {
let last_id = n - 1;
@@ -341,16 +446,84 @@ impl HeapTable {
self.get_by_column_key("id", key)
}
/// 按某一列的索引取一行
/// 按某一列的整数索引取一行
pub fn get_by_column_key(&mut self, column: &str, key: i64) -> io::Result<Option<Row>> {
self.get_by_bytes(column, &key_of(key))
}
/// 按某一列的字符串索引取一行
pub fn get_by_string_key(&mut self, column: &str, key: &str) -> io::Result<Option<Row>> {
self.get_by_bytes(column, key.as_bytes())
}
/// 按某一列的整数索引取全部行
pub fn get_all_by_column_key(&mut self, column: &str, key: i64) -> io::Result<Vec<Row>> {
self.get_all_by_bytes(column, &key_of(key))
}
/// 按某一列的字符串索引取全部行
pub fn get_all_by_string_key(&mut self, column: &str, key: &str) -> io::Result<Vec<Row>> {
self.get_all_by_bytes(column, key.as_bytes())
}
/// 按索引键的字节取第一行
fn get_by_bytes(&mut self, column: &str, key: &[u8]) -> io::Result<Option<Row>> {
Ok(self.get_all_by_bytes(column, key)?.into_iter().next())
}
/// 按索引键的字节取全部行(重复值都给出来)
fn get_all_by_bytes(&mut self, column: &str, key: &[u8]) -> io::Result<Vec<Row>> {
let Some(idx) = self.indexes.iter_mut().find(|i| i.column == column) else {
return Ok(Vec::new());
};
let positions = idx.tree.get_all(key)?;
let mut rows = Vec::new();
for pos in positions {
let (page_id, slot) = unpack_position(pos);
if let Some(row) = self.read_at(page_id, slot)? {
rows.push(row);
}
}
Ok(rows)
}
/// 范围扫描:某一列有索引就走叶子链,没有索引返回 None(上层退回全表扫描)
pub fn scan_range(
&mut self,
column: &str,
lo: Option<(&serde_json::Value, bool)>,
hi: Option<(&serde_json::Value, bool)>,
) -> io::Result<Option<Vec<Row>>> {
let Some(i) = self.indexes.iter().position(|idx| idx.column == column) else {
return Ok(None);
};
let Some(pos) = idx.tree.get(key)? else {
return Ok(None);
};
let (page_id, slot) = unpack_position(pos);
self.read_at(page_id, slot)
let lo_bound = lo.and_then(|(v, inclusive)| value_key(v).map(|k| (k, inclusive)));
let hi_bound = hi.and_then(|(v, inclusive)| value_key(v).map(|k| (k, inclusive)));
// 树按闭区间取,排他端在取回来之后再筛
let pairs = self.indexes[i].tree.scan(
lo_bound.as_ref().map(|(k, _)| k.as_slice()),
hi_bound.as_ref().map(|(k, _)| k.as_slice()),
)?;
let mut rows = Vec::new();
for (key, pos) in pairs {
if let Some((bound, inclusive)) = &lo_bound {
let outside = if *inclusive { &key < bound } else { &key <= bound };
if outside {
continue;
}
}
if let Some((bound, inclusive)) = &hi_bound {
let outside = if *inclusive { &key > bound } else { &key >= bound };
if outside {
continue;
}
}
let (page_id, slot) = unpack_position(pos);
if let Some(row) = self.read_at(page_id, slot)? {
rows.push(row);
}
}
Ok(Some(rows))
}
/// 按位置读一行
@@ -359,7 +532,7 @@ impl HeapTable {
match bytes {
None => Ok(None),
Some(bytes) => {
let row: Row = serde_json::from_slice(&bytes).map_err(io::Error::other)?;
let row = decode_row(&bytes, None, &self.hidden_columns)?;
Ok(Some(row))
}
}
@@ -373,9 +546,10 @@ impl HeapTable {
return Ok(None);
}
if let Some(r) = &row {
let pos = pack_position(page_id, slot);
for idx in &mut self.indexes {
if let Some(k) = row_int(r, &idx.column) {
idx.tree.delete(k)?;
if let Some(k) = row_key(r, &idx.column) {
idx.tree.delete(&k, pos)?;
}
}
}
@@ -392,7 +566,7 @@ impl HeapTable {
.indexes
.iter_mut()
.find(|i| i.column == "id")
.and_then(|i| i.tree.get(k).ok().flatten());
.and_then(|i| i.tree.get(&key_of(k)).ok().flatten());
if let Some(p) = pos {
positions.push(unpack_position(p));
}
@@ -401,10 +575,8 @@ impl HeapTable {
let mut wanted: Vec<i64> = keys.to_vec();
wanted.sort_unstable();
for (page_id, slot, r) in self.scan_with_positions()? {
if let Some(k) = row_int(&r, "id") {
if wanted.binary_search(&k).is_ok() {
positions.push((page_id, slot));
}
if row_int(&r, "id").is_some_and(|k| wanted.binary_search(&k).is_ok()) {
positions.push((page_id, slot));
}
}
}
@@ -420,8 +592,12 @@ impl HeapTable {
/// 全表扫描
pub fn scan(&mut self) -> io::Result<Vec<Row>> {
self.scan_columns(None)
}
pub fn scan_columns(&mut self, columns: Option<&[String]>) -> io::Result<Vec<Row>> {
Ok(self
.scan_with_positions()?
.scan_projected_with_positions(columns)?
.into_iter()
.map(|(_, _, r)| r)
.collect())
@@ -429,6 +605,10 @@ impl HeapTable {
/// 全表扫描,同时给出每行的位置(建索引用)
pub fn scan_with_positions(&mut self) -> io::Result<Vec<(PageId, u16, Row)>> {
self.scan_projected_with_positions(None)
}
fn scan_projected_with_positions(&mut self, columns: Option<&[String]>) -> io::Result<Vec<(PageId, u16, Row)>> {
let n = self.file.num_pages()?;
let mut out = Vec::new();
for i in 0..n {
@@ -442,7 +622,7 @@ impl HeapTable {
v
})?;
for (slot, bytes) in tuples {
let row: Row = serde_json::from_slice(&bytes).map_err(io::Error::other)?;
let row = decode_row(&bytes, columns, &self.hidden_columns)?;
out.push((i, slot, row));
}
}
@@ -459,8 +639,8 @@ impl HeapTable {
let (page_id, slot) = self.insert_returning_position(r)?;
let pos = pack_position(page_id, slot);
for idx in &mut self.indexes {
if let Some(k) = row_int(r, &idx.column) {
idx.tree.insert(k, pos)?;
if let Some(k) = row_key(r, &idx.column) {
idx.tree.insert(&k, pos)?;
}
}
}
+203
View File
@@ -0,0 +1,203 @@
//! `scripts/init.sql` 的最小读取:只认 CREATE TABLE 与 CREATE INDEX。
//! 系统库的 schema 由这份脚本声明,存储进程按它建系统表。
use crate::protocol::SchemaColumn;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InitColumn {
pub name: String,
pub ty: String,
pub nullable: bool,
pub auto_increment: bool,
pub primary_key: bool,
pub unique: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InitTable {
pub name: String,
pub columns: Vec<InitColumn>,
pub indexes: Vec<String>,
}
impl InitColumn {
/// 转成数据字典里的列
pub fn to_schema(&self) -> SchemaColumn {
SchemaColumn {
name: self.name.clone(),
ty: self.ty.clone(),
nullable: self.nullable,
default: None,
auto_increment: self.auto_increment,
primary_key: self.primary_key,
unique: self.unique,
check: None,
}
}
}
/// 解析整份脚本;不认识的语句直接报错,避免悄悄漏建表
pub fn parse(text: &str) -> Result<Vec<InitTable>, String> {
let mut tables: Vec<InitTable> = Vec::new();
for statement in statements(text) {
let words = statement.split_whitespace().collect::<Vec<_>>();
let head = |index: usize| words.get(index).map(|word| word.to_ascii_uppercase());
match (head(0).as_deref(), head(1).as_deref()) {
(Some("CREATE"), Some("TABLE")) => {
let name = clean_name(words.get(2).ok_or("CREATE TABLE needs a name")?);
let body = statement.split_once('(').ok_or("CREATE TABLE needs a column list")?.1;
let inner = body.rsplit_once(')').map(|(inner, _)| inner).unwrap_or(body);
tables.push(InitTable { name, columns: columns(inner)?, indexes: Vec::new() });
}
(Some("CREATE"), Some("INDEX")) => {
let table = clean_name(words.get(2).ok_or("CREATE INDEX needs a table")?);
let column = statement
.split_once('(')
.and_then(|(_, rest)| rest.split_once(')'))
.map(|(column, _)| clean_name(column))
.ok_or("CREATE INDEX needs a column")?;
let entry = tables
.iter_mut()
.find(|entry| entry.name.eq_ignore_ascii_case(&table))
.ok_or_else(|| format!("CREATE INDEX on unknown table: {table}"))?;
entry.indexes.push(column);
}
_ => return Err(format!("unsupported statement: {}", statement.trim())),
}
}
Ok(tables)
}
/// 一行一列:`name type [NOT NULL] [AUTO_INCREMENT] [PRIMARY KEY] [UNIQUE]`
fn columns(body: &str) -> Result<Vec<InitColumn>, String> {
let mut out = Vec::new();
for part in split_top_level(body) {
let words = part.split_whitespace().collect::<Vec<_>>();
if words.is_empty() {
continue;
}
let keyword = words[0].to_ascii_uppercase();
if matches!(keyword.as_str(), "PRIMARY" | "UNIQUE" | "KEY" | "CONSTRAINT" | "INDEX" | "CHECK" | "FOREIGN") {
return Err(format!("table-level constraint is not supported: {}", part.trim()));
}
let name = clean_name(words[0]);
let ty = words
.get(1)
.ok_or_else(|| format!("column {name} needs a type"))?
.to_string();
let upper = part.to_ascii_uppercase();
out.push(InitColumn {
name,
ty,
nullable: !upper.contains("NOT NULL"),
auto_increment: upper.contains("AUTO_INCREMENT"),
primary_key: upper.contains("PRIMARY KEY"),
unique: upper.contains("UNIQUE"),
});
}
Ok(out)
}
/// 按顶层的逗号切开(类型里的括号不算)
fn split_top_level(body: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut current = String::new();
let mut depth = 0;
for ch in body.chars() {
match ch {
'(' => {
depth += 1;
current.push(ch);
}
')' => {
depth -= 1;
current.push(ch);
}
',' if depth == 0 => {
parts.push(std::mem::take(&mut current));
}
_ => current.push(ch),
}
}
if !current.trim().is_empty() {
parts.push(current);
}
parts
}
/// 去掉反引号与空白
fn clean_name(raw: &str) -> String {
raw.trim().trim_matches('`').trim_matches('"').trim().to_string()
}
/// 去掉注释后按分号切开
fn statements(text: &str) -> Vec<String> {
let mut cleaned = String::new();
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '-' && chars.peek() == Some(&'-') {
for next in chars.by_ref() {
if next == '\n' {
cleaned.push('\n');
break;
}
}
} else if ch == '/' && chars.peek() == Some(&'*') {
chars.next();
while let Some(next) = chars.next() {
if next == '*' && chars.peek() == Some(&'/') {
chars.next();
break;
}
}
} else {
cleaned.push(ch);
}
}
cleaned
.split(';')
.map(|statement| statement.trim().to_string())
.filter(|statement| !statement.is_empty())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// 仓库里的系统库脚本必须始终能解析
const SYSTEM_SCHEMA: &str = include_str!("../../scripts/init.sql");
#[test]
fn parses_the_repository_init_script() {
let tables = parse(SYSTEM_SCHEMA).expect("scripts/init.sql must parse");
let users = tables
.iter()
.find(|table| table.name == "__chusql_users")
.expect("init.sql must declare the account table");
let names: Vec<&str> = users.columns.iter().map(|column| column.name.as_str()).collect();
assert_eq!(names, ["id", "user", "password_hash", "registered_at", "last_login_at", "revision"]);
assert_eq!(users.columns[0].ty, "int");
assert!(users.columns[0].primary_key && users.columns[0].auto_increment && !users.columns[0].nullable);
assert_eq!(users.columns[1].ty, "varchar(64)");
assert!(users.columns[1].unique);
assert_eq!(users.columns[4].ty, "timestamp");
assert!(users.columns[4].nullable);
assert_eq!(users.indexes, ["user"]);
}
#[test]
fn rejects_unknown_statements_and_table_level_constraints() {
assert!(parse("DROP TABLE users;").is_err());
assert!(parse("CREATE TABLE t (id int, PRIMARY KEY (id));").is_err());
assert!(parse("CREATE INDEX missing (user);").is_err());
}
#[test]
fn ignores_comments_and_blank_statements() {
let tables = parse("-- note\nCREATE TABLE t (a int NOT NULL);\n\n/* done */\n").unwrap();
assert_eq!(tables.len(), 1);
assert_eq!(tables[0].columns[0].name, "a");
assert!(!tables[0].columns[0].nullable);
}
}
+2
View File
@@ -2,7 +2,9 @@
pub mod btree;
pub mod config;
pub mod endpoint;
pub mod heap;
pub mod initsql;
pub mod log;
pub mod page;
pub mod protocol;
+29 -29
View File
@@ -82,7 +82,9 @@ impl Level {
static LEVEL: AtomicU8 = AtomicU8::new(Level::Info as u8);
static COLOR: OnceLock<bool> = OnceLock::new();
static COLOR_STDERR: OnceLock<bool> = OnceLock::new();
static COLOR_STDOUT: OnceLock<bool> = OnceLock::new();
/// 设置全局等级
pub fn set_level(level: Level) {
@@ -100,22 +102,29 @@ pub fn init() {
enable_vt();
}
/// 写出一行
/// 写出一行:warn / error 走 stderr,info / debug 走 stdout。
pub fn write(channel: Channel, level: Level, args: fmt::Arguments) {
let msg = args.to_string();
let line = render(level, channel, color_enabled(), &now(), &msg);
let stderr = std::io::stderr();
let mut out = stderr.lock();
let _ = writeln!(out, "{}", line);
let to_stderr = streams_to_stderr(level);
let line = render(level, channel, color_enabled(to_stderr), &now(), &msg);
if to_stderr {
let mut out = std::io::stderr().lock();
let _ = writeln!(out, "{}", line);
} else {
let mut out = std::io::stdout().lock();
let _ = writeln!(out, "{}", line);
}
}
/// 是否着色(终端或强制)
fn color_enabled() -> bool {
*COLOR.get_or_init(|| match std::env::var("CHUSQL_LOG_COLOR").as_deref().map(str::trim) {
Ok("always") | Ok("yes") | Ok("1") => true,
Ok("never") | Ok("no") | Ok("0") => false,
_ => std::io::stderr().is_terminal(),
})
/// 这个等级写 stderr 吗:warn / error 是,info / debug 写 stdout
fn streams_to_stderr(level: Level) -> bool {
matches!(level, Level::Error | Level::Warn)
}
/// 是否着色:只按要写的那条流是不是终端判断,没有环境变量开关
fn color_enabled(to_stderr: bool) -> bool {
let cached = if to_stderr { &COLOR_STDERR } else { &COLOR_STDOUT };
*cached.get_or_init(|| if to_stderr { std::io::stderr().is_terminal() } else { std::io::stdout().is_terminal() })
}
/// 拼一行(纯函数,可测)
@@ -203,8 +212,8 @@ fn now() -> Stamp {
let (year, month, day) = civil_from_days((secs / 86_400) as i64);
Stamp {
year: year as u16,
month: month as u16,
day: day as u16,
month,
day,
hour: ((secs % 86_400) / 3600) as u16,
minute: ((secs % 3_600) / 60) as u16,
second: (secs % 60) as u16,
@@ -346,19 +355,10 @@ mod tests {
}
#[test]
fn render_without_color_is_plain() {
let stamp = Stamp {
year: 2026,
month: 9,
day: 24,
hour: 1,
minute: 2,
second: 3,
milli: 4,
};
assert_eq!(
render(Level::Error, Channel::Core, false, &stamp, "boom"),
"[ERROR 2026-09-24 01:02:03.004] [core] boom"
);
fn warnings_and_errors_go_to_stderr() {
assert!(streams_to_stderr(Level::Error));
assert!(streams_to_stderr(Level::Warn));
assert!(!streams_to_stderr(Level::Info));
assert!(!streams_to_stderr(Level::Debug));
}
}
+114 -8
View File
@@ -5,18 +5,55 @@ use std::collections::HashMap;
pub type Row = HashMap<String, serde_json::Value>;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ColumnType {
Int,
Str,
Bool,
pub const USERS_TABLE: &str = "__chusql_users";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Account {
pub id: i64,
pub user: String,
pub password_hash: String,
pub revision: u64,
#[serde(default)]
pub registered_at: String,
#[serde(default)]
pub last_login_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SchemaColumn {
pub name: String,
pub ty: ColumnType,
pub ty: String,
#[serde(default = "default_true")]
pub nullable: bool,
#[serde(default)]
pub default: Option<serde_json::Value>,
#[serde(default)]
pub auto_increment: bool,
#[serde(default)]
pub primary_key: bool,
#[serde(default)]
pub unique: bool,
#[serde(default)]
pub check: Option<String>,
}
impl Default for SchemaColumn {
fn default() -> Self {
SchemaColumn {
name: String::new(),
ty: String::new(),
nullable: true,
default: None,
auto_increment: false,
primary_key: false,
unique: false,
check: None,
}
}
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -35,9 +72,20 @@ pub struct IndexWire {
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "method", rename_all = "snake_case")]
pub enum Request {
AccountsList,
AccountCreate { user: String, password_hash: String },
AccountReset { user: String, password_hash: String },
AccountLogin {
user: String,
#[serde(default)]
at: Option<String>,
},
AccountDrop { user: String },
Ping,
Scan {
table: String,
#[serde(default)]
columns: Option<Vec<String>>,
},
Insert {
table: String,
@@ -55,7 +103,19 @@ pub enum Request {
table: String,
#[serde(default = "default_id_column")]
column: String,
key: i64,
key: serde_json::Value,
},
RangeByIndex {
table: String,
column: String,
#[serde(default)]
lo: Option<serde_json::Value>,
#[serde(default = "default_true")]
lo_inclusive: bool,
#[serde(default)]
hi: Option<serde_json::Value>,
#[serde(default = "default_true")]
hi_inclusive: bool,
},
ReplaceAll {
table: String,
@@ -85,6 +145,51 @@ pub enum Request {
table: String,
column: String,
},
Compact {
table: String,
},
ReplaceSchema {
table: String,
columns: Vec<SchemaColumn>,
rows: Vec<Row>,
},
}
impl Request {
/// 拒绝可能影响文件路径的列名
pub fn valid_columns(&self) -> bool {
let valid = |name: &str| !name.is_empty()
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
match self {
Self::CreateIndex { column, .. } | Self::DropIndex { column, .. }
| Self::DropColumn { column, .. } | Self::LookupByIndex { column, .. }
| Self::RangeByIndex { column, .. } => valid(column),
Self::CreateTable { columns, .. } => columns.iter().all(|c| valid(&c.name)),
Self::ReplaceSchema { columns, .. } => columns.iter().all(|c| valid(&c.name)),
_ => true,
}
}
/// 普通表操作的统一访问边界
pub fn table(&self) -> Option<&str> {
match self {
Self::Scan { table, .. } | Self::Insert { table, .. }
| Self::InsertBatch { table, .. } | Self::DeleteKeys { table, .. }
| Self::LookupByIndex { table, .. } | Self::ReplaceAll { table, .. }
| Self::RangeByIndex { table, .. }
| Self::DescribeTable { table } | Self::CreateTable { table, .. }
| Self::DropTable { table } | Self::CreateIndex { table, .. }
| Self::DropIndex { table, .. } | Self::DropColumn { table, .. }
| Self::Compact { table }
| Self::ReplaceSchema { table, .. } => Some(table),
Self::Ping | Self::ListTables | Self::ListCatalog | Self::AccountsList
| Self::AccountCreate { .. } | Self::AccountReset { .. } | Self::AccountLogin { .. }
| Self::AccountDrop { .. } => None,
}
}
}
pub fn reserved_table(table: &str) -> bool {
table.to_ascii_lowercase().starts_with("__chusql_")
}
/// 没写 column 时按 `id` 算
@@ -95,6 +200,7 @@ fn default_id_column() -> String {
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum Response {
Accounts { accounts: Vec<Account> },
Pong,
Rows { rows: Vec<Row> },
Tables { tables: Vec<String> },
+60 -1
View File
@@ -5,7 +5,7 @@ use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use crate::protocol::Row;
use crate::protocol::{Account, Row, SchemaColumn, USERS_TABLE};
// 预写日志:先记操作并 fsync,再改数据;重启重放残留。
@@ -14,9 +14,16 @@ const OP_REPLACE_ALL: u8 = 2;
const OP_INSERT_BATCH: u8 = 3;
const OP_DELETE_KEYS: u8 = 4;
const OP_DROP_COLUMN: u8 = 5;
const OP_ACCOUNTS: u8 = 6;
const OP_REPLACE_SCHEMA: u8 = 7;
const OP_HIDE_COLUMN: u8 = 8;
const OP_COMPACT: u8 = 9;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum WalOp {
HideColumn { table: String, column: String },
Compact { table: String, rows: Vec<Row> },
Accounts { accounts: Vec<Account> },
Insert {
table: String,
row: Row,
@@ -38,6 +45,11 @@ pub enum WalOp {
column: String,
rows: Vec<Row>,
},
ReplaceSchema {
table: String,
columns: Vec<SchemaColumn>,
rows: Vec<Row>,
},
}
#[derive(Debug, Serialize, Deserialize)]
@@ -46,6 +58,12 @@ struct DropColumnPayload {
rows: Vec<Row>,
}
#[derive(Debug, Serialize, Deserialize)]
struct ReplaceSchemaPayload {
columns: Vec<SchemaColumn>,
rows: Vec<Row>,
}
pub struct Wal {
path: PathBuf,
handle: Mutex<Option<File>>,
@@ -134,6 +152,14 @@ fn truncate(f: &mut File) -> io::Result<()> {
/// 把操作编成字节
fn encode(op: &WalOp) -> io::Result<Vec<u8>> {
let (op_type, table, payload) = match op {
WalOp::HideColumn { table, column } => (OP_HIDE_COLUMN, table.clone(),
serde_json::to_vec(column).map_err(io::Error::other)?),
WalOp::Compact { table, rows } => {
let p = serde_json::to_vec(rows).map_err(io::Error::other)?;
(OP_COMPACT, table.clone(), p)
}
WalOp::Accounts { accounts } => (OP_ACCOUNTS, USERS_TABLE.to_string(),
serde_json::to_vec(accounts).map_err(io::Error::other)?),
WalOp::Insert { table, row } => {
let p = serde_json::to_vec(row).map_err(io::Error::other)?;
(OP_INSERT, table.clone(), p)
@@ -158,6 +184,14 @@ fn encode(op: &WalOp) -> io::Result<Vec<u8>> {
.map_err(io::Error::other)?;
(OP_DROP_COLUMN, table.clone(), p)
}
WalOp::ReplaceSchema { table, columns, rows } => {
let p = serde_json::to_vec(&ReplaceSchemaPayload {
columns: columns.clone(),
rows: rows.clone(),
})
.map_err(io::Error::other)?;
(OP_REPLACE_SCHEMA, table.clone(), p)
}
};
let table_bytes = table.as_bytes();
@@ -202,6 +236,22 @@ fn decode(buf: &[u8]) -> Result<Option<WalOp>, String> {
let payload = &body[11 + table_len..];
let op = match op_type {
OP_HIDE_COLUMN => {
let column = serde_json::from_slice(payload)
.map_err(|e| format!("bad hide_column payload: {e}"))?;
WalOp::HideColumn { table, column }
}
OP_COMPACT => {
let rows: Vec<Row> = serde_json::from_slice(payload)
.map_err(|e| format!("bad compact payload: {e}"))?;
WalOp::Compact { table, rows }
}
OP_ACCOUNTS => {
if table != USERS_TABLE { return Err("invalid account WAL table".into()); }
let accounts = serde_json::from_slice(payload)
.map_err(|e| format!("bad account payload: {e}"))?;
WalOp::Accounts { accounts }
}
OP_INSERT => {
let row: Row =
serde_json::from_slice(payload).map_err(|e| format!("bad insert payload: {}", e))?;
@@ -231,6 +281,15 @@ fn decode(buf: &[u8]) -> Result<Option<WalOp>, String> {
rows: p.rows,
}
}
OP_REPLACE_SCHEMA => {
let p: ReplaceSchemaPayload = serde_json::from_slice(payload)
.map_err(|e| format!("bad replace_schema payload: {}", e))?;
WalOp::ReplaceSchema {
table,
columns: p.columns,
rows: p.rows,
}
}
other => return Err(format!("unknown op type: {}", other)),
};
Ok(Some(op))
+147 -102
View File
@@ -1,21 +1,24 @@
use chusql_storage::btree::DiskBTree;
use chusql_storage::btree::{fits_in_page, DiskBTree, MAX_KEY_BYTES};
use chusql_storage::config::{DEFAULT_BTREE_ORDER, DEFAULT_PAGE_SIZE};
use chusql_storage::page::DEFAULT_POOL_SIZE;
// B+ 树测试:插查、重开、分裂、页大小与 order 校验。
// B+ 树测试:字节键、重复键、范围扫描、重开与配置校验。
fn open(dir: &std::path::Path, name: &str) -> DiskBTree {
DiskBTree::open(dir.join(name), DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, DEFAULT_POOL_SIZE).unwrap()
}
/// 整数键编码成保序的 8 字节(与堆表一致)
fn key(n: i64) -> [u8; 8] {
(n as u64 ^ (1u64 << 63)).to_be_bytes()
}
/// 空树查不到,遍历为空
#[test]
fn empty_tree_get() {
let dir = tempfile::tempdir().unwrap();
let mut t = DiskBTree::open(
dir.path().join("t.idx"),
DEFAULT_PAGE_SIZE,
DEFAULT_BTREE_ORDER,
DEFAULT_POOL_SIZE,
)
.unwrap();
assert_eq!(t.get(1).unwrap(), None);
let mut t = open(dir.path(), "t.idx");
assert_eq!(t.get(&key(1)).unwrap(), None);
assert_eq!(t.iter_all().unwrap(), vec![]);
}
@@ -23,18 +26,12 @@ fn empty_tree_get() {
#[test]
fn insert_then_get() {
let dir = tempfile::tempdir().unwrap();
let mut t = DiskBTree::open(
dir.path().join("t.idx"),
DEFAULT_PAGE_SIZE,
DEFAULT_BTREE_ORDER,
DEFAULT_POOL_SIZE,
)
.unwrap();
let mut t = open(dir.path(), "t.idx");
for i in 0..100 {
t.insert(i, i as u64 * 10).unwrap();
t.insert(&key(i), i as u64 * 10).unwrap();
}
for i in 0..100 {
assert_eq!(t.get(i).unwrap(), Some(i as u64 * 10));
assert_eq!(t.get(&key(i)).unwrap(), Some(i as u64 * 10));
}
}
@@ -46,81 +43,42 @@ fn reopen_persists() {
{
let mut t = DiskBTree::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, DEFAULT_POOL_SIZE).unwrap();
for i in 0..50 {
t.insert(i, i as u64).unwrap();
t.insert(&key(i), i as u64).unwrap();
}
}
let mut t = DiskBTree::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, DEFAULT_POOL_SIZE).unwrap();
for i in 0..50 {
assert_eq!(t.get(i).unwrap(), Some(i as u64));
assert_eq!(t.get(&key(i)).unwrap(), Some(i as u64));
}
}
/// 同一个键后写的值生效
/// 同一个键可以挂多个位置,按值升序返回
#[test]
fn update_existing() {
fn duplicate_keys_keep_every_value() {
let dir = tempfile::tempdir().unwrap();
let mut t = DiskBTree::open(
dir.path().join("t.idx"),
DEFAULT_PAGE_SIZE,
DEFAULT_BTREE_ORDER,
DEFAULT_POOL_SIZE,
)
.unwrap();
t.insert(5, 100).unwrap();
t.insert(5, 200).unwrap();
assert_eq!(t.get(5).unwrap(), Some(200));
let mut t = open(dir.path(), "dup.idx");
t.insert(b"same", 30).unwrap();
t.insert(b"same", 10).unwrap();
t.insert(b"same", 20).unwrap();
assert_eq!(t.get_all(b"same").unwrap(), vec![10, 20, 30]);
assert_eq!(t.get(b"same").unwrap(), Some(10));
assert!(t.delete(b"same", 10).unwrap());
assert_eq!(t.get_all(b"same").unwrap(), vec![20, 30]);
assert!(!t.delete(b"same", 10).unwrap());
}
/// 倒序插入后遍历仍升序
#[test]
fn iter_all_sorted() {
let dir = tempfile::tempdir().unwrap();
let mut t = DiskBTree::open(
dir.path().join("t.idx"),
DEFAULT_PAGE_SIZE,
DEFAULT_BTREE_ORDER,
DEFAULT_POOL_SIZE,
)
.unwrap();
let mut t = open(dir.path(), "t.idx");
for i in (0..50).rev() {
t.insert(i, i as u64).unwrap();
t.insert(&key(i), i as u64).unwrap();
}
let all = t.iter_all().unwrap();
assert_eq!(all.len(), 50);
for (i, (k, _)) in all.iter().enumerate() {
assert_eq!(*k, i as i64);
}
}
/// 倒序插 500 个键都能查到
#[test]
fn reverse_order_insert() {
let dir = tempfile::tempdir().unwrap();
let mut t = DiskBTree::open(
dir.path().join("t.idx"),
DEFAULT_PAGE_SIZE,
DEFAULT_BTREE_ORDER,
DEFAULT_POOL_SIZE,
)
.unwrap();
for i in (0..500).rev() {
t.insert(i, i as u64).unwrap();
}
for i in 0..500 {
assert_eq!(t.get(i).unwrap(), Some(i as u64));
}
}
/// 512 字节页 + 8 个孩子可用
#[test]
fn custom_page_size_and_order() {
let dir = tempfile::tempdir().unwrap();
let mut t = DiskBTree::open(dir.path().join("t.idx"), 512, 8, DEFAULT_POOL_SIZE).unwrap();
for i in 0..200 {
t.insert(i, i as u64 + 1).unwrap();
}
for i in 0..200 {
assert_eq!(t.get(i).unwrap(), Some(i as u64 + 1));
assert_eq!(k.as_slice(), key(i as i64).as_slice());
}
}
@@ -131,7 +89,7 @@ fn config_mismatch_errors() {
let path = dir.path().join("t.idx");
{
let mut t = DiskBTree::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, DEFAULT_POOL_SIZE).unwrap();
t.insert(1, 1).unwrap();
t.insert(&key(1), 1).unwrap();
}
assert!(DiskBTree::open(&path, DEFAULT_PAGE_SIZE, 8, DEFAULT_POOL_SIZE).is_err());
assert!(DiskBTree::open(&path, 8192, DEFAULT_BTREE_ORDER, DEFAULT_POOL_SIZE).is_err());
@@ -152,60 +110,147 @@ fn not_an_index_file_errors() {
fn order_too_large_for_page_errors() {
let dir = tempfile::tempdir().unwrap();
assert!(DiskBTree::open(dir.path().join("t.idx"), 512, 300, DEFAULT_POOL_SIZE).is_err());
assert!(!fits_in_page(64, DEFAULT_BTREE_ORDER));
}
/// 删掉键之后查不到,别人不受影响
/// 删掉一个键值之后查不到,别人不受影响
#[test]
fn delete_removes_only_that_key() {
let dir = tempfile::tempdir().unwrap();
let mut t = DiskBTree::open(
dir.path().join("t.idx"),
DEFAULT_PAGE_SIZE,
DEFAULT_BTREE_ORDER,
DEFAULT_POOL_SIZE,
)
.unwrap();
let mut t = open(dir.path(), "t.idx");
for i in 0..200 {
t.insert(i, i as u64 + 1).unwrap();
t.insert(&key(i), i as u64 + 1).unwrap();
}
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(&key(50), 51).unwrap(), "deleting an existing entry should return true");
assert_eq!(t.get(&key(50)).unwrap(), None);
assert_eq!(t.get(&key(49)).unwrap(), Some(50));
assert_eq!(t.get(&key(51)).unwrap(), Some(52));
assert!(!t.delete(50).unwrap());
assert!(!t.delete(&key(50), 51).unwrap());
let all = t.iter_all().unwrap();
assert_eq!(all.len(), 199);
assert!(all.iter().all(|(k, _)| *k != 50));
assert!(all.iter().all(|(k, _)| k.as_slice() != key(50).as_slice()));
}
/// 删过之后树还能继续插
#[test]
fn insert_after_delete_still_works() {
let dir = tempfile::tempdir().unwrap();
let mut t = DiskBTree::open(
dir.path().join("t.idx"),
DEFAULT_PAGE_SIZE,
DEFAULT_BTREE_ORDER,
DEFAULT_POOL_SIZE,
)
.unwrap();
let mut t = open(dir.path(), "t.idx");
for i in 0..300 {
t.insert(i, i as u64).unwrap();
t.insert(&key(i), i as u64).unwrap();
}
for i in (0..300).step_by(2) {
assert!(t.delete(i).unwrap());
assert!(t.delete(&key(i), i as u64).unwrap());
}
for i in 1000..1100 {
t.insert(i, i as u64).unwrap();
t.insert(&key(i), i as u64).unwrap();
}
for i in 0..300 {
let want = if i % 2 == 0 { None } else { Some(i as u64) };
assert_eq!(t.get(i).unwrap(), want, "key {}", i);
assert_eq!(t.get(&key(i)).unwrap(), want, "key {}", i);
}
for i in 1000..1100 {
assert_eq!(t.get(i).unwrap(), Some(i as u64));
assert_eq!(t.get(&key(i)).unwrap(), Some(i as u64));
}
}
/// 变长键能插能查,最长键也支持
#[test]
fn variable_length_keys_round_trip() {
let dir = tempfile::tempdir().unwrap();
let mut t = open(dir.path(), "var.idx");
t.insert(b"a", 1).unwrap();
t.insert(b"alice", 2).unwrap();
t.insert(&[b'z'; MAX_KEY_BYTES], 3).unwrap();
t.flush().unwrap();
assert_eq!(t.get(b"a").unwrap(), Some(1));
assert_eq!(t.get(b"alice").unwrap(), Some(2));
assert_eq!(t.get(b"bob").unwrap(), None);
assert_eq!(t.get(&[b'z'; MAX_KEY_BYTES]).unwrap(), Some(3));
assert_eq!(t.get(&[b'z'; MAX_KEY_BYTES - 1]).unwrap(), None);
}
/// 超出键长上限直接报错,不写坏节点
#[test]
fn oversized_key_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let mut t = open(dir.path(), "long.idx");
let too_long = vec![b'k'; MAX_KEY_BYTES + 1];
assert!(t.insert(&too_long, 1).is_err());
assert_eq!(t.get(&too_long).unwrap(), None);
}
/// 插满一页后会分裂,顺序仍然正确
#[test]
fn splits_keep_keys_sorted() {
let dir = tempfile::tempdir().unwrap();
let mut t = open(dir.path(), "split.idx");
let mut expected: Vec<Vec<u8>> = Vec::new();
for i in 0..600 {
let k = format!("key-{:04}", (i * 7919) % 600).into_bytes();
t.insert(&k, i as u64).unwrap();
expected.push(k);
}
expected.sort();
expected.dedup();
let all: Vec<Vec<u8>> = t.iter_all().unwrap().into_iter().map(|(k, _)| k).collect();
assert_eq!(all, expected);
for k in &expected {
assert!(t.get(k).unwrap().is_some(), "missing {:?}", String::from_utf8_lossy(k));
}
}
/// 范围扫描走叶子链,两端都是闭区间
#[test]
fn range_scan_walks_the_leaf_chain() {
let dir = tempfile::tempdir().unwrap();
let mut t = open(dir.path(), "range.idx");
for word in ["apple", "banana", "cherry", "date", "elderberry"] {
t.insert(word.as_bytes(), word.len() as u64).unwrap();
}
let mid: Vec<String> = t
.scan(Some(b"banana"), Some(b"date"))
.unwrap()
.into_iter()
.map(|(k, _)| String::from_utf8(k).unwrap())
.collect();
assert_eq!(mid, vec!["banana", "cherry", "date"]);
let from: Vec<String> = t
.scan(Some(b"cherry"), None)
.unwrap()
.into_iter()
.map(|(k, _)| String::from_utf8(k).unwrap())
.collect();
assert_eq!(from, vec!["cherry", "date", "elderberry"]);
let until: Vec<String> = t
.scan(None, Some(b"banana"))
.unwrap()
.into_iter()
.map(|(k, _)| String::from_utf8(k).unwrap())
.collect();
assert_eq!(until, vec!["apple", "banana"]);
assert!(t.scan(Some(b"z"), None).unwrap().is_empty());
}
/// 清空之后还能接着用
#[test]
fn clear_resets_the_tree() {
let dir = tempfile::tempdir().unwrap();
let mut t = open(dir.path(), "clear.idx");
t.insert(b"a", 1).unwrap();
t.insert(b"b", 2).unwrap();
t.clear().unwrap();
assert!(t.iter_all().unwrap().is_empty());
t.insert(b"c", 3).unwrap();
assert_eq!(t.get(b"c").unwrap(), Some(3));
}
+41 -16
View File
@@ -1,5 +1,5 @@
use chusql_storage::catalog::Catalog;
use chusql_storage::protocol::{ColumnType, Row};
use chusql_storage::protocol::Row;
use serde_json::json;
// 数据字典测试:补列、去重、未知表。
@@ -14,13 +14,13 @@ fn row(pairs: &[(&str, serde_json::Value)]) -> Row {
m
}
/// 空文件得到空字典
/// 空文件得到空字典,未知表返回 None
#[test]
fn empty_file_gives_empty_catalog() {
let dir = tempfile::tempdir().unwrap();
fn empty_file_gives_empty_catalog_and_unknown_table_returns_none() { let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("catalog.json");
let c = Catalog::load(&path).unwrap();
assert_eq!(c.table_names().len(), 0);
assert!(c.describe("nope").is_none());
}
/// 补列之后能查到 schema
@@ -40,9 +40,9 @@ fn ensure_then_describe() {
assert!(names.contains(&"id"));
assert!(names.contains(&"name"));
let id_col = s.columns.iter().find(|c| c.name == "id").unwrap();
assert_eq!(id_col.ty, ColumnType::Int);
assert_eq!(id_col.ty, "int");
let name_col = s.columns.iter().find(|c| c.name == "name").unwrap();
assert_eq!(name_col.ty, ColumnType::Str);
assert_eq!(name_col.ty, "str");
}
/// 再插入不会重复列
@@ -57,15 +57,6 @@ fn second_insert_does_not_duplicate_columns() {
assert_eq!(c.describe("users").unwrap().columns.len(), 1);
}
/// 未知表返回 None
#[test]
fn describe_unknown_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("catalog.json");
let c = Catalog::load(&path).unwrap();
assert!(c.describe("nope").is_none());
}
/// 删列同时清掉统计与索引
#[test]
fn remove_column_drops_definition_stats_and_index() {
@@ -83,10 +74,44 @@ fn remove_column_drops_definition_stats_and_index() {
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");
assert!(!s.stats.contains_key("code"), "stats for code should be gone");
let again = c.remove_column("t", "code").unwrap();
assert!(!again);
assert!(c.remove_column("nope", "code").is_err());
}
/// 老类型名并到新写法,参数保留,未知类型原样
#[test]
fn normalize_type_maps_legacy_names() {
use chusql_storage::catalog::normalize_type;
assert_eq!(normalize_type("integer"), "int");
assert_eq!(normalize_type("TEXT"), "str");
assert_eq!(normalize_type("BOOLEAN"), "bool");
assert_eq!(normalize_type("varchar(20)"), "varchar(20)");
assert_eq!(normalize_type("numeric(8,2)"), "decimal(8,2)");
assert_eq!(normalize_type("real"), "float");
assert_eq!(normalize_type("weird"), "weird");
}
/// 老格式字典(列里没有新字段)能读进来,规范化后写回新写法
#[test]
fn legacy_catalog_loads_and_normalizes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("catalog.json");
std::fs::write(
&path,
r#"{"tables":{"legacy":{"columns":[{"name":"id","ty":"integer"},{"name":"name","ty":"text"}]}}}"#,
)
.unwrap();
let mut c = Catalog::load(&path).unwrap();
let s = c.describe("legacy").unwrap();
assert_eq!(s.columns.len(), 2);
assert!(s.columns[0].nullable, "老列默认可空");
assert!(c.normalize_types());
let types: Vec<&str> = c.describe("legacy").unwrap().columns.iter().map(|col| col.ty.as_str()).collect();
assert_eq!(types, vec!["int", "str"]);
assert!(!c.normalize_types(), "第二次没有可改的");
}
+146 -45
View File
@@ -1,23 +1,32 @@
use std::path::PathBuf;
use std::sync::Mutex;
use chusql_storage::config::{
self, Config, DEFAULT_BTREE_ORDER, DEFAULT_DATA_DIR, DEFAULT_PAGE_SIZE, DEFAULT_PIPE_NAME,
Origin,
self, Config, DEFAULT_BTREE_ORDER, DEFAULT_PAGE_SIZE, DEFAULT_PIPE_NAME, Origin,
};
use chusql_storage::log::Level;
// 配置测试:默认值、文件覆盖、环境变量优先、非法值报错。
// 配置测试:默认值、文件覆盖、环境变量彻底无效、非法值报错。
/// set_var 改的是进程环境,用例之间要串行,免得互相污染
static ENV_LOCK: Mutex<()> = Mutex::new(());
/// 拿锁(前面的用例 panic 过也照样拿)
fn env_guard() -> std::sync::MutexGuard<'static, ()> {
ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
/// 无文件时全默认
#[test]
fn defaults_without_file() {
let loaded = config::resolve(None, None, &|_| None).unwrap();
let _guard = env_guard();
let loaded = config::resolve(None, None).unwrap();
assert_eq!(loaded.config, Config::default());
assert!(loaded.config_path.is_none());
assert!(loaded.origins.iter().all(|(_, _, o)| *o == Origin::Default));
assert_eq!(loaded.config.page_size, DEFAULT_PAGE_SIZE);
assert_eq!(loaded.config.btree_order, DEFAULT_BTREE_ORDER);
assert_eq!(loaded.config.data_dir, PathBuf::from(DEFAULT_DATA_DIR));
assert_eq!(loaded.config.data_dir, config::default_data_dir());
assert_eq!(loaded.config.pipe_name, DEFAULT_PIPE_NAME);
assert_eq!(loaded.config.log_level, Level::Info);
}
@@ -25,13 +34,14 @@ fn defaults_without_file() {
/// 只覆盖文件里写了的项
#[test]
fn file_overrides_only_written_keys() {
let _guard = env_guard();
let path = PathBuf::from("t.toml");
let loaded = config::resolve(Some("[page]\nsize = 8192\n"), Some(path.clone()), &|_| None)
let loaded = config::resolve(Some("[page]\nsize = 8192\n"), Some(path.clone()))
.unwrap();
assert_eq!(loaded.config.page_size, 8192);
assert_eq!(loaded.config.btree_order, DEFAULT_BTREE_ORDER);
assert_eq!(loaded.config.data_dir, PathBuf::from(DEFAULT_DATA_DIR));
assert_eq!(loaded.config.data_dir, config::default_data_dir());
assert_eq!(loaded.config_path, Some(path.clone()));
let page = loaded.origins.iter().find(|(n, _, _)| *n == "page.size").unwrap();
@@ -43,9 +53,10 @@ fn file_overrides_only_written_keys() {
/// 六项全写都生效
#[test]
fn file_all_keys() {
let _guard = env_guard();
let text = "[page]\nsize = 8192\n[btree]\norder = 32\n[buffer]\npool_size = 128\n[storage]\ndata_dir = \"mydata\"\n[server]\npipe_name = \"mypipe\"\n[log]\nlevel = \"debug\"\n";
let path = PathBuf::from("t.toml");
let loaded = config::resolve(Some(text), Some(path.clone()), &|_| None).unwrap();
let loaded = config::resolve(Some(text), Some(path.clone())).unwrap();
assert_eq!(loaded.config.page_size, 8192);
assert_eq!(loaded.config.btree_order, 32);
@@ -61,71 +72,161 @@ fn file_all_keys() {
);
}
/// 环境变量优先于文件
/// 环境变量完全无效:这条通道已经删掉,设了 CHUSQL_* 也只认文件/内置默认
#[test]
fn env_beats_file() {
let text = "[storage]\ndata_dir = \"from-file\"\n";
let env = |k: &str| (k == "CHUSQL_DATA_DIR").then(|| "from-env".to_string());
let loaded = config::resolve(Some(text), Some(PathBuf::from("t.toml")), &env).unwrap();
fn env_vars_are_ignored_entirely() {
let _guard = env_guard();
unsafe {
std::env::set_var("CHUSQL_DATA_DIR", "from-env");
std::env::set_var("CHUSQL_PAGE_SIZE", "8192");
}
assert_eq!(loaded.config.data_dir, PathBuf::from("from-env"));
let text = "[storage]\ndata_dir = \"from-file\"\n";
let loaded = config::resolve(Some(text), Some(PathBuf::from("t.toml"))).unwrap();
assert_eq!(loaded.config.data_dir, PathBuf::from("from-file"));
assert_eq!(loaded.config.page_size, DEFAULT_PAGE_SIZE);
let dir = loaded
.origins
.iter()
.find(|(n, _, _)| *n == "storage.data_dir")
.unwrap();
assert_eq!(dir.2, Origin::Env("CHUSQL_DATA_DIR"));
assert_eq!(dir.2, Origin::File(PathBuf::from("t.toml")));
let builtin = config::resolve(None, None).unwrap();
assert_eq!(builtin.config.data_dir, config::default_data_dir());
assert_eq!(builtin.config.page_size, DEFAULT_PAGE_SIZE);
unsafe {
std::env::remove_var("CHUSQL_DATA_DIR");
std::env::remove_var("CHUSQL_PAGE_SIZE");
}
}
/// 固定路径按系统惯例:<配置目录>/ChuSQL/chusql.toml
#[test]
fn default_config_path_follows_system_convention() {
let _guard = env_guard();
let path = config::default_config_path();
assert_eq!(path.file_name().unwrap(), "chusql.toml");
assert_eq!(path.parent().unwrap().file_name().unwrap(), "ChuSQL");
assert!(path.ends_with(format!("ChuSQL{}chusql.toml", std::path::MAIN_SEPARATOR)), "got {}", path.display());
// 本机 APPDATA 存在时必须是 %APPDATA%\ChuSQL\chusql.toml
#[cfg(windows)]
if let Some(appdata) = std::env::var_os("APPDATA") {
assert_eq!(
path,
PathBuf::from(appdata).join("ChuSQL").join("chusql.toml")
);
}
}
/// 默认数据目录也按系统惯例:Windows 落 %LOCALAPPDATA%\ChuSQL\data,Unix 落 XDG 数据目录
#[test]
fn default_data_dir_follows_system_convention() {
let _guard = env_guard();
let dir = config::default_data_dir();
let expected_parent = if cfg!(windows) { "ChuSQL" } else { "chusql" };
assert_eq!(dir.file_name().unwrap(), "data");
assert_eq!(dir.parent().unwrap().file_name().unwrap(), expected_parent);
// 老的内置默认是仓库里的相对路径 ../localdata,现在不能再指回仓库
assert_ne!(dir, PathBuf::from("../localdata"));
#[cfg(windows)]
if let Some(local) = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()) {
assert_eq!(dir, PathBuf::from(local).join("ChuSQL").join("data"));
} else if let Some(roaming) = std::env::var_os("APPDATA").filter(|v| !v.is_empty()) {
assert_eq!(dir, PathBuf::from(roaming).join("ChuSQL").join("data"));
}
#[cfg(not(windows))]
if let Some(xdg) = std::env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty()) {
assert_eq!(dir, PathBuf::from(xdg).join("chusql").join("data"));
} else if let Some(home) = std::env::var_os("HOME").filter(|v| !v.is_empty()) {
assert_eq!(
dir,
PathBuf::from(home)
.join(".local")
.join("share")
.join("chusql")
.join("data")
);
}
}
/// 显式给了一个不存在的路径:必须报错,不能静默回退默认
#[test]
fn explicit_missing_path_is_an_error() {
let _guard = env_guard();
let missing = std::env::temp_dir().join("chusql-no-such-config-9f21.toml");
assert!(!missing.is_file());
let err = config::load(Some(missing.to_str().unwrap())).unwrap_err();
assert!(
err.contains("config file not found") || err.contains("cannot read config file"),
"got {}",
err
);
assert!(err.contains("chusql-no-such-config-9f21.toml"), "got {}", err);
}
/// 不给 --config:固定路径没有文件时全用内置默认,且不报错
#[test]
fn load_without_explicit_path_uses_builtin_defaults() {
let _guard = env_guard();
let default = config::default_config_path();
let loaded = config::load(None).unwrap();
if default.is_file() {
assert_eq!(loaded.config_path, Some(default));
} else {
assert_eq!(loaded.config, Config::default());
assert_eq!(loaded.config.data_dir, config::default_data_dir());
assert_eq!(loaded.config.pipe_name, DEFAULT_PIPE_NAME);
assert!(loaded.origins.iter().all(|(_, _, o)| *o == Origin::Default));
assert!(loaded.config_path.is_none());
}
}
/// 日志等级非法要报错
#[test]
fn rejects_bad_log_level() {
let err = config::resolve(Some("[log]\nlevel = \"loud\"\n"), None, &|_| None).unwrap_err();
let _guard = env_guard();
let err = config::resolve(Some("[log]\nlevel = \"loud\"\n"), None).unwrap_err();
assert!(err.contains("log.level"), "got {}", err);
}
/// 页大小非法要报错
/// 页大小或 order 非法都要报错
#[test]
fn rejects_bad_page_size() {
let err = config::resolve(Some("[page]\nsize = 3000\n"), None, &|_| None).unwrap_err();
assert!(err.contains("page.size"), "got {}", err);
fn rejects_bad_numeric_limits() {
let _guard = env_guard();
for text in ["[page]\nsize = 3000\n", "[page]\nsize = 128\n"] {
let err = config::resolve(Some(text), None).unwrap_err();
assert!(err.contains("page.size"), "{}: {}", text, err);
}
let err = config::resolve(Some("[page]\nsize = 128\n"), None, &|_| None).unwrap_err();
assert!(err.contains("page.size"), "got {}", err);
}
/// order 非法要报错
#[test]
fn rejects_bad_btree_order() {
let err = config::resolve(Some("[btree]\norder = 2\n"), None, &|_| None).unwrap_err();
let err = config::resolve(Some("[btree]\norder = 2\n"), None).unwrap_err();
assert!(err.contains("btree.order"), "got {}", err);
let err = config::resolve(Some("[btree]\norder = 300\n"), None, &|_| None).unwrap_err();
let err = config::resolve(Some("[btree]\norder = 300\n"), None).unwrap_err();
assert!(err.contains("does not fit"), "got {}", err);
}
/// 未知配置键要报错
#[test]
fn rejects_unknown_key() {
let err = config::resolve(Some("[page]\nsizes = 4096\n"), None, &|_| None).unwrap_err();
let _guard = env_guard();
let err = config::resolve(Some("[page]\nsizes = 4096\n"), None).unwrap_err();
assert!(err.contains("bad config"), "got {}", err);
}
/// 环境变量非法要报错
/// 坏的页面尺寸只能来自文件
#[test]
fn rejects_bad_env_value() {
let env = |k: &str| (k == "CHUSQL_PAGE_SIZE").then(|| "big".to_string());
let err = config::resolve(None, None, &env).unwrap_err();
assert!(err.contains("CHUSQL_PAGE_SIZE"), "got {}", err);
}
fn rejects_bad_page_size_from_file() {
let _guard = env_guard();
unsafe { std::env::set_var("CHUSQL_PAGE_SIZE", "big") };
/// 来源文案正确
#[test]
fn origin_description() {
assert_eq!(Origin::Default.describe(), "default");
assert_eq!(Origin::Env("CHUSQL_PIPE").describe(), "env CHUSQL_PIPE");
assert_eq!(
Origin::File(PathBuf::from("a.toml")).describe(),
format!("file {}", PathBuf::from("a.toml").display())
);
let err = config::resolve(Some("[page]\nsize = 3000\n"), None).unwrap_err();
assert!(err.contains("page.size"), "got {}", err);
unsafe { std::env::remove_var("CHUSQL_PAGE_SIZE") };
}
+69 -49
View File
@@ -14,20 +14,62 @@ fn row(pairs: &[(&str, serde_json::Value)]) -> Row {
m
}
/// 插一行再扫描读回
/// 索引文件被清空(强杀或截断)后打开,会按堆数据补齐
#[test]
fn insert_and_scan_one_row() {
fn empty_index_file_is_rebuilt_from_rows() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.db");
let mut t = HeapTable::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_POOL_SIZE).unwrap();
let data = dir.path().join("t.db");
let index = dir.path().join("t.idx");
{
let mut t = HeapTable::open_indexed(&data, &index, DEFAULT_PAGE_SIZE, 4, DEFAULT_POOL_SIZE).unwrap();
for i in 1..=5 {
t.insert_row(&row(&[("id", json!(i))])).unwrap();
}
t.flush().unwrap();
}
std::fs::write(&index, b"").unwrap();
let mut t = HeapTable::open_indexed(&data, &index, DEFAULT_PAGE_SIZE, 4, DEFAULT_POOL_SIZE).unwrap();
assert!(t.get_by_column_key("id", 3).unwrap().is_some(), "空索引文件要从堆数据重建");
assert!(t.get_by_column_key("id", 9).unwrap().is_none());
}
t.insert_row(&row(&[("id", json!(1)), ("name", json!("Alice"))]))
.unwrap();
/// 字符串列能真正建索引:按值点查,NULL 不入索引
#[test]
fn string_index_looks_up_by_value() {
let dir = tempfile::tempdir().unwrap();
let data = dir.path().join("s.db");
let index = dir.path().join("s.idx");
let mut t = HeapTable::open(&data, DEFAULT_PAGE_SIZE, DEFAULT_POOL_SIZE).unwrap();
t.insert_row(&row(&[("id", json!(1)), ("name", json!("alice"))])).unwrap();
t.insert_row(&row(&[("id", json!(2)), ("name", json!("bob"))])).unwrap();
t.insert_row(&row(&[("id", json!(3)), ("name", json!(null))])).unwrap();
t.build_index("name", &index, DEFAULT_PAGE_SIZE, 4, DEFAULT_POOL_SIZE).unwrap();
let rows = t.scan().unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["id"], json!(1));
assert_eq!(rows[0]["name"], json!("Alice"));
assert_eq!(t.get_by_string_key("name", "bob").unwrap().unwrap()["id"], json!(2));
assert!(t.get_by_string_key("name", "dave").unwrap().is_none());
assert!(t.get_by_string_key("name", "").unwrap().is_none(), "NULL 不入索引");
}
/// 允许重复值建索引:点查把重复的行都取回来
#[test]
fn duplicate_values_are_indexed_and_all_returned() {
let dir = tempfile::tempdir().unwrap();
let data = dir.path().join("d.db");
let index = dir.path().join("d.idx");
let mut t = HeapTable::open(&data, DEFAULT_PAGE_SIZE, DEFAULT_POOL_SIZE).unwrap();
t.insert_row(&row(&[("id", json!(1)), ("name", json!("alice"))])).unwrap();
t.insert_row(&row(&[("id", json!(2)), ("name", json!("alice"))])).unwrap();
t.build_index("name", &index, DEFAULT_PAGE_SIZE, 4, DEFAULT_POOL_SIZE).unwrap();
let mut ids: Vec<i64> = t
.get_all_by_string_key("name", "alice")
.unwrap()
.iter()
.filter_map(|r| r["id"].as_i64())
.collect();
ids.sort_unstable();
assert_eq!(ids, vec![1, 2]);
assert!(t.get_all_by_string_key("name", "bob").unwrap().is_empty());
}
/// 500 行跨页顺序不变
@@ -94,22 +136,6 @@ fn reopen_and_scan() {
assert_eq!(rows[1]["id"], json!(2));
}
/// 512 字节页可用
#[test]
fn custom_page_size() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("small.db");
let mut t = HeapTable::open(&path, 512, DEFAULT_POOL_SIZE).unwrap();
for i in 0..50 {
t.insert_row(&row(&[("id", json!(i))])).unwrap();
}
assert_eq!(t.scan().unwrap().len(), 50);
let big = "x".repeat(600);
assert!(t.insert_row(&row(&[("data", json!(big))])).is_err());
}
/// 整表改写后索引重建
#[test]
fn replace_all_rebuilds_index() {
@@ -246,40 +272,34 @@ fn secondary_index_is_maintained() {
t.delete_by_keys(&[2]).unwrap();
assert!(t.get_by_column_key("code", 7002).unwrap().is_none());
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();
t.insert_row(&row(&[("id", json!(4)), ("code", json!(7001))])).unwrap();
let mut dup_ids: Vec<i64> = t
.get_all_by_column_key("code", 7001)
.unwrap()
.iter()
.filter_map(|r| r["id"].as_i64())
.collect();
dup_ids.sort_unstable();
assert_eq!(dup_ids, vec![1, 4], "duplicate index values keep both rows");
let mut t2 = HeapTable::open(dir.path().join("d.db"), DEFAULT_PAGE_SIZE, DEFAULT_POOL_SIZE).unwrap();
t2.insert_rows(&[
row(&[("id", json!(1)), ("age", json!(30))]),
row(&[("id", json!(2)), ("age", json!(30))]),
])
.unwrap();
let err = t2.build_index(
t2.build_index(
"age",
&dir.path().join("d.age.idx"),
DEFAULT_PAGE_SIZE,
chusql_storage::config::DEFAULT_BTREE_ORDER,
DEFAULT_POOL_SIZE,
);
assert!(err.is_err(), "duplicate values must not be indexed");
assert!(!t2.has_index("age"), "no half-built index after failure");
}
/// 同一个 id 插两次要报错
#[test]
fn duplicate_key_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let mut t = HeapTable::open_indexed(
dir.path().join("t.db"),
dir.path().join("t.idx"),
DEFAULT_PAGE_SIZE,
chusql_storage::config::DEFAULT_BTREE_ORDER,
DEFAULT_POOL_SIZE,
)
.unwrap();
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, "the failed row should not reach the heap");
assert_eq!(
t2.get_all_by_column_key("age", 30).unwrap().len(),
2,
"duplicate values are all indexed"
);
}
-19
View File
@@ -49,25 +49,6 @@ fn append_grows_file() {
assert_eq!(pf.num_pages().unwrap(), 3);
}
/// 两页内容互不影响
#[test]
fn two_pages_independent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.db");
let mut pf = PageFile::open(&path, DEFAULT_PAGE_SIZE).unwrap();
let mut a = Page::new(0, DEFAULT_PAGE_SIZE);
a.data[0] = 1;
let mut b = Page::new(1, DEFAULT_PAGE_SIZE);
b.data[0] = 2;
pf.write_page(&a).unwrap();
pf.write_page(&b).unwrap();
assert_eq!(pf.read_page(0).unwrap().data[0], 1);
assert_eq!(pf.read_page(1).unwrap().data[0], 2);
}
/// 覆写同页读到新内容
#[test]
fn overwrite_page() {
File diff suppressed because it is too large. Load diff
+34 -75
View File
@@ -13,77 +13,51 @@ fn row(pairs: &[(&str, serde_json::Value)]) -> Row {
m
}
/// 写读 Insert 操作
/// 三种操作写完都能原样读回
#[test]
fn append_and_read_insert() {
fn append_and_read_round_trips() {
let dir = tempfile::tempdir().unwrap();
let wal = Wal::new(dir.path().join("wal.log"));
let op = WalOp::Insert {
table: "users".into(),
row: row(&[("id", json!(1)), ("name", json!("Alice"))]),
};
wal.append(&op).unwrap();
let back = wal.read().unwrap().unwrap();
assert_eq!(back, op);
let ops = [
WalOp::Insert {
table: "users".into(),
row: row(&[("id", json!(1)), ("name", json!("Alice"))]),
},
WalOp::ReplaceAll {
table: "users".into(),
rows: vec![
row(&[("id", json!(1)), ("name", json!("Alice"))]),
row(&[("id", json!(2)), ("name", json!("Bob"))]),
],
},
WalOp::DropColumn {
table: "users".into(),
column: "age".into(),
rows: vec![
row(&[("id", json!(1)), ("name", json!("Alice"))]),
row(&[("id", json!(2)), ("name", json!("Bob"))]),
],
},
];
for op in ops {
wal.clear().unwrap();
wal.append(&op).unwrap();
assert_eq!(wal.read().unwrap().unwrap(), op);
}
}
/// 写读 ReplaceAll 操作
/// 文件不存在或为空都返回 None
#[test]
fn append_and_read_replace_all() {
fn read_without_content_returns_none() {
let dir = tempfile::tempdir().unwrap();
let wal = Wal::new(dir.path().join("wal.log"));
let missing = Wal::new(dir.path().join("missing.log"));
assert!(missing.read().unwrap().is_none());
let op = WalOp::ReplaceAll {
table: "users".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);
}
/// 写读 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() {
let dir = tempfile::tempdir().unwrap();
let wal = Wal::new(dir.path().join("wal.log"));
assert!(wal.read().unwrap().is_none());
}
/// 空文件返回 None
#[test]
fn empty_file_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("wal.log");
std::fs::write(&path, b"").unwrap();
let wal = Wal::new(&path);
assert!(wal.read().unwrap().is_none());
let empty = Wal::new(&path);
assert!(empty.read().unwrap().is_none());
}
/// 清空后读不到
@@ -124,18 +98,3 @@ fn truncated_tail_is_ignored() {
assert!(wal.read().unwrap().is_none());
}
/// 第二次追加会覆盖
#[test]
fn overwrite_on_second_append_is_visible() {
let dir = tempfile::tempdir().unwrap();
let wal = Wal::new(dir.path().join("wal.log"));
wal.append(&WalOp::Insert {
table: "t".into(),
row: row(&[("id", json!(1))]),
})
.unwrap();
let first = wal.read().unwrap().unwrap();
assert!(matches!(first, WalOp::Insert { .. }));
}
+75 -54
View File
@@ -2,22 +2,25 @@
module Main (main) where
import ChuSQL.Web.Api (AppEnv (..), Live (..), newAppEnv, setLive, webApp)
import ChuSQL.Web.Auth (SessionPolicy (..), newSessionStore)
import ChuSQL.Web.API (AppEnv (..), Live (..), newAppEnvAt, setLive, webApp)
import ChuSQL.Web.Auth (Credential (..), SessionPolicy (..), newSessionStore)
import ChuSQL.Web.Backend (Backend, ipcBackend)
import ChuSQL.Web.Demo (SeedReport (..), seedDemo)
import ChuSQL.Web.Config (
WebConfig (..),
defaultPassword,
defaultUser,
loadWebConfig,
loadWebConfigAt,
resolveCredential,
resolvePipeName,
resolveStaticDir,
staticDirCandidates,
usingDefaultCredentials,
)
import ChuSQL.Web.RateLimit (newRateLimiter)
import ChuSQL.Web.Settings (effectiveSettings)
import ChuSQL.Storage.IPC (setPipeName)
import ChuSQL.Web.Settings (effectiveSettings, readSettingsFile)
import ChuSQL.Web.TOML (resolveConfigPath)
import ChuSQL.Web.StorageProcess (
StorageProcess (..),
findStorageServer,
@@ -25,8 +28,9 @@ import ChuSQL.Web.StorageProcess (
stopStorageProcess,
waitForStorage,
)
import Control.Exception (IOException, bracket, try)
import Control.Exception (IOException, bracket, onException, try)
import Data.Char (toLower)
import qualified Data.Map.Strict as Map
import Data.String (fromString)
import Data.Text (Text)
import qualified Data.Text as T
@@ -42,7 +46,8 @@ import Text.Read (readMaybe)
-- Web 管理端入口:读配置、备凭据、拉起存储进程,最后起 Warp。
data Options = Options
{ optHost :: Maybe String
{ optConfig :: Maybe FilePath
, optHost :: Maybe String
, optPort :: Maybe Int
, optStatic :: Maybe FilePath
, optStorage :: Maybe FilePath
@@ -65,7 +70,8 @@ data Options = Options
emptyOptions :: Options
emptyOptions =
Options
{ optHost = Nothing
{ optConfig = Nothing
, optHost = Nothing
, optPort = Nothing
, optStatic = Nothing
, optStorage = Nothing
@@ -91,6 +97,7 @@ parseArgs = go emptyOptions
go opts [] = Right opts
go opts ("--help" : rest) = go opts{optHelp = True} rest
go opts ("-h" : rest) = go opts{optHelp = True} rest
go opts ("--config" : v : rest) = go opts{optConfig = Just v} 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
@@ -109,11 +116,9 @@ parseArgs = go emptyOptions
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"]
@@ -122,27 +127,36 @@ 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 "config: chusql.toml sits at a fixed place and its sections hold every knob below;"
putStrLn " Windows: %APPDATA%\\ChuSQL\\chusql.toml"
putStrLn " other: $XDG_CONFIG_HOME/ChuSQL/chusql.toml (or ~/.config/ChuSQL/chusql.toml)"
putStrLn " [web] host/port/static_dir/user/password and the limits; [server] pipe_name;"
putStrLn " [storage] data_dir; [page] size; [btree] order; [buffer] pool_size; [log] level"
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"
putStrLn " --config FILE read that config file instead ([web] host ...)"
putStrLn " --host H listen address ([web] host, default 127.0.0.1)"
putStrLn " --port N listen port ([web] port, default 7777)"
putStrLn " --static DIR static asset directory ([web] static_dir, default static)"
putStrLn " --user NAME administrator name ([web] user, default root)"
putStrLn " --storage SERVER Rust storage exe to spawn ([web] storage_server)"
putStrLn " --cookie-secure BOOL add Secure to the cookie ([web] cookie_secure, default false)"
putStrLn " --body-limit N max request body bytes ([web] body_limit, default 65536)"
putStrLn " --session-idle N session idle timeout, sec ([web] session_idle, default 28800)"
putStrLn " --session-max N session max age, sec ([web] session_max, default 86400)"
putStrLn " --login-max-attempts N failed logins before 429 ([web] login_max_attempts, default 5)"
putStrLn " --login-window N lockout window, sec ([web] login_window, default 300)"
putStrLn " --page-size N rows per page by default ([web] rows_per_page, default 25)"
putStrLn " --max-page-size N rows per page ceiling ([web] max_page_size, default 500)"
putStrLn " --max-rows N rows returned per query ([web] max_rows, default 1000)"
putStrLn " --max-sql-length N max SQL characters ([web] max_sql_length, default 20000)"
putStrLn " --seed BOOL load demo data when the db is empty ([web] seed, default false)"
putStrLn ""
putStrLn "administrator password: user / password in the [web] section of chusql.toml, else the built-in demo"
putStrLn " an empty password means the administrator signs in with no password; ordinary accounts are then refused"
putStrLn ("without either the demo administrator is " ++ T.unpack defaultUser ++ " / " ++ T.unpack defaultPassword)
putStrLn "ordinary accounts: CREATE USER / ALTER USER / DROP USER from the SQL console (administrator only)"
putStrLn "storage process: [server] pipe_name says where to talk and where a spawned one listens;"
putStrLn " [storage] data_dir, [page] size, [btree] order, [buffer] pool_size and [log] level are its own"
-- | 命令行覆盖配置
applyOptions :: WebConfig -> Options -> WebConfig
@@ -168,20 +182,19 @@ applyOptions cfg opts =
, 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
startStorageIfWanted :: WebConfig -> FilePath -> IO (Either String (Maybe StorageProcess))
startStorageIfWanted cfg configPath = 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)
Right bin -> fmap (fmap Just) (startStorageProcess bin configPath)
-- | 入口
main :: IO ()
@@ -200,16 +213,20 @@ main = do
-- | 起服务
run :: Options -> IO ()
run opts = do
base <- loadWebConfig
configPath <- resolveConfigPath (optConfig opts)
base <- loadWebConfigAt configPath
let cfg = applyOptions base opts
setPipeName (resolvePipeName cfg)
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
let settingsFile = configPath
saved <- readSettingsFile settingsFile
cred <- resolveCredential cfg saved
storage <- startStorageIfWanted cfg configPath
case storage of
Left err -> do
putStrLn err
@@ -226,17 +243,18 @@ run opts = do
getCurrentTime
(wcLoginMaxAttempts cfg)
(fromIntegral (wcLoginWindow cfg))
env0 <- newAppEnv backend sessions cred limiter staticDir
env0 <- newAppEnvAt settingsFile backend sessions cred limiter staticDir
`onException` maybe (pure ()) stopStorageProcess maybeStorage
let env =
env0
{ aeCookieSecure = wcCookieSecure cfg
, aeEffective = effectiveSettings cfg
, aeLog = \msg -> TIO.putStrLn ("[web] " <> msg)
, aeLog = TIO.putStrLn
}
setLive env (liveFromConfig cfg)
app <- webApp env
printBanner cfg staticDir maybeStorage
reportCredential cfg
printBanner cfg configPath staticDir maybeStorage
reportCredential cred saved
waitForStorageReport
seedIfWanted cfg backend
let settings =
@@ -273,15 +291,17 @@ serve settings app = do
exitFailure
-- | 口令是从哪来的(用了内置默认口令就明确警告)
reportCredential :: WebConfig -> IO ()
reportCredential cfg
| usingDefaultCredentials cfg = do
reportCredential :: Credential -> Map.Map Text Text -> IO ()
reportCredential cred saved
| usingDefaultCredentials saved = 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 " !! Administrator credentials are not configured; the built-in demo password is in use:"
putStrLn (" !! user " ++ T.unpack (credUser cred) ++ " / password " ++ T.unpack defaultPassword)
putStrLn " !! set user / password in the [web] section of chusql.toml and restart"
putStrLn ""
| otherwise = putStrLn ("password: from CHUSQL_WEB_PASSWORD(_HASH) (user " ++ T.unpack (wcUser cfg) ++ ")")
| T.null (credEncoded cred) =
putStrLn ("administrator: " ++ T.unpack (credUser cred) ++ " (passwordless: only the administrator may sign in)")
| otherwise = putStrLn ("administrator: " ++ T.unpack (credUser cred) ++ " (credentials come from the settings file only)")
-- | 配了 seed 就补一份演示数据
seedIfWanted :: WebConfig -> Backend -> IO ()
@@ -290,10 +310,10 @@ seedIfWanted cfg backend
| otherwise = do
result <- seedDemo backend
case result of
Left e -> TIO.putStrLn ("[web] demo data skipped: " <> T.pack e)
Left e -> TIO.putStrLn ("demo data skipped: " <> T.pack e)
Right report ->
TIO.putStrLn
( "[web] demo data: "
( "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)))
@@ -305,15 +325,16 @@ waitForStorageReport = do
up <- waitForStorage 20
putStrLn
( if up
then "storage: up (named pipe answered ping)"
then "storage: up (endpoint 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
printBanner :: WebConfig -> FilePath -> FilePath -> Maybe StorageProcess -> IO ()
printBanner cfg configPath staticDir maybeStorage = do
putStrLn "==================== ChuSQL Web ===================="
putStrLn ("url: http://" ++ wcHost cfg ++ ":" ++ show (wcPort cfg) ++ "/")
putStrLn ("config file: " ++ configPath)
putStrLn ("static dir: " ++ staticDir)
putStrLn
( "limits: body "
@@ -340,5 +361,5 @@ printBanner cfg staticDir maybeStorage = do
++ "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)"
Just sp -> putStrLn ("storage proc: spawned, talking on pipe " ++ spPipe sp)
Nothing -> putStrLn "storage proc: assumed to run elsewhere (pipe name from [server] pipe_name)"
+6 -2
View File
@@ -10,17 +10,20 @@ build-type: Simple
library
hs-source-dirs: src
exposed-modules: ChuSQL.Web.Actions
ChuSQL.Web.Api
ChuSQL.Web.Accounts
ChuSQL.Web.API
ChuSQL.Web.Auth
ChuSQL.Web.Backend
ChuSQL.Web.Config
ChuSQL.Web.Demo
ChuSQL.Web.Privileges
ChuSQL.Web.RateLimit
ChuSQL.Web.Secure
ChuSQL.Web.Settings
ChuSQL.Web.Static
ChuSQL.Web.StorageProcess
ChuSQL.Web.UiSettings
ChuSQL.Web.TOML
ChuSQL.Web.UISettings
build-depends: base >= 4.14 && < 5
, aeson
, bytestring
@@ -48,6 +51,7 @@ executable chusql-web
, bytestring
, chusql-engine
, chusql-web
, containers
, crypton
, directory
, filepath
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env node
// CLI 占位入口:功能尚未实现,仅提示用户。
console.log('ChuSQL CLI is not implemented yet. Coming soon.');
-95
View File
@@ -1,95 +0,0 @@
#!/usr/bin/env node
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createServer } from 'vite';
import { parseArgs } from './lib/args.js';
import {
defaultConfigPath, getAtPath, parseConfigValue, readConfig, setAtPath, unsetAtPath, writeConfig,
} from './lib/config.js';
import { openBrowser } from './lib/open.js';
// ChuSQL 前端 CLI:启动开发服务器并管理本地配置。
const cyan = (text) => `\u001b[36m${text}\u001b[0m`;
const green = (text) => `\u001b[32m${text}\u001b[0m`;
const red = (text) => `\u001b[31m${text}\u001b[0m`;
const help = `Usage: chusql <command> [options]
Commands:
web Start the web server
config list List all configuration
config get <key> Get a configuration value
config set <key> <val> Set a configuration value
config unset <key> Remove a configuration value
config path Print the configuration file path
help Show this help message
Global options:
--port <n> Specify port
--host <h> Specify host
--no-open Do not open the browser automatically
--config <path> Use a custom configuration file
--adapter <mock|rest> Specify the data source adapter
--help, -h Show help
--version, -v Show version
Run "chusql <command> --help" for command-specific options.`;
function configCommand(args, configPath) {
const config = readConfig(configPath);
const [key, rawValue] = args.operands;
if (!args.action || args.action === 'list') console.log(JSON.stringify(config, null, 2));
else if (args.action === 'path') console.log(configPath);
else if (args.action === 'get') {
if (!key) throw new Error('Usage: chusql config get <key>');
const value = getAtPath(config, key);
if (value === undefined) throw new Error(`Configuration key not found: ${key}`);
console.log(typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value));
} else if (args.action === 'set') {
if (!key || rawValue === undefined) throw new Error('Usage: chusql config set <key> <value>');
setAtPath(config, key, parseConfigValue(rawValue));
writeConfig(configPath, config);
console.log(green(`Saved ${key}.`));
} else if (args.action === 'unset') {
if (!key) throw new Error('Usage: chusql config unset <key>');
if (!unsetAtPath(config, key)) throw new Error(`Configuration key not found: ${key}`);
writeConfig(configPath, config);
console.log(green(`Removed ${key}.`));
} else throw new Error(`Unknown config command: ${args.action}`);
}
async function webCommand(args, configPath) {
const stored = readConfig(configPath);
const config = {
...stored,
...(args.port === undefined ? {} : { port: args.port }),
...(args.host === undefined ? {} : { host: args.host }),
...(args.adapter === undefined ? {} : { adapter: args.adapter }),
...(args.autoOpenBrowser === undefined ? {} : { autoOpenBrowser: args.autoOpenBrowser }),
};
if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) throw new Error('Configured port must be an integer between 1 and 65535.');
if (!['mock', 'rest'].includes(config.adapter)) throw new Error('Configured adapter must be mock or rest.');
process.env.VITE_CHUSQL_ADAPTER = config.adapter;
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const server = await createServer({ root, server: { host: config.host, port: config.port, strictPort: true } });
await server.listen();
const url = `http://${config.host}:${config.port}/`;
console.log(`${cyan('➜')} ${green('ChuSQL Web is running at:')}`);
console.log(` ${url}`);
console.log('Press Ctrl+C to stop.');
if (config.autoOpenBrowser) openBrowser(url);
}
try {
const args = parseArgs(process.argv.slice(2));
const configPath = path.resolve(args.configPath ?? defaultConfigPath());
if (args.help || args.command === 'help') console.log(help);
else if (args.command === 'version') console.log('ChuSQL 1.0.0');
else if (args.command === 'config') configCommand(args, configPath);
else if (args.command === 'web') await webCommand(args, configPath);
else throw new Error(`Unknown command: ${args.command}`);
} catch (error) {
console.error(red(`Error: ${error instanceof Error ? error.message : String(error)}`));
process.exitCode = 1;
}
-54
View File
@@ -1,54 +0,0 @@
// 命令行参数解析:命令、动作、全局选项与取值校验。
const HOST_PATTERN = /^[A-Za-z0-9.:[\]-]+$/;
function valueAfter(argv, index, label) {
const value = argv[index + 1];
if (!value || value.startsWith('--')) throw new Error(`${label} requires a value.`);
return value;
}
export function parseArgs(argv) {
const result = { command: 'help', action: '', operands: [], autoOpenBrowser: undefined };
let index = 0;
if (argv[index] && !argv[index].startsWith('-')) {
result.command = argv[index];
index += 1;
} else if (argv.length) {
result.command = argv[0] === '--version' || argv[0] === '-v' ? 'version' : 'help';
}
if (result.command === 'config' && argv[index] && !argv[index].startsWith('-')) {
result.action = argv[index];
index += 1;
}
while (index < argv.length) {
const token = argv[index];
if (token === '--help' || token === '-h') result.help = true;
else if (token === '--no-open') result.autoOpenBrowser = false;
else if (token === '--port') {
const raw = valueAfter(argv, index, 'Port');
if (!/^\d+$/.test(raw) || Number(raw) < 1 || Number(raw) > 65535) throw new Error('Port must be an integer between 1 and 65535.');
result.port = Number(raw);
index += 1;
} else if (token === '--host') {
const host = valueAfter(argv, index, 'Host');
if (!HOST_PATTERN.test(host)) throw new Error('Host contains invalid characters.');
result.host = host;
index += 1;
} else if (token === '--adapter') {
const adapter = valueAfter(argv, index, 'Adapter');
if (adapter !== 'mock' && adapter !== 'rest') throw new Error('Adapter must be mock or rest.');
result.adapter = adapter;
index += 1;
} else if (token === '--config') {
result.configPath = valueAfter(argv, index, 'Config');
index += 1;
} else if (token.startsWith('-')) {
throw new Error(`Unknown option: ${token}`);
} else {
result.operands.push(token);
}
index += 1;
}
return result;
}
-18
View File
@@ -1,18 +0,0 @@
import { describe, expect, it } from 'vitest';
import { parseArgs } from './args.js';
// 覆盖 CLI 参数解析与非法取值拒绝。
describe('parseArgs', () => {
it('parses command-specific and global options', () => {
expect(parseArgs(['web', '--port', '9000', '--no-open', '--adapter', 'rest'])).toMatchObject({
command: 'web', port: 9000, autoOpenBrowser: false, adapter: 'rest',
});
});
it('rejects unsafe or invalid values', () => {
expect(() => parseArgs(['web', '--port', 'nope'])).toThrow('Port must be an integer');
expect(() => parseArgs(['web', '--host', 'x & calc'])).toThrow('Host contains invalid characters');
expect(() => parseArgs(['web', '--adapter', 'other'])).toThrow('Adapter must be mock or rest');
});
});
-76
View File
@@ -1,76 +0,0 @@
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;
}
@@ -1,49 +0,0 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { DEFAULT_CONFIG, getAtPath, parseConfigValue, readConfig, setAtPath, unsetAtPath, writeConfig } from './config.js';
// 覆盖 CLI 配置读写、取值解析与路径安全。
const temporaryDirectories = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) fs.rmSync(directory, { recursive: true, force: true });
});
function temporaryConfig() {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'chusql-cli-'));
temporaryDirectories.push(directory);
return path.join(directory, 'nested', 'config.json');
}
describe('CLI configuration', () => {
it('creates the formatted default configuration on first read', () => {
const file = temporaryConfig();
expect(readConfig(file)).toEqual(DEFAULT_CONFIG);
expect(fs.readFileSync(file, 'utf8')).toBe(`${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`);
});
it('persists dotted paths and can remove them', () => {
const file = temporaryConfig();
const config = readConfig(file);
setAtPath(config, 'db.host', 'localhost');
writeConfig(file, config);
expect(getAtPath(readConfig(file), 'db.host')).toBe('localhost');
expect(unsetAtPath(config, 'db.host')).toBe(true);
expect(getAtPath(config, 'db.host')).toBeUndefined();
});
it('parses numbers and booleans without changing ordinary text', () => {
expect(parseConfigValue('5173')).toBe(5173);
expect(parseConfigValue('true')).toBe(true);
expect(parseConfigValue('rest')).toBe('rest');
});
it('rejects prototype-polluting configuration paths', () => {
const config = {};
expect(() => setAtPath(config, '__proto__.polluted', true)).toThrow('forbidden path segment');
expect({}.polluted).toBeUndefined();
});
});
-11
View File
@@ -1,11 +0,0 @@
import { spawn } from 'node:child_process';
// 用系统默认浏览器打开本地地址,并校验 URL 合法性。
export function openBrowser(url) {
if (!/^http:\/\/[A-Za-z0-9.:[\]-]+:\d+\/$/.test(url)) throw new Error('Refusing to open an invalid local URL.');
const command = process.platform === 'win32' ? 'cmd.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open';
const args = process.platform === 'win32' ? ['/d', '/s', '/c', 'start', '', url] : [url];
const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true });
child.unref();
}
-17
View File
@@ -1,17 +0,0 @@
<!doctype html>
<!-- ChuSQL 前端页面壳:挂载根节点并加载入口脚本。 -->
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>ChuSQL 数据库终端</title>
<link rel="stylesheet" href="/src/styles/global.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
-40
View File
@@ -1,40 +0,0 @@
{
"name": "chusql-web-ide",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "ChuSQL database Web IDE and command-line launcher",
"bin": {
"chusql": "./bin/chusql.js",
"chusql-cli": "./bin/chusql-cli.js"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run",
"test:watch": "vitest",
"check": "npm run test && npm run build"
},
"dependencies": {
"lucide-react": "0.468.0",
"monaco-editor": "0.52.2",
"react": "18.3.1",
"react-dom": "18.3.1",
"vite": "8.3.1",
"zustand": "5.0.3"
},
"devDependencies": {
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.1.0",
"@types/node": "22.20.4",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@vitejs/plugin-react": "6.1.1",
"jsdom": "25.0.1",
"typescript": "5.7.2",
"vitest": "5.0.2"
},
"engines": {
"node": ">=20.19"
}
}
-18
View File
@@ -1,18 +0,0 @@
/* 工作台布局样式:编辑器面板、标签页、底部面板与提示。 */
.editorPane { display: grid; grid-template-rows: 30px minmax(0, 1fr); height: 100%; min-width: 0; min-height: 0; overflow: hidden; }
.tabs { align-items: stretch; background: var(--bg-elevated); border-bottom: 1px solid var(--border); display: flex; overflow-x: auto; }
.tab, .tabActive { align-items: center; background: var(--bg-base); border-right: 1px solid var(--border); border-top: 1px solid transparent; display: flex; flex: 0 0 auto; }
.tab { color: var(--fg-muted); }
.tab:hover { color: var(--fg-primary); }
.tabActive { border-top-color: var(--accent); color: var(--fg-primary); }
.tabLabel { background: transparent; border: 0; color: inherit; cursor: pointer; font: inherit; max-width: 180px; overflow: hidden; padding: 0 8px 0 12px; text-overflow: ellipsis; white-space: nowrap; }
.tabs button[aria-label] { flex-shrink: 0; height: 20px; margin-right: 4px; opacity: 0; width: 20px; }
.tab:hover button[aria-label], .tabActive button[aria-label], .tabs button[aria-label]:focus-visible { opacity: 1; }
.tabs svg { flex-shrink: 0; }
.notice, .welcome { color: var(--fg-muted); padding: 24px; }
.welcome { align-items: center; display: flex; height: 100%; justify-content: center; text-align: center; }
.bottomTabs { border-bottom: 1px solid var(--border); display: flex; gap: 3px; height: 28px; }
.bottomTabs button { background: transparent; border: 0; color: var(--fg-primary); font: inherit; padding: 0 9px; }
.bottomTabs button:first-child { border-bottom: 1px solid var(--accent); }
.message { color: var(--fg-muted); padding: 8px; }
-150
View File
@@ -1,150 +0,0 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useChangeStore } from './store/changeStore';
import { App } from './App';
import { MockAdapter } from './adapters/MockAdapter';
// App 组件测试:校验行删除、编辑提交与多标签编辑区。
vi.mock('./components/sql/SqlConsole', () => ({ SqlConsole: () => <div data-testid="sql-console" /> }));
describe('App table editing flow', () => {
beforeEach(() => useChangeStore.getState().clear());
it('stages row deletion on the first click and allows undo', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
const commit = vi.spyOn(adapter, 'commitChanges');
render(<App adapter={adapter} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
fireEvent.click(await screen.findByText('User_001'));
fireEvent.click(screen.getByRole('button', { name: '删除选中行' }));
expect(useChangeStore.getState().changes.deletes.size).toBe(1);
expect(commit).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
expect(useChangeStore.getState().changes.deletes.size).toBe(0);
});
it('keeps edits local and commits the complete change set once', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
const commit = vi.spyOn(adapter, 'commitChanges');
render(<App adapter={adapter} />);
expect(screen.getByText('资源管理器')).toBeInTheDocument();
fireEvent.click(await screen.findByRole('button', { name: /打开表 users/ }));
const cell = await screen.findByText('User_001');
fireEvent.doubleClick(cell);
const editor = screen.getByRole('textbox', { name: '编辑 name' });
fireEvent.change(editor, { target: { value: 'edited' } });
fireEvent.keyDown(editor, { key: 'Enter' });
expect(screen.getByRole('button', { name: '提交 (1)' })).toBeEnabled();
expect(commit).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '提交 (1)' }));
await waitFor(() => expect(commit).toHaveBeenCalledTimes(1));
expect(commit.mock.calls[0][0].changes).toHaveLength(1);
});
});
describe('App tabs', () => {
beforeEach(() => useChangeStore.getState().clear());
it('keeps two tables open and switches back to the first one', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
expect(await screen.findByText('User_001')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '打开表 orders' }));
expect(screen.getAllByRole('tab')).toHaveLength(2);
expect(screen.getByRole('tab', { name: 'orders' })).toHaveAttribute('aria-selected', 'true');
fireEvent.click(screen.getByRole('tab', { name: 'users' }));
expect(screen.getByRole('tab', { name: 'users' })).toHaveAttribute('aria-selected', 'true');
expect(await screen.findByText('User_001')).toBeInTheDocument();
});
it('does not add a second tab for a table that is already open', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
fireEvent.click(screen.getByRole('button', { name: '打开表 users' }));
expect(screen.getAllByRole('tab')).toHaveLength(1);
expect(await screen.findByText('User_001')).toBeInTheDocument();
});
it('activates the neighbouring tab after closing the active one', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
await screen.findByText('User_001');
fireEvent.click(screen.getByRole('button', { name: '打开表 orders' }));
fireEvent.click(screen.getByRole('button', { name: '关闭 orders' }));
expect(screen.getAllByRole('tab')).toHaveLength(1);
expect(screen.queryByRole('tab', { name: 'orders' })).not.toBeInTheDocument();
expect(await screen.findByText('User_001')).toBeInTheDocument();
});
it('opens one tab for every new query', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '新建查询' }));
fireEvent.click(screen.getByRole('button', { name: '新建查询' }));
expect(screen.getAllByRole('tab')).toHaveLength(2);
expect(await screen.findByTestId('sql-console')).toBeInTheDocument();
expect(screen.getAllByTestId('sql-console')).toHaveLength(1);
});
});
describe('App pending changes', () => {
beforeEach(() => useChangeStore.getState().clear());
it('shows a staged row edit as highlighted SQL instead of JSON', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
fireEvent.doubleClick(await screen.findByText('User_001'));
const editor = screen.getByRole('textbox', { name: '编辑 name' });
fireEvent.change(editor, { target: { value: "A'B" } });
fireEvent.keyDown(editor, { key: 'Enter' });
const code = await screen.findByTitle("UPDATE users SET name = 'A''B' WHERE id = 1;");
expect(code).toHaveTextContent("UPDATE users SET name = 'A''B' WHERE id = 1;");
expect(within(code).getByText('UPDATE')).toHaveStyle({ color: '#569CD6' });
expect(within(code).getByText("'A''B'")).toHaveStyle({ color: '#CE9178' });
expect(within(code).getByText('users')).toHaveStyle({ color: 'rgb(200, 200, 200)' });
expect(screen.queryByText(/\{"id":1/)).not.toBeInTheDocument();
});
it('counts a staged index change as a pending change and previews its SQL', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
await screen.findByText('User_001');
fireEvent.click(screen.getByRole('button', { name: '管理 users 的索引' }));
const indexRows = within(screen.getByRole('dialog')).getAllByRole('listitem');
fireEvent.click(within(indexRows[2]).getByRole('button', { name: '建索引' }));
expect(await screen.findByTitle('CREATE INDEX ON users (age);')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '提交 (1)' })).toBeEnabled();
});
it('creates a staged table only when the change set is committed', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
render(<App adapter={adapter} />);
fireEvent.click(await screen.findByRole('button', { name: '新建表' }));
fireEvent.change(screen.getByLabelText('表名'), { target: { value: 'items' } });
fireEvent.change(screen.getByLabelText('列名 2'), { target: { value: 'title' } });
fireEvent.click(screen.getByRole('button', { name: '建表' }));
expect(await screen.findByTitle('CREATE TABLE items (id int, title str);')).toBeInTheDocument();
expect((await adapter.listTables('chusql')).some((table) => table.name === 'items')).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '提交 (1)' }));
await waitFor(async () => {
expect((await adapter.listTables('chusql')).some((table) => table.name === 'items')).toBe(true);
});
expect(await screen.findByRole('button', { name: '打开表 items' })).toBeInTheDocument();
});
it('selects a single row on click, toggles with Ctrl and extends with Shift', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
fireEvent.click(await screen.findByText('User_001'));
expect(screen.getByText('已选 1 行')).toBeInTheDocument();
fireEvent.click(screen.getByText('User_002'), { ctrlKey: true });
expect(screen.getByText('已选 2 行')).toBeInTheDocument();
fireEvent.click(screen.getByText('User_002'), { ctrlKey: true });
expect(screen.getByText('已选 1 行')).toBeInTheDocument();
fireEvent.click(screen.getByText('User_001'));
fireEvent.click(screen.getByText('User_004'), { shiftKey: true });
expect(screen.getByText('已选 4 行')).toBeInTheDocument();
fireEvent.click(screen.getByText('User_003'));
expect(screen.getByText('已选 1 行')).toBeInTheDocument();
});
});
-126
View File
@@ -1,126 +0,0 @@
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { MockAdapter } from './adapters/MockAdapter';
import { IconButton } from './components/common/IconButton';
import { TableEditor } from './components/grid/TableEditor';
import { BottomPanel } from './components/layout/BottomPanel';
import { Explorer } from './components/layout/Explorer';
import { Workbench } from './components/layout/Workbench';
import { SchemaDialog, type SchemaAction } from './components/layout/SchemaDialog';
import { SettingsPanel } from './components/settings/SettingsPanel';
import type { QueryHistoryEntry } from './components/sql/SqlConsole';
import { changeCount, toCommitPayload } from './core/changes';
import { closeTab, openQueryTab, openSettingsTab, openTableTab, type Tab, type TabState } from './core/tabs';
import { syncUiSettings } from './core/uiSettings';
import { zhCN } from './i18n/zh-CN';
import { useChangeStore } from './store/changeStore';
import { fontFamilyValue, useSettingsStore } from './store/settingsStore';
import type { DataSourceAdapter, SchemaChange, StatementResult, TableMeta } from './types';
import styles from './App.module.css';
// 应用根组件:装配数据源、多标签编辑区、变更提交与设置视图。
interface AppProps { adapter?: DataSourceAdapter }
const SqlConsole = lazy(async () => ({ default: (await import('./components/sql/SqlConsole')).SqlConsole }));
function initialHistory(): QueryHistoryEntry[] {
localStorage.removeItem('chusql.sql.history.v1');
try { const value = JSON.parse(localStorage.getItem('chusql.sql.history.v2') ?? '[]'); return Array.isArray(value) ? value : []; }
catch { return []; }
}
export function App({ adapter: providedAdapter }: AppProps) {
const adapter = useMemo(() => providedAdapter ?? new MockAdapter(), [providedAdapter]);
const [tables, setTables] = useState<TableMeta[]>([]);
const [tabs, setTabs] = useState<Tab[]>([]);
const [activeId, setActiveId] = useState<string>();
const [totals, setTotals] = useState<Record<string, number>>({});
const [messages, setMessages] = useState<string[]>([]);
const [results, setResults] = useState<StatementResult[]>([]);
const [history, setHistory] = useState<QueryHistoryEntry[]>(initialHistory);
const [revision, setRevision] = useState(0);
const [committing, setCommitting] = useState(false);
const [schemaAction, setSchemaAction] = useState<SchemaAction>();
const idRef = useRef(0);
const queryRef = useRef(0);
const { changes, undoStack, redoStack, undoOne, undo, redo, clear, acknowledge } = useChangeStore();
const settings = useSettingsStore((state) => state.settings);
const count = changeCount(changes);
const activeTab = tabs.find((tab) => tab.id === activeId);
const activeTableTab = activeTab?.kind === 'table' ? activeTab : undefined;
const activeMeta = activeTableTab ? tables.find((table) => table.name === activeTableTab.table) : undefined;
const pushMessage = (message: string) => setMessages((current) => [...current, message].slice(-100));
const nextTabId = () => `tab-${(idRef.current += 1)}`;
const applyTabs = (state: TabState) => { setTabs(state.tabs); setActiveId(state.activeId); };
const forgetTotal = (id: string) => setTotals((current) => { const next = { ...current }; delete next[id]; return next; });
const loadTables = async () => {
try { setTables(await adapter.listTables(zhCN.database)); }
catch (reason) { pushMessage(`${zhCN.loadError}: ${reason instanceof Error ? reason.message : String(reason)}`); }
};
useEffect(() => { void loadTables(); }, [adapter]);
useEffect(() => syncUiSettings(), []);
useEffect(() => {
const root = document.documentElement;
root.style.setProperty('--ui-font', fontFamilyValue(settings.uiFonts));
root.style.setProperty('--grid-font', fontFamilyValue(settings.gridFonts));
root.style.setProperty('--grid-font-size', `${settings.gridFontSize}px`);
root.style.setProperty('--grid-row-height', `${settings.gridRowHeight}px`);
root.style.fontSize = `${settings.uiFontSize}px`;
}, [settings]);
useEffect(() => {
const warn = (event: BeforeUnloadEvent) => { if (count) event.preventDefault(); };
window.addEventListener('beforeunload', warn);
return () => window.removeEventListener('beforeunload', warn);
}, [count]);
const rollback = () => { clear(); setRevision((value) => value + 1); };
const applySchema = async (change: SchemaChange) => {
if (change.op === 'createTable') await adapter.createTable(change.table, change.columns);
else if (change.op === 'dropTable') await adapter.dropTable(change.table);
else if (change.op === 'createIndex') await adapter.createIndex(change.table, change.column);
else await adapter.dropIndex(change.table, change.column);
};
const commit = async () => {
if (committing) return;
setCommitting(true);
try {
const rows = toCommitPayload(changes);
const schemas = Array.from(changes.schemas.values());
const creates = schemas.filter((change) => change.op === 'createTable' || change.op === 'createIndex');
const drops = schemas.filter((change) => change.op === 'dropIndex' || change.op === 'dropTable');
let applied = 0;
let failure = '';
try {
for (const change of creates) { await applySchema(change); applied += 1; }
if (rows.length) {
const result = await adapter.commitChanges({ db: zhCN.database, changes: rows });
applied += result.ok ? rows.length : result.applied;
if (!result.ok) failure = result.errors?.map((item) => item.message).join('; ') ?? '';
}
if (!failure) for (const change of drops) { await applySchema(change); applied += 1; }
} catch (reason) { failure = reason instanceof Error ? reason.message : String(reason); }
if (applied) acknowledge(changes, applied);
if (failure) pushMessage(`${zhCN.commitFailed} (${applied} applied; retry submits only the remainder): ${failure}`);
else pushMessage(zhCN.committed(applied));
await loadTables(); setRevision((value) => value + 1);
} finally { setCommitting(false); }
};
const requestCommit = () => { if (count) void commit(); };
const openTable = (name: string) => applyTabs(openTableTab(tabs, name, nextTabId()));
const newQuery = (sql?: string) => applyTabs(openQueryTab(tabs, zhCN.queryTab((queryRef.current += 1)), nextTabId(), sql));
const openSettings = () => applyTabs(openSettingsTab(tabs, zhCN.settings, nextTabId()));
const closeTabById = (tab: Tab) => {
forgetTotal(tab.id);
applyTabs(closeTab(tabs, tab.id, activeId));
};
let editor = <div className={styles.welcome}>{zhCN.emptyWorkspace}</div>;
if (tabs.length) {
let body = <div className={styles.welcome}>{zhCN.emptyWorkspace}</div>;
if (activeTab?.kind === 'sql') body = <Suspense fallback={<div className={styles.notice}>{zhCN.loading}</div>}><SqlConsole key={activeTab.id} adapter={adapter} settings={settings} initialSql={activeTab.sql} onResult={(result) => setResults((current) => [...current, result])} onMessage={pushMessage} onHistory={setHistory} /></Suspense>;
else if (activeTab?.kind === 'settings') body = <SettingsPanel />;
else if (activeTableTab && activeMeta) body = <TableEditor key={activeTableTab.id} adapter={adapter} table={activeMeta} settings={settings} revision={revision} onTotal={(value) => setTotals((current) => ({ ...current, [activeTableTab.id]: value }))} onMessage={pushMessage} />;
editor = <section className={styles.editorPane}><div className={styles.tabs} role="tablist">{tabs.map((tab) => <div key={tab.id} className={tab.id === activeId ? styles.tabActive : styles.tab}><button role="tab" aria-selected={tab.id === activeId} className={styles.tabLabel} onClick={() => setActiveId(tab.id)}>{tab.title}</button><IconButton compact icon={<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true"><path d="m4 4 8 8m0-8-8 8" /></svg>} label={zhCN.closeTab(tab.title)} onClick={() => closeTabById(tab)} /></div>)}</div>{body}</section>;
}
return <><Workbench explorer={<Explorer tables={tables} activeTable={activeMeta?.name} history={history} onOpenTable={openTable} onOpenSql={newQuery} onCreateTable={() => setSchemaAction({ kind: 'create' })} onIndexes={(table) => setSchemaAction({ kind: 'indexes', table })} onDropTable={(table) => setSchemaAction({ kind: 'drop', table })} />} editor={editor} bottom={<BottomPanel changes={changes} results={results} messages={messages} onUndoOne={undoOne} />} status={<><span>{zhCN.connection}</span><span>{zhCN.database}</span>{activeMeta && <span>{activeMeta.name}</span>}{activeTableTab && activeMeta && <span>{zhCN.rowCount(totals[activeTableTab.id] ?? 0)}</span>}<span>{zhCN.pendingCount(count)}</span></>} changeCount={count} canUndo={undoStack.length > 0} canRedo={redoStack.length > 0} onCommit={requestCommit} onRollback={rollback} onUndo={undo} onRedo={redo} onSql={() => newQuery()} onSettings={openSettings} />{schemaAction && <SchemaDialog action={schemaAction} onClose={() => setSchemaAction(undefined)} onChanged={pushMessage} />}</>;
}
@@ -1,48 +0,0 @@
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();
});
});
@@ -1,178 +0,0 @@
import { matchesFilter } from '../core/filters';
import type {
ColumnMeta, CommitChange, DataSourceAdapter, FetchRowsParams, StatementResult, TableMeta,
} from '../types';
// 内存数据源:内置 mock 表,供无后端环境演示与测试。
type TableData = { columns: ColumnMeta[]; rows: Array<Record<string, unknown>> };
function makeData(): Map<string, TableData> {
const users = Array.from({ length: 500 }, (_, index) => ({
id: index + 1,
name: `User_${String(index + 1).padStart(3, '0')}`,
age: 18 + (index % 55),
active: index % 4 !== 0,
joined_at: `202${index % 5}-${String((index % 12) + 1).padStart(2, '0')}-${String((index % 28) + 1).padStart(2, '0')}`,
}));
const orders = Array.from({ length: 500 }, (_, index) => ({
id: index + 1,
user_id: (index % 500) + 1,
product: ['Keyboard', 'Monitor', 'Database Course', 'Hard Drive'][index % 4],
amount: 49 + ((index * 37) % 2400),
paid: index % 3 !== 0,
}));
return new Map([
['users', {
columns: [
{ name: 'id', type: 'int', primaryKey: true, indexed: true },
{ name: 'name', type: 'str' },
{ name: 'age', type: 'int' },
{ name: 'active', type: 'bool' },
{ name: 'joined_at', type: 'date' },
], rows: users,
}],
['orders', {
columns: [
{ name: 'id', type: 'int', primaryKey: true, indexed: true },
{ name: 'user_id', type: 'int', indexed: true },
{ name: 'product', type: 'str' },
{ name: 'amount', type: 'int' },
{ name: 'paid', type: 'bool' },
], rows: orders,
}],
]);
}
function compareValues(left: unknown, right: unknown): number {
if (left === right) return 0;
if (left === null || left === undefined) return -1;
if (right === null || right === undefined) return 1;
if (typeof left === 'number' && typeof right === 'number') return left - right;
return String(left).localeCompare(String(right), 'zh-CN', { numeric: true });
}
export class MockAdapter implements DataSourceAdapter {
async createTable(name: string, columns: Array<Pick<ColumnMeta, 'name' | 'type'>>): Promise<void> {
await this.delay();
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || this.tables.has(name)) throw new Error('Invalid or already existing table name');
if (!columns.length || new Set(columns.map((column) => column.name)).size !== columns.length) throw new Error('Column names must be non-empty and unique');
this.tables.set(name, { columns: columns.map((column) => ({ ...column, primaryKey: column.name === 'id', indexed: column.name === 'id' })), rows: [] });
}
async dropTable(table: string): Promise<void> {
await this.delay();
if (!this.tables.delete(table)) throw new Error('Table does not exist');
}
async createIndex(table: string, column: string): Promise<void> {
await this.delay();
const found = this.tables.get(table);
const meta = found?.columns.find((item) => item.name === column);
if (!found || !meta || meta.type !== 'int' || meta.indexed) throw new Error('Only unindexed integer columns can be indexed');
if (new Set(found.rows.map((row) => row[column])).size !== found.rows.length) throw new Error('Index column contains duplicate values and must be unique');
meta.indexed = true;
}
async dropIndex(table: string, column: string): Promise<void> {
await this.delay();
const meta = this.tables.get(table)?.columns.find((item) => item.name === column);
if (!meta?.indexed || column === 'id') throw new Error('Index does not exist or is a built-in index that cannot be dropped');
meta.indexed = false;
}
private readonly tables = makeData();
private readonly latencyMs: number | [number, number];
constructor(options: { latencyMs?: number | [number, number] } = {}) {
this.latencyMs = options.latencyMs ?? [80, 200];
}
private async delay(): Promise<void> {
const milliseconds = Array.isArray(this.latencyMs)
? this.latencyMs[0] + Math.random() * (this.latencyMs[1] - this.latencyMs[0])
: this.latencyMs;
if (milliseconds > 0) await new Promise((resolve) => window.setTimeout(resolve, milliseconds));
}
async listTables(_db: string): Promise<TableMeta[]> {
await this.delay();
return Array.from(this.tables, ([name, table]) => ({ name, rowCount: table.rows.length, columns: table.columns }));
}
async getColumns(_db: string, table: string): Promise<ColumnMeta[]> {
await this.delay();
const found = this.tables.get(table);
if (!found) throw new Error(`Unknown table: ${table}`);
return found.columns.map((column) => ({ ...column }));
}
async fetchRows(params: FetchRowsParams): Promise<{ rows: Record<string, unknown>[]; total: number }> {
await this.delay();
const found = this.tables.get(params.table);
if (!found) throw new Error(`Unknown table: ${params.table}`);
let rows = found.rows.filter((row) => params.filters.every((filter) => matchesFilter(row[filter.column], filter.expression)));
if (params.search?.trim()) {
const term = params.search.toLocaleLowerCase();
rows = rows.filter((row) => Object.values(row).some((value) => String(value ?? '').toLocaleLowerCase().includes(term)));
}
if (params.sort.length) {
rows = [...rows].sort((left, right) => {
for (const sort of params.sort) {
const compared = compareValues(left[sort.column], right[sort.column]);
if (compared !== 0) return sort.dir === 'asc' ? compared : -compared;
}
return 0;
});
}
const start = params.page * params.pageSize;
return { rows: rows.slice(start, start + params.pageSize).map((row) => ({ ...row })), total: rows.length };
}
async commitChanges(payload: { db: string; changes: CommitChange[] }): Promise<{ ok: boolean; applied: number; errors?: Array<{ index: number; message: string }> }> {
await this.delay();
const working = new Map(Array.from(this.tables, ([name, table]) => [name, { columns: table.columns, rows: table.rows.map((row) => ({ ...row })) }]));
const errors: Array<{ index: number; message: string }> = [];
payload.changes.forEach((change, index) => {
const table = working.get(change.table);
if (!table) {
errors.push({ index, message: `Unknown table: ${change.table}` });
return;
}
if (change.op === 'insert') {
if (table.rows.some((row) => row.id === change.values.id)) errors.push({ index, message: `Duplicate id: ${String(change.values.id)}` });
else table.rows.push({ ...change.values });
return;
}
const rowIndex = table.rows.findIndex((row) => row.id === change.pk);
if (rowIndex < 0) {
errors.push({ index, message: `Row not found: ${String(change.pk)}` });
} else if (change.op === 'delete') {
table.rows.splice(rowIndex, 1);
} else {
table.rows[rowIndex] = { ...table.rows[rowIndex], ...change.set };
}
});
if (errors.length) return { ok: false, applied: 0, errors };
this.tables.clear();
for (const [name, table] of working) this.tables.set(name, table);
return { ok: true, applied: payload.changes.length };
}
async execStatement({ sql }: { db: string; sql: string }): Promise<StatementResult> {
const started = performance.now();
await this.delay();
const normalized = sql.trim().replace(/;$/, '');
const select = normalized.match(/^select\s+(.+?)\s+from\s+([A-Za-z_][A-Za-z0-9_]*)/is);
if (select) {
const table = this.tables.get(select[2]);
if (!table) return { type: 'error', durationMs: performance.now() - started, message: `Unknown table: ${select[2]}` };
const columns = select[1].trim() === '*' ? table.columns.map((column) => column.name) : select[1].split(',').map((item) => item.trim());
return {
type: 'rows', columns,
rows: table.rows.slice(0, 200).map((row) => columns.map((column) => row[column])),
durationMs: performance.now() - started,
};
}
if (/^(insert|update|delete|create|drop)\b/i.test(normalized)) {
return { type: 'affected', affected: 1, durationMs: performance.now() - started };
}
return { type: 'error', durationMs: performance.now() - started, message: 'MockAdapter cannot execute this statement' };
}
}
@@ -1,53 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { RestAdapter } from './RestAdapter';
// RestAdapter 测试:接口路径编码、响应归一化与错误处理。
describe('RestAdapter', () => {
afterEach(() => vi.unstubAllGlobals());
it('uses structured schema endpoints and encodes identifiers in paths', async () => {
const request = vi.fn().mockImplementation(async () => new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', request);
const adapter = new RestAdapter();
await adapter.createTable('items', [{ name: 'id', type: 'int' }]);
await adapter.createIndex('items', 'sku');
await adapter.dropIndex('items', 'sku');
await adapter.dropTable('a/b');
expect(request.mock.calls.map(([path, init]) => [path, init.method, init.body])).toEqual([
['/api/tables', 'POST', JSON.stringify({ name: 'items', columns: [{ name: 'id', type: 'int' }] })],
['/api/tables/items/indexes', 'POST', JSON.stringify({ column: 'sku' })],
['/api/tables/items/indexes/sku', 'DELETE', undefined],
['/api/tables/a%2Fb', 'DELETE', undefined],
]);
});
it('normalizes the existing Haskell catalog response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify([{
table: 'users', rowCount: 2,
columns: [{ name: 'id', type: 'int', primaryKey: true, indexed: true }],
indexes: [{ column: 'id', builtIn: true }],
}]), { status: 200, headers: { 'Content-Type': 'application/json' } })));
const tables = await new RestAdapter().listTables('chusql');
expect(tables).toEqual([{ name: 'users', rowCount: 2, columns: [{ name: 'id', type: 'int', primaryKey: true, indexed: true }] }]);
});
it('maps pages, sorting and filters to the existing rows endpoint', async () => {
const request = vi.fn().mockResolvedValue(new Response(JSON.stringify({
columns: ['id', 'name'], rows: [[1, 'Ada']], total: 1,
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
vi.stubGlobal('fetch', request);
const result = await new RestAdapter().fetchRows({
db: 'chusql', table: 'users', page: 2, pageSize: 20,
sort: [{ column: 'name', dir: 'desc' }], filters: [{ column: 'name', expression: 'Ada' }],
});
expect(result).toEqual({ rows: [{ id: 1, name: 'Ada' }], total: 1 });
expect(String(request.mock.calls[0][0])).toContain('/api/tables/users/rows?');
expect(String(request.mock.calls[0][0])).toContain('offset=40');
expect(String(request.mock.calls[0][0])).toContain('sort=name');
});
it('turns non-JSON and structured API failures into safe errors', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('<stack trace>', { status: 500 })));
await expect(new RestAdapter().listTables('chusql')).rejects.toThrow('Request failed with status 500');
});
});
@@ -1,153 +0,0 @@
import type {
ColumnMeta, CommitChange, DataSourceAdapter, FetchRowsParams, StatementResult, TableMeta,
} from '../types';
// REST 数据源:调用后端 JSON 接口并校验返回结构。
type JsonObject = Record<string, unknown>;
function isObject(value: unknown): value is JsonObject {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function asColumns(value: unknown): ColumnMeta[] {
if (!Array.isArray(value)) throw new Error('The server returned an invalid column list.');
return value.map((entry) => {
if (!isObject(entry) || typeof entry.name !== 'string' || typeof entry.type !== 'string') {
throw new Error('The server returned an invalid column description.');
}
if (!['int', 'str', 'bool', 'date'].includes(entry.type)) throw new Error(`Unsupported column type: ${entry.type}`);
return {
name: entry.name,
type: entry.type as ColumnMeta['type'],
...(typeof entry.primaryKey === 'boolean' ? { primaryKey: entry.primaryKey } : {}),
...(typeof entry.indexed === 'boolean' ? { indexed: entry.indexed } : {}),
...(typeof entry.prime === 'boolean' ? { prime: entry.prime } : {}),
...(typeof entry.distinct === 'number' ? { distinct: entry.distinct } : {}),
...(typeof entry.statsCapped === 'boolean' ? { statsCapped: entry.statsCapped } : {}),
};
});
}
function asTable(value: unknown): TableMeta {
if (!isObject(value) || typeof value.table !== 'string' || typeof value.rowCount !== 'number') {
throw new Error('The server returned an invalid table description.');
}
return { name: value.table, rowCount: value.rowCount, columns: asColumns(value.columns) };
}
async function requestJson(url: string, init?: RequestInit): Promise<unknown> {
const response = await fetch(url, { credentials: 'same-origin', ...init });
let body: unknown;
try {
body = await response.json();
} catch {
body = undefined;
}
if (!response.ok) {
const message = isObject(body) && typeof body.message === 'string'
? body.message
: `Request failed with status ${response.status}.`;
throw new Error(message);
}
return body;
}
function jsonInit(method: string, body?: unknown): RequestInit {
return {
method,
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
};
}
function changeRequest(change: CommitChange): [string, RequestInit] {
const table = encodeURIComponent(change.table);
if (change.op === 'insert') return [`/api/tables/${table}/rows`, jsonInit('POST', { values: change.values })];
const pk = encodeURIComponent(String(change.pk));
if (change.op === 'delete') return [`/api/tables/${table}/rows/${pk}`, jsonInit('DELETE')];
return [`/api/tables/${table}/rows/${pk}`, jsonInit('PATCH', { values: change.set })];
}
export class RestAdapter implements DataSourceAdapter {
async createTable(name: string, columns: Array<Pick<ColumnMeta, 'name' | 'type'>>): Promise<void> {
await requestJson('/api/tables', jsonInit('POST', { name, columns }));
}
async dropTable(table: string): Promise<void> {
await requestJson(`/api/tables/${encodeURIComponent(table)}`, jsonInit('DELETE'));
}
async createIndex(table: string, column: string): Promise<void> {
await requestJson(`/api/tables/${encodeURIComponent(table)}/indexes`, jsonInit('POST', { column }));
}
async dropIndex(table: string, column: string): Promise<void> {
await requestJson(`/api/tables/${encodeURIComponent(table)}/indexes/${encodeURIComponent(column)}`, jsonInit('DELETE'));
}
async listTables(_db: string): Promise<TableMeta[]> {
const body = await requestJson('/api/tables');
if (!Array.isArray(body)) throw new Error('The server returned an invalid table list.');
return body.map(asTable);
}
async getColumns(_db: string, table: string): Promise<ColumnMeta[]> {
return asTable(await requestJson(`/api/tables/${encodeURIComponent(table)}`)).columns;
}
async fetchRows(params: FetchRowsParams): Promise<{ rows: Record<string, unknown>[]; total: number }> {
const query = new URLSearchParams({
limit: String(params.pageSize),
offset: String(params.page * params.pageSize),
});
const firstSort = params.sort[0];
if (firstSort) {
query.set('sort', firstSort.column);
query.set('dir', firstSort.dir);
}
if (params.filters.length) {
query.set('filter', JSON.stringify(params.filters.map((filter) => ({ column: filter.column, value: filter.expression }))));
}
const body = await requestJson(`/api/tables/${encodeURIComponent(params.table)}/rows?${query}`);
if (!isObject(body) || !Array.isArray(body.columns) || !body.columns.every((column) => typeof column === 'string') || !Array.isArray(body.rows) || typeof body.total !== 'number') {
throw new Error('The server returned an invalid row page.');
}
const columns = body.columns as string[];
const rows = body.rows.map((raw) => {
if (!Array.isArray(raw)) throw new Error('The server returned an invalid row.');
return Object.fromEntries(columns.map((column, index) => [column, raw[index]]));
});
return { rows, total: body.total };
}
async commitChanges(payload: { db: string; changes: CommitChange[] }): Promise<{ ok: boolean; applied: number; errors?: Array<{ index: number; message: string }> }> {
let applied = 0;
for (const [index, change] of payload.changes.entries()) {
const [url, init] = changeRequest(change);
try {
await requestJson(url, init);
applied += 1;
} catch (error) {
return {
ok: false,
applied,
errors: [{ index, message: error instanceof Error ? error.message : String(error) }],
};
}
}
return { ok: true, applied };
}
async execStatement({ sql }: { db: string; sql: string }): Promise<StatementResult> {
const started = performance.now();
try {
const body = await requestJson('/api/query', jsonInit('POST', { sql }));
if (!isObject(body) || !Array.isArray(body.columns) || !Array.isArray(body.rows)) {
throw new Error('The server returned an invalid query result.');
}
const durationMs = performance.now() - started;
return body.columns.length
? { type: 'rows', columns: body.columns as string[], rows: body.rows as unknown[][], durationMs }
: { type: 'affected', affected: typeof body.rowCount === 'number' ? body.rowCount : 0, durationMs };
} catch (error) {
return { type: 'error', durationMs: performance.now() - started, message: error instanceof Error ? error.message : String(error) };
}
}
}
@@ -1,25 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AuthGate } from './AuthGate';
// 覆盖登录门的会话校验与登录流程。
describe('AuthGate', () => {
afterEach(() => vi.unstubAllGlobals());
it('keeps REST content private until a successful login', async () => {
const request = vi.fn()
.mockResolvedValueOnce(new Response('', { status: 401 }))
.mockResolvedValueOnce(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', request);
render(<AuthGate><p>protected content</p></AuthGate>);
expect(await screen.findByRole('heading', { name: '登录 ChuSQL' })).toBeInTheDocument();
expect(screen.queryByText('protected content')).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'chusql' } });
fireEvent.click(screen.getByRole('button', { name: '登录' }));
await waitFor(() => expect(screen.getByText('protected content')).toBeInTheDocument());
expect(request).toHaveBeenLastCalledWith('/api/login', expect.objectContaining({ method: 'POST' }));
});
});
@@ -1,56 +0,0 @@
import { type FormEvent, type ReactNode, useEffect, useState } from 'react';
import { Database } from 'lucide-react';
import { zhCN } from '../../i18n/zh-CN';
import styles from './auth.module.css';
// 登录门组件:先查会话状态,未登录时渲染登录表单。
type AuthState = 'checking' | 'signed-out' | 'signed-in';
export function AuthGate({ children }: { children: ReactNode }) {
const [state, setState] = useState<AuthState>('checking');
const [user, setUser] = useState('root');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [failed, setFailed] = useState(false);
useEffect(() => {
const controller = new AbortController();
void fetch('/api/session', { credentials: 'same-origin', signal: controller.signal })
.then((response) => setState(response.ok ? 'signed-in' : 'signed-out'))
.catch((error: unknown) => {
if (!(error instanceof DOMException && error.name === 'AbortError')) setState('signed-out');
});
return () => controller.abort();
}, []);
const submit = async (event: FormEvent) => {
event.preventDefault();
setBusy(true);
setFailed(false);
try {
const response = await fetch('/api/login', {
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user, password }),
});
if (response.ok) setState('signed-in');
else setFailed(true);
} catch {
setFailed(true);
} finally {
setBusy(false);
}
};
if (state === 'signed-in') return children;
if (state === 'checking') return <main className={styles.center}><p>{zhCN.signingIn}</p></main>;
return <main className={styles.center}>
<form className={styles.login} onSubmit={(event) => void submit(event)}>
<header><Database size={18} /><h1>{zhCN.signInTitle}</h1></header>
<label><span>{zhCN.userName}</span><input autoFocus autoComplete="username" value={user} onChange={(event) => setUser(event.target.value)} /></label>
<label><span>{zhCN.password}</span><input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} /></label>
{failed && <p role="alert">{zhCN.signInFailed}</p>}
<button disabled={busy || !user || !password}>{zhCN.signIn}</button>
</form>
</main>;
}
@@ -1,19 +0,0 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import styles from './common.module.css';
// 通用图标按钮:支持紧凑模式,标签兼作无障碍名。
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
icon: ReactNode;
label: string;
compact?: boolean;
}
export function IconButton({ icon, label, compact = false, ...props }: IconButtonProps) {
return (
<button className={`${styles.iconButton} ${compact ? styles.compact : ''}`} aria-label={label} title={label} {...props}>
{icon}
{!compact && <span>{label}</span>}
</button>
);
}
@@ -1,39 +0,0 @@
import { useEffect, useId, useRef, type ReactNode } from 'react';
import { X } from 'lucide-react';
import { zhCN } from '../../i18n/zh-CN';
import { IconButton } from './IconButton';
import styles from './common.module.css';
// 通用模态对话框:标题栏、内容区与可选页脚。
interface ModalProps {
title: string;
children: ReactNode;
footer?: ReactNode;
onClose: () => void;
}
export function Modal({ title, children, footer, onClose }: ModalProps) {
const titleId = useId();
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return undefined;
if (typeof dialog.showModal === 'function') dialog.showModal();
else dialog.setAttribute('open', '');
return () => {
if (typeof dialog.close === 'function') dialog.close();
else dialog.removeAttribute('open');
};
}, []);
return (
<dialog ref={dialogRef} aria-labelledby={titleId} className={styles.dialog} onCancel={(event) => { event.preventDefault(); onClose(); }}>
<header className={styles.dialogHeader}>
<h2 id={titleId}>{title}</h2>
<IconButton icon={<X size={14} />} label={zhCN.close} compact onClick={onClose} />
</header>
<div className={styles.dialogBody}>{children}</div>
{footer && <footer className={styles.dialogFooter}>{footer}</footer>}
</dialog>
);
}
@@ -1,11 +0,0 @@
/* 登录页样式:居中卡片与表单控件。 */
.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; }
@@ -1,41 +0,0 @@
/* 通用组件样式:图标按钮与模态对话框。 */
.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); }
@@ -1,54 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { DataGrid } from './DataGrid';
// 数据网格测试:表头排序、筛选弹层、行选中模式与列宽调整。
describe('DataGrid column controls', () => {
it('keeps row selection out of the grid columns and exposes header sorting and filtering', () => {
const onFilter = vi.fn();
const onSort = vi.fn();
const onSelectRow = vi.fn();
const onWidth = vi.fn();
render(<DataGrid
table="users"
columns={[{ name: 'id', type: 'int', primaryKey: true }, { name: 'name', type: 'str' }]}
rows={[{ id: 1, name: 'Ada' }]}
changes={{ updates: new Map(), inserts: new Map(), deletes: new Map(), schemas: new Map() }}
nullText="NULL"
filters={{}}
widths={{}}
selectedRows={new Set()}
onSort={onSort}
onFilter={onFilter}
onWidth={onWidth}
onSelectRow={onSelectRow}
onEdit={vi.fn()}
onDelete={vi.fn()}
/>);
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
expect(screen.queryByText('#')).not.toBeInTheDocument();
expect(screen.queryByRole('textbox', { name: '筛选 name' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'name' }));
expect(onSort).toHaveBeenCalledWith('name');
fireEvent.click(screen.getByRole('button', { name: '筛选 name' }));
fireEvent.change(screen.getByRole('textbox', { name: '筛选 name' }), { target: { value: 'Ada' } });
expect(onFilter).toHaveBeenCalledWith('name', 'Ada');
fireEvent.click(screen.getByText('Ada'));
expect(onSelectRow).toHaveBeenCalledWith(0, 'single');
fireEvent.click(screen.getByText('Ada'), { ctrlKey: true });
expect(onSelectRow).toHaveBeenCalledWith(0, 'toggle');
fireEvent.click(screen.getByText('Ada'), { shiftKey: true });
expect(onSelectRow).toHaveBeenCalledWith(0, 'range');
fireEvent.keyDown(screen.getByText('Ada'), { key: ' ' });
expect(onSelectRow).toHaveBeenCalledTimes(4);
fireEvent.keyDown(screen.getByRole('separator', { name: '调整 name 列宽' }), { key: 'ArrowRight' });
expect(onWidth).toHaveBeenCalledWith('name', 150);
expect(screen.getByRole('table')).toHaveStyle({ width: '280px' });
fireEvent.keyDown(screen.getByRole('textbox', { name: '筛选 name' }), { key: 'Escape' });
expect(screen.queryByRole('textbox', { name: '筛选 name' })).not.toBeInTheDocument();
});
});
@@ -1,111 +0,0 @@
import { useMemo, useState, type ClipboardEvent, type KeyboardEvent, type PointerEvent } from 'react';
import type { ChangeSet, ColumnMeta } from '../../types';
import { changeKey } from '../../core/changes';
import { zhCN } from '../../i18n/zh-CN';
import styles from './grid.module.css';
// 数据网格:列排序、筛选、列宽拖拽、行选中与单元格编辑。
export interface GridRow extends Record<string, unknown> { _tempId?: string }
export type SelectionMode = 'single' | 'toggle' | 'range';
interface DataGridProps {
table: string;
columns: ColumnMeta[];
rows: GridRow[];
changes: ChangeSet;
nullText: string;
sort?: { column: string; dir: 'asc' | 'desc' };
filters: Record<string, string>;
widths: Record<string, number>;
selectedRows: Set<string>;
onSort: (column: string) => void;
onFilter: (column: string, expression: string) => void;
onWidth: (column: string, width: number) => void;
onSelectRow: (index: number, mode: SelectionMode) => void;
onEdit: (row: GridRow, column: string, oldValue: unknown, newValue: unknown) => void;
onDelete: (row: GridRow) => void;
}
interface EditingCell { rowIndex: number; column: string; value: unknown }
// 行键:临时行用 tempId,其余用主键。
export function rowKey(row: GridRow, index: number): string { return row._tempId ?? String(row.id ?? index); }
function valueFor(row: GridRow, table: string, column: string, changes: ChangeSet): unknown {
if (row._tempId) return changes.inserts.get(row._tempId)?.values[column] ?? row[column];
return changes.updates.get(changeKey(table, row.id, column))?.newValue ?? row[column];
}
function Editor({ column, value, onChange, onKeyDown, onBlur }: { column: ColumnMeta; value: unknown; onChange: (value: unknown) => void; onKeyDown: (event: KeyboardEvent<HTMLInputElement | HTMLSelectElement>) => void; onBlur: () => void }) {
if (column.type === 'bool') return <select autoFocus aria-label={zhCN.editCell(column.name)} value={String(value)} onChange={(event) => onChange(event.target.value)} onKeyDown={onKeyDown} onBlur={onBlur}><option value="true">true</option><option value="false">false</option></select>;
return <input autoFocus aria-label={zhCN.editCell(column.name)} type={column.type === 'int' ? 'number' : column.type === 'date' ? 'date' : 'text'} value={String(value ?? '')} onChange={(event) => onChange(event.target.value)} onKeyDown={onKeyDown} onBlur={onBlur} />;
}
export function DataGrid(props: DataGridProps) {
const [editing, setEditing] = useState<EditingCell>();
const [filterColumn, setFilterColumn] = useState<string>();
const deleted = useMemo(() => new Set(Array.from(props.changes.deletes.values()).filter((item) => item.table === props.table).map((item) => String(item.pk))), [props.changes, props.table]);
const commitEdit = () => {
if (!editing) return;
const row = props.rows[editing.rowIndex];
const column = props.columns.find((item) => item.name === editing.column);
if (!row || !column) return setEditing(undefined);
let next: unknown = editing.value;
if (column.type === 'int') next = Number(editing.value);
if (column.type === 'bool') next = editing.value === true || editing.value === 'true';
props.onEdit(row, column.name, valueFor(row, props.table, column.name, props.changes), next);
setEditing(undefined);
};
const onEditorKeyDown = (event: KeyboardEvent<HTMLInputElement | HTMLSelectElement>) => {
if (event.key === 'Escape') setEditing(undefined);
if (event.key === 'Enter' || event.key === 'Tab') { event.preventDefault(); commitEdit(); }
};
const paste = (event: ClipboardEvent<HTMLTableCellElement>, rowIndex: number, columnIndex: number) => {
const matrix = event.clipboardData.getData('text/plain').replace(/\r/g, '').split('\n').filter((line) => line.length).map((line) => line.split('\t'));
if (!matrix.length) return;
event.preventDefault();
matrix.forEach((values, y) => values.forEach((value, x) => {
const row = props.rows[rowIndex + y];
const column = props.columns[columnIndex + x];
if (row && column && !column.primaryKey) props.onEdit(row, column.name, valueFor(row, props.table, column.name, props.changes), value);
}));
};
const beginResize = (event: PointerEvent<HTMLSpanElement>, column: string) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = props.widths[column] ?? 140;
const move = (next: globalThis.PointerEvent) => props.onWidth(column, Math.max(70, startWidth + next.clientX - startX));
const stop = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', stop); };
window.addEventListener('pointermove', move); window.addEventListener('pointerup', stop);
};
return (
<table className={styles.grid} style={{ width: props.columns.reduce((sum, column) => sum + (props.widths[column.name] ?? 140), 0) }}>
<colgroup>{props.columns.map((column) => <col key={column.name} style={{ width: props.widths[column.name] ?? 140 }} />)}</colgroup>
<thead>
<tr>{props.columns.map((column) => <th key={column.name} aria-sort={props.sort?.column === column.name ? props.sort.dir === 'asc' ? 'ascending' : 'descending' : 'none'}>
<div className={styles.columnHeader}><button className={styles.columnButton} aria-label={column.name} onClick={() => props.onSort(column.name)}>{column.name}<span>{props.sort?.column === column.name ? props.sort.dir === 'asc' ? '▲' : '▼' : ''}</span></button>
<button className={styles.filterButton} aria-label={zhCN.filter(column.name)} aria-expanded={filterColumn === column.name} aria-pressed={Boolean(props.filters[column.name])} onClick={() => setFilterColumn(filterColumn === column.name ? undefined : column.name)}><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true"><path d="M2 3h12L9.5 8v5l-3-1.5V8Z" /></svg></button></div>
{filterColumn === column.name && <div className={styles.filterPopover}><input autoFocus aria-label={zhCN.filter(column.name)} placeholder={zhCN.filterPlaceholder} value={props.filters[column.name] ?? ''} onChange={(event) => props.onFilter(column.name, event.target.value)} onKeyDown={(event) => { if (event.key === 'Escape' || event.key === 'Enter') { setFilterColumn(undefined); event.currentTarget.closest('th')?.querySelector('button')?.focus(); } }} /><button onClick={() => props.onFilter(column.name, '')}>{zhCN.clear}</button></div>}
<span className={styles.resizeHandle} role="separator" aria-label={zhCN.resizeColumn(column.name)} aria-orientation="vertical" tabIndex={0} onKeyDown={(event) => { if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); props.onWidth(column.name, Math.max(70, (props.widths[column.name] ?? 140) + (event.key === 'ArrowRight' ? 10 : -10))); } }} onPointerDown={(event) => beginResize(event, column.name)} />
</th>)}</tr>
</thead>
<tbody>
{props.rows.map((row, rowIndex) => {
const key = rowKey(row, rowIndex);
const isDeleted = !row._tempId && deleted.has(String(row.id));
return <tr key={key} aria-selected={props.selectedRows.has(key)} className={`${isDeleted ? styles.deleted : ''} ${row._tempId ? styles.inserted : ''}`} onContextMenu={(event) => { event.preventDefault(); props.onDelete(row); }}>
{props.columns.map((column, columnIndex) => {
const value = valueFor(row, props.table, column.name, props.changes);
const dirty = row._tempId || props.changes.updates.has(changeKey(props.table, row.id, column.name));
const isEditing = editing?.rowIndex === rowIndex && editing.column === column.name;
return <td key={column.name} className={dirty ? styles.dirty : ''} tabIndex={0} onClick={(event) => { if (!isEditing && event.detail < 2) props.onSelectRow(rowIndex, event.ctrlKey || event.metaKey ? 'toggle' : event.shiftKey ? 'range' : 'single'); }} onKeyDown={(event) => { if (!isEditing && event.key === ' ') { event.preventDefault(); props.onSelectRow(rowIndex, event.ctrlKey || event.metaKey ? 'toggle' : 'single'); } }} onPaste={(event) => paste(event, rowIndex, columnIndex)} onDoubleClick={() => !column.primaryKey && setEditing({ rowIndex, column: column.name, value })}>
{isEditing ? <Editor column={column} value={editing.value} onChange={(value) => setEditing({ ...editing, value })} onKeyDown={onEditorKeyDown} onBlur={commitEdit} /> : value === null || value === undefined ? <span className={styles.nullValue}>{props.nullText}</span> : String(value)}
</td>;
})}
</tr>;
})}
</tbody>
</table>
);
}
@@ -1,16 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, it, vi } from 'vitest';
import { RowDialog } from './RowDialog';
// 插入行对话框测试:整数校验与暂存值。
it('requires an explicit integer key and stages typed values without generating an id', () => {
const onInsert = vi.fn();
render(<RowDialog table={{ name: 'users', rowCount: 500, columns: [{ name: 'id', type: 'int', primaryKey: true }, { name: 'name', type: 'str' }, { name: 'active', type: 'bool' }] }} onClose={vi.fn()} onInsert={onInsert} />);
expect(screen.getByLabelText('id')).toHaveValue('');
fireEvent.click(screen.getByRole('button', { name: '加入待提交' }));
expect(onInsert).not.toHaveBeenCalled();
expect(screen.getByRole('alert')).toHaveTextContent('id:请输入合法整数');
fireEvent.change(screen.getByLabelText('id'), { target: { value: '800' } });
fireEvent.change(screen.getByLabelText('name'), { target: { value: "Ada's" } });
fireEvent.click(screen.getByRole('button', { name: '加入待提交' }));
expect(onInsert).toHaveBeenCalledWith({ id: 800, name: "Ada's", active: false });
});
@@ -1,26 +0,0 @@
import { useState } from 'react';
import { columnTypeLabels, zhCN } from '../../i18n/zh-CN';
import type { TableMeta } from '../../types';
import { Modal } from '../common/Modal';
import styles from './grid.module.css';
// 插入行对话框:按列类型校验并暂存待提交变更。
export function RowDialog({ table, onClose, onInsert }: { table: TableMeta; onClose: () => void; onInsert: (values: Record<string, unknown>) => void }) {
const [draft, setDraft] = useState<Record<string, string>>(() => Object.fromEntries(table.columns.map((column) => [column.name, column.type === 'bool' ? 'false' : ''])));
const [error, setError] = useState('');
const submit = () => {
const values: Record<string, unknown> = {};
for (const column of table.columns) {
const value = draft[column.name];
if (column.type === 'int' && (!/^-?\d+$/.test(value) || !Number.isSafeInteger(Number(value)))) return setError(zhCN.invalidInteger(column.name));
if (column.type === 'date' && !/^\d{4}-\d{2}-\d{2}$/.test(value)) return setError(zhCN.chooseDate(column.name));
values[column.name] = column.type === 'int' ? Number(value) : column.type === 'bool' ? value === 'true' : value;
}
onInsert(values);
};
return <Modal title={zhCN.insertRowTitle(table.name)} onClose={onClose} footer={<><button onClick={onClose}>{zhCN.cancel}</button><button className={styles.primaryButton} onClick={submit}>{zhCN.addToPending}</button></>}>
<p>{zhCN.insertRowHint}</p>
<div className={styles.formFields}>{table.columns.map((column) => <label key={column.name}><span>{column.name} <small>{columnTypeLabels[column.type] ?? column.type}{column.primaryKey ? zhCN.primaryKeyBadge : ''}</small></span>{column.type === 'bool' ? <select aria-label={column.name} value={draft[column.name]} onChange={(event) => setDraft({ ...draft, [column.name]: event.target.value })}><option value="false">{zhCN.booleanFalse}</option><option value="true">{zhCN.booleanTrue}</option></select> : <input aria-label={column.name} type={column.type === 'date' ? 'date' : 'text'} inputMode={column.type === 'int' ? 'numeric' : undefined} value={draft[column.name]} onChange={(event) => setDraft({ ...draft, [column.name]: event.target.value })} />}</label>)}</div>
{error && <p role="alert">{error}</p>}
</Modal>;
}
@@ -1,113 +0,0 @@
import { Plus, RefreshCw, Replace, Trash2 } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { DataSourceAdapter, FilterExpr, TableMeta } from '../../types';
import type { IdeSettings } from '../../store/settingsStore';
import { useChangeStore } from '../../store/changeStore';
import { zhCN } from '../../i18n/zh-CN';
import { IconButton } from '../common/IconButton';
import { DataGrid, rowKey, type GridRow, type SelectionMode } from './DataGrid';
import { RowDialog } from './RowDialog';
import styles from './grid.module.css';
// 表数据编辑器:分页加载、工具栏操作与批量删除/替换。
interface TableEditorProps { adapter: DataSourceAdapter; table: TableMeta; settings: IdeSettings; revision: number; onTotal: (count: number) => void; onMessage: (message: string) => void }
export function TableEditor({ adapter, table, settings, revision, onTotal, onMessage }: TableEditorProps) {
const [rows, setRows] = useState<GridRow[]>([]);
const [page, setPage] = useState(0);
const [total, setTotal] = useState(0);
const [sort, setSort] = useState<{ column: string; dir: 'asc' | 'desc' }>();
const [filters, setFilters] = useState<Record<string, string>>({});
const [widths, setWidths] = useState<Record<string, number>>(() => JSON.parse(localStorage.getItem(`chusql.widths.${table.name}`) ?? '{}'));
const [selectedRows, setSelectedRows] = useState(new Set<string>());
const [selectionAnchor, setSelectionAnchor] = useState<number>();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [replaceOpen, setReplaceOpen] = useState(false);
const [find, setFind] = useState('');
const [replacement, setReplacement] = useState('');
const [insertOpen, setInsertOpen] = useState(false);
const requestId = useRef(0);
const { changes, updateCell, insertRow, updateInsert, deleteRow } = useChangeStore();
const inserted = useMemo(() => Array.from(changes.inserts.values()).filter((item) => item.table === table.name).map((item): GridRow => ({ ...item.values, _tempId: item.tempId })), [changes, table.name]);
const visibleRows: GridRow[] = [...rows, ...inserted];
const activeFilters: FilterExpr[] = Object.entries(filters).filter(([, value]) => value.trim()).map(([column, expression]) => ({ column, expression }));
const load = async (nextPage: number, append: boolean) => {
const id = ++requestId.current; setLoading(true); setError('');
try {
const result = await adapter.fetchRows({ db: zhCN.database, table: table.name, page: nextPage, pageSize: settings.pageSize, sort: sort ? [sort] : [], filters: activeFilters });
if (id !== requestId.current) return;
setRows((current) => append ? [...current, ...result.rows] : result.rows);
if (!append) { setSelectedRows(new Set()); setSelectionAnchor(undefined); }
setTotal(result.total); setPage(nextPage); onTotal(result.total);
} catch (reason) { if (id === requestId.current) setError(reason instanceof Error ? reason.message : String(reason)); }
finally { if (id === requestId.current) setLoading(false); }
};
useEffect(() => { const timer = window.setTimeout(() => void load(0, false), 180); return () => window.clearTimeout(timer); }, [table.name, settings.pageSize, sort?.column, sort?.dir, JSON.stringify(filters), revision]);
const sortColumn = (column: string) => setSort((current) => current?.column !== column ? { column, dir: 'asc' } : { column, dir: current.dir === 'asc' ? 'desc' : 'asc' });
const setWidth = (column: string, width: number) => setWidths((current) => { const next = { ...current, [column]: width }; localStorage.setItem(`chusql.widths.${table.name}`, JSON.stringify(next)); return next; });
const addRow = (values: Record<string, unknown>) => {
const tempId = `temp-${crypto.randomUUID()}`;
insertRow(table.name, tempId, values);
setInsertOpen(false);
onMessage(zhCN.rowStaged);
};
const stageDelete = (row: GridRow) => {
deleteRow(table.name, row._tempId ?? row.id, row, Boolean(row._tempId));
};
const selectRow = (index: number, mode: SelectionMode) => {
const key = rowKey(visibleRows[index], index);
if (mode === 'range' && selectionAnchor !== undefined) {
const from = Math.min(selectionAnchor, index);
const to = Math.max(selectionAnchor, index);
setSelectedRows(new Set(visibleRows.slice(from, to + 1).map((row, offset) => rowKey(row, from + offset))));
return;
}
if (mode === 'toggle') {
setSelectedRows((current) => { const next = new Set(current); if (next.has(key)) next.delete(key); else next.add(key); return next; });
setSelectionAnchor(index);
return;
}
setSelectedRows(new Set([key]));
setSelectionAnchor(index);
};
const removeSelected = () => {
if (!selectedRows.size) return onMessage(zhCN.selectedRowsRequired);
visibleRows.forEach((row, index) => { if (selectedRows.has(row._tempId ?? String(row.id ?? index))) stageDelete(row); });
setSelectedRows(new Set());
setSelectionAnchor(undefined);
};
const replaceSelected = () => {
if (!selectedRows.size) return onMessage(zhCN.selectedRowsRequired);
visibleRows.forEach((row, index) => {
const key = row._tempId ?? String(row.id ?? index); if (!selectedRows.has(key)) return;
table.columns.filter((column) => !column.primaryKey && column.type === 'str').forEach((column) => {
const oldValue = row._tempId ? changes.inserts.get(row._tempId)?.values[column.name] : row[column.name];
if (String(oldValue ?? '').includes(find)) {
const next = String(oldValue ?? '').replaceAll(find, replacement);
if (row._tempId) updateInsert(row._tempId, { ...(changes.inserts.get(row._tempId)?.values ?? row), [column.name]: next });
else updateCell(table.name, row.id, column.name, row[column.name], next);
}
});
}); setReplaceOpen(false);
};
return <section className={styles.tableEditor}>
<div className={styles.toolbar}>
<IconButton icon={<RefreshCw size={14} />} label={zhCN.refresh} onClick={() => void load(0, false)} />
<IconButton icon={<Plus size={14} />} label={zhCN.insertRow} onClick={() => setInsertOpen(true)} />
<IconButton icon={<Trash2 size={14} />} label={zhCN.deleteRows} disabled={!selectedRows.size} onClick={removeSelected} />
<IconButton icon={<Replace size={14} />} label={zhCN.batchReplace} onClick={() => selectedRows.size ? setReplaceOpen(true) : onMessage(zhCN.selectedRowsRequired)} />
<span className={styles.spacer} /><span title={zhCN.selectRowsHint}>{zhCN.selectedRows(selectedRows.size)}</span><span>{zhCN.rowCount(total)}</span>
</div>
{replaceOpen && <div className={styles.replaceBar}><label>{zhCN.findText}<input value={find} onChange={(event) => setFind(event.target.value)} /></label><label>{zhCN.replaceText}<input value={replacement} onChange={(event) => setReplacement(event.target.value)} /></label><button onClick={replaceSelected}>{zhCN.replaceSelected}</button><button onClick={() => setReplaceOpen(false)}>{zhCN.close}</button></div>}
<div className={styles.viewport} onScroll={(event) => { const target = event.currentTarget; if (!loading && rows.length < total && target.scrollTop + target.clientHeight >= target.scrollHeight - 32) void load(page + 1, true); }}>
{error ? <div role="alert" className={styles.empty}>{zhCN.loadError}: {error} <button onClick={() => void load(0, false)}>{zhCN.retry}</button></div> : <DataGrid table={table.name} columns={table.columns} rows={visibleRows} changes={changes} nullText={settings.nullText} sort={sort} filters={filters} widths={widths} selectedRows={selectedRows} onSort={sortColumn} onFilter={(column, expression) => setFilters((current) => ({ ...current, [column]: expression }))} onWidth={setWidth} onSelectRow={selectRow} onEdit={(row, column, oldValue, newValue) => row._tempId ? updateInsert(row._tempId, { ...(changes.inserts.get(row._tempId)?.values ?? row), [column]: newValue }) : updateCell(table.name, row.id, column, oldValue, newValue)} onDelete={stageDelete} />}
{loading && <div className={styles.loading}>{zhCN.loading}</div>}
{!loading && !visibleRows.length && !error && <div className={styles.empty}>{zhCN.noRows}</div>}
</div>
{insertOpen && <RowDialog table={table} onClose={() => setInsertOpen(false)} onInsert={addRow} />}
</section>;
}
@@ -1,54 +0,0 @@
/* 数据网格与表结构对话框的样式 */
.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; }
@@ -1,44 +0,0 @@
import { Undo2 } from 'lucide-react';
import { useState } from 'react';
import type { ChangeSet, SchemaChange, StatementResult } from '../../types';
import { deleteSql, insertSql, schemaSql, updateSql } from '../../core/changeSql';
import { SQL_PLAIN_COLOR, SQL_TOKEN_COLORS, tokenizeSql } from '../../core/sqlHighlight';
import { zhCN } from '../../i18n/zh-CN';
import { IconButton } from '../common/IconButton';
import styles from './bottom.module.css';
// 底部面板:待提交变更、查询结果与消息三个标签页。
interface BottomPanelProps { changes: ChangeSet; results: StatementResult[]; messages: string[]; onUndoOne: (kind: keyof ChangeSet, key: string) => void }
// 结构变更的中文描述。
function schemaLabel(change: SchemaChange): string {
if (change.op === 'createTable') return zhCN.createTableChange(change.table);
if (change.op === 'dropTable') return zhCN.dropTableChange(change.table);
if (change.op === 'createIndex') return zhCN.createIndexChange(change.table, change.column);
return zhCN.dropIndexChange(change.table, change.column);
}
// 按执行器的 token 配色渲染一条 SQL。
function SqlText({ sql }: { sql: string }) {
return <>{tokenizeSql(sql).map((token, index) => <span key={index} style={{ color: token.kind === 'plain' ? SQL_PLAIN_COLOR : SQL_TOKEN_COLORS[token.kind] }}>{token.text}</span>)}</>;
}
export function BottomPanel({ changes, results, messages, onUndoOne }: BottomPanelProps) {
const [tab, setTab] = useState<'changes' | 'results' | 'messages'>('changes');
const [resultIndex, setResultIndex] = useState(0);
const entries = [
...Array.from(changes.updates, ([key, value]) => ({ kind: 'updates' as const, key, label: zhCN.updateChange(value.table, value.column), detail: updateSql(value.table, value.pk, value.column, value.newValue) })),
...Array.from(changes.inserts, ([key, value]) => ({ kind: 'inserts' as const, key, label: zhCN.insertChange(value.table), detail: insertSql(value.table, value.values) })),
...Array.from(changes.deletes, ([key, value]) => ({ kind: 'deletes' as const, key, label: zhCN.deleteChange(value.table), detail: deleteSql(value.table, value.pk) })),
...Array.from(changes.schemas, ([key, value]) => ({ kind: 'schemas' as const, key, label: schemaLabel(value), detail: schemaSql(value) })),
];
const result = results[resultIndex];
return <div className={styles.panel}>
<nav><button className={tab === 'changes' ? styles.active : ''} onClick={() => setTab('changes')}>{zhCN.pendingChanges}</button><button className={tab === 'results' ? styles.active : ''} onClick={() => setTab('results')}>{zhCN.results}</button><button className={tab === 'messages' ? styles.active : ''} onClick={() => setTab('messages')}>{zhCN.messages}</button></nav>
<div className={styles.body}>
{tab === 'changes' && (entries.length ? <ul className={styles.changes}>{entries.map((entry) => <li key={`${entry.kind}-${entry.key}`}><strong>{entry.label}</strong><code title={entry.detail}><SqlText sql={entry.detail} /></code><IconButton compact icon={<Undo2 size={13} />} label={zhCN.undoChange} onClick={() => onUndoOne(entry.kind, entry.key)} /></li>)}</ul> : <p>{zhCN.noPendingChanges}</p>)}
{tab === 'results' && (result ? <><select aria-label={zhCN.results} value={resultIndex} onChange={(event) => setResultIndex(Number(event.target.value))}>{results.map((item, index) => <option key={index} value={index}>{zhCN.resultLabel(index + 1, item.rows?.length ?? item.affected ?? 0, item.durationMs)}</option>)}</select>{result.type === 'rows' ? <div className={styles.resultScroll}><table><thead><tr>{result.columns?.map((column) => <th key={column}>{column}</th>)}</tr></thead><tbody>{result.rows?.map((row, rowIndex) => <tr key={rowIndex}>{row.map((value, columnIndex) => <td key={columnIndex}>{value === null ? zhCN.nullValue : String(value)}</td>)}</tr>)}</tbody></table></div> : <p>{result.message ?? zhCN.executionAffected(result.affected ?? 0, result.durationMs)}</p>}</> : <p>{zhCN.noResults}</p>)}
{tab === 'messages' && <ol className={styles.messages}>{messages.map((message, index) => <li key={`${index}-${message}`}>{message}</li>)}</ol>}
</div>
</div>;
}
@@ -1,23 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, it, vi } from 'vitest';
import { Explorer } from './Explorer';
// 资源管理器测试:列图标、展开收起与打开表。
it('expands columns with distinct primary key, index and ordinary column icons', () => {
const onOpenTable = vi.fn();
render(<Explorer tables={[{ name: 'users', rowCount: 1, columns: [
{ name: 'id', type: 'int', primaryKey: true },
{ name: 'email', type: 'str', indexed: true },
{ name: 'name', type: 'str' },
] }]} history={[]} onOpenTable={onOpenTable} onOpenSql={vi.fn()} />);
const expand = screen.getByRole('button', { name: '展开 users 的列' });
expect(screen.queryByText('email')).not.toBeInTheDocument();
fireEvent.click(expand);
expect(expand).toHaveAttribute('aria-expanded', 'true');
for (const name of ['主键列', '索引列', '普通列']) expect(screen.getByRole('img', { name })).toBeInTheDocument();
expect(onOpenTable).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '打开表 users' }));
expect(onOpenTable).toHaveBeenCalledWith('users');
fireEvent.click(expand);
expect(screen.queryByText('email')).not.toBeInTheDocument();
});
@@ -1,59 +0,0 @@
import { ChevronDown, Database, FileClock, GitCompareArrows, Table2, Plus, ListFilter, Trash2 } from 'lucide-react';
import { IconButton } from '../common/IconButton';
import type { ColumnMeta, TableMeta } from '../../types';
import { columnTypeLabels, zhCN } from '../../i18n/zh-CN';
import styles from './layout.module.css';
import { useState } from 'react';
// 资源管理器:库-表-列树、变更集与查询历史入口。
interface ExplorerProps {
tables: TableMeta[];
activeTable?: string;
history: Array<{ sql: string; at: string }>;
onOpenTable: (name: string) => void;
onOpenSql: (sql?: string) => void;
onCreateTable?: () => void;
onIndexes?: (table: TableMeta) => void;
onDropTable?: (table: TableMeta) => void;
}
// 列图标的可访问名,按主键/索引/普通区分。
function columnKind(column: ColumnMeta): string {
if (column.primaryKey) return zhCN.columnPrimaryKey;
return column.indexed ? zhCN.columnIndexed : zhCN.columnOrdinary;
}
export function Explorer({ tables, activeTable, history, onOpenTable, onOpenSql, onCreateTable, onIndexes, onDropTable }: ExplorerProps) {
const [expanded, setExpanded] = useState(new Set<string>());
return (
<aside className={styles.sidebar} aria-label={zhCN.explorer}>
<h2>{zhCN.explorer}</h2>
{onCreateTable && <IconButton icon={<Plus size={14} />} label={zhCN.newTable} onClick={onCreateTable} />}
<div className={styles.sectionTitle}><ChevronDown size={13} />{zhCN.databaseSection}</div>
<div className={styles.treeRoot}><Database size={14} /><span>{zhCN.database}</span></div>
<ul className={styles.tableList}>
{tables.map((table) => (
<li key={table.name}>
<div className={styles.tableEntry}>
<button className={styles.expandButton} aria-label={zhCN.expandColumns(table.name)} aria-expanded={expanded.has(table.name)} onClick={() => setExpanded((current) => { const next = new Set(current); if (next.has(table.name)) next.delete(table.name); else next.add(table.name); return next; })}><svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d={expanded.has(table.name) ? 'm4 6 4 4 4-4' : 'm6 4 4 4-4 4'} /></svg></button>
<button
className={activeTable === table.name ? styles.activeTreeItem : styles.treeItem}
aria-label={zhCN.openTable(table.name)}
onClick={() => onOpenTable(table.name)}
>
<Table2 size={13} /><span>{table.name}</span><small>{table.rowCount}</small>
</button>
{onIndexes && <IconButton compact icon={<ListFilter size={13} />} label={zhCN.manageIndexes(table.name)} onClick={() => onIndexes(table)} />}
{onDropTable && <IconButton compact icon={<Trash2 size={13} />} label={zhCN.dropTableAction(table.name)} onClick={() => onDropTable(table)} />}
</div>
{expanded.has(table.name) && <ul className={styles.columnList}>{table.columns.map((column) => <li key={column.name}><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" role="img" aria-label={columnKind(column)}><title>{columnKind(column)}</title>{column.primaryKey ? <><circle cx="5" cy="5" r="3" /><path d="m7 7 6 6m-3-3 2-2m0 4 2-2" /></> : column.indexed ? <><path d="M2 3h9v10H2ZM5 3v10M2 6h9M2 10h9m10-5 4 3-4 3" /></> : <><rect x="4" y="2" width="8" height="12" rx="1" /><path d="M4 6h8M4 10h8" /></>}</svg><span>{column.name}</span><small>{columnTypeLabels[column.type] ?? column.type}</small></li>)}</ul>}
</li>
))}
</ul>
<div className={styles.sectionTitle}><GitCompareArrows size={13} />{zhCN.changesSection}</div>
<div className={styles.sectionTitle}><FileClock size={13} />{zhCN.historySection}</div>
<button className={styles.treeItem} onClick={() => onOpenSql()}>{zhCN.newQuery}</button>
<ul className={styles.historyList}>{history.slice(0, 8).map((entry) => <li key={`${entry.at}-${entry.sql}`}><button title={entry.sql} onClick={() => onOpenSql(entry.sql)}>{entry.sql}</button></li>)}</ul>
</aside>
);
}
@@ -1,73 +0,0 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { MockAdapter } from '../../adapters/MockAdapter';
import { useChangeStore } from '../../store/changeStore';
import { SchemaDialog } from './SchemaDialog';
// 建表/删表/索引对话框测试:校验、直接响应与结构变更暂存。
afterEach(cleanup);
beforeEach(() => useChangeStore.getState().clear());
it('stages a create table change with explicit columns and rejects duplicate columns', () => {
const onChanged = vi.fn();
const onClose = vi.fn();
render(<SchemaDialog action={{ kind: 'create' }} onClose={onClose} onChanged={onChanged} />);
fireEvent.change(screen.getByLabelText('表名'), { target: { value: 'items' } });
fireEvent.change(screen.getByLabelText('列名 2'), { target: { value: 'id' } });
fireEvent.click(screen.getByRole('button', { name: '建表' }));
expect(screen.getByRole('alert')).toHaveTextContent('列名重复');
expect(useChangeStore.getState().changes.schemas.size).toBe(0);
fireEvent.change(screen.getByLabelText('列名 2'), { target: { value: 'title' } });
fireEvent.click(screen.getByRole('button', { name: '建表' }));
expect(onChanged).toHaveBeenCalledWith('已暂存:CREATE TABLE items (id int, title str);');
expect(onClose).toHaveBeenCalled();
expect([...useChangeStore.getState().changes.schemas.values()]).toEqual([
{ op: 'createTable', table: 'items', columns: [{ name: 'id', type: 'int' }, { name: 'title', type: 'str' }] },
]);
});
it('stages a drop table change on the first click without asking for the name', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
const table = (await adapter.listTables('chusql'))[0];
const onChanged = vi.fn();
render(<SchemaDialog action={{ kind: 'drop', table }} onClose={vi.fn()} onChanged={onChanged} />);
expect(screen.queryByLabelText(/输入/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '永久删除表' }));
expect(onChanged).toHaveBeenCalledWith('已暂存:DROP TABLE users;');
expect([...useChangeStore.getState().changes.schemas.values()]).toEqual([{ op: 'dropTable', table: 'users' }]);
expect((await adapter.listTables('chusql')).some((item) => item.name === 'users')).toBe(true);
});
it('protects the built-in primary key index and non-integer columns', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
const table = (await adapter.listTables('chusql'))[0];
render(<SchemaDialog action={{ kind: 'indexes', table }} onClose={vi.fn()} onChanged={vi.fn()} />);
const rows = screen.getAllByRole('listitem');
expect(within(rows[0]).getByRole('button')).toBeDisabled();
expect(within(rows[1]).getByRole('button')).toBeDisabled();
expect(within(rows[2]).getByRole('button')).toBeEnabled();
});
it('stages index creation and deletion on the first click and reflects the pending state', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
await adapter.createTable('items', [{ name: 'id', type: 'int' }, { name: 'sku', type: 'int' }]);
const table = (await adapter.listTables('chusql')).find((item) => item.name === 'items')!;
const onChanged = vi.fn();
render(<SchemaDialog action={{ kind: 'indexes', table }} onClose={vi.fn()} onChanged={onChanged} />);
expect(screen.getByText('无索引')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '建索引' }));
expect(screen.getByText('唯一索引')).toBeInTheDocument();
expect(onChanged).toHaveBeenCalledWith('已暂存:CREATE INDEX ON items (sku);');
fireEvent.click(within(screen.getAllByRole('listitem')[1]).getByRole('button', { name: '删索引' }));
expect(screen.getByText('无索引')).toBeInTheDocument();
expect([...useChangeStore.getState().changes.schemas.keys()]).toEqual(['createIndex:items:sku', 'dropIndex:items:sku']);
});
it('leaves nothing staged when the create dialog is cancelled', () => {
const onClose = vi.fn();
render(<SchemaDialog action={{ kind: 'create' }} onClose={onClose} onChanged={vi.fn()} />);
fireEvent.change(screen.getByLabelText('表名'), { target: { value: 'items' } });
fireEvent.click(screen.getByRole('button', { name: '取消' }));
expect(onClose).toHaveBeenCalled();
expect(useChangeStore.getState().changes.schemas.size).toBe(0);
});
@@ -1,69 +0,0 @@
import { useState } from 'react';
import { schemaSql } from '../../core/changeSql';
import { columnTypeLabels, zhCN } from '../../i18n/zh-CN';
import { useChangeStore } from '../../store/changeStore';
import type { ColumnMeta, SchemaChange, TableMeta } from '../../types';
import { Modal } from '../common/Modal';
import styles from '../grid/grid.module.css';
// 建表、删表与索引对话框:结构变更一律暂存,提交时才下发。
export type SchemaAction = { kind: 'create' } | { kind: 'drop' | 'indexes'; table: TableMeta };
const identifier = /^[A-Za-z_][A-Za-z0-9_]*$/;
// 列类型的中文名,未知类型原样显示。
function typeLabel(type: string): string {
return columnTypeLabels[type] ?? type;
}
export function SchemaDialog({ action, onClose, onChanged }: {
action: SchemaAction; onClose: () => void; onChanged: (message: string) => void;
}) {
const [name, setName] = useState('');
const [columns, setColumns] = useState<Array<Pick<ColumnMeta, 'name' | 'type'>>>([{ name: 'id', type: 'int' }, { name: '', type: 'str' }]);
const [error, setError] = useState('');
const stageSchema = useChangeStore((state) => state.stageSchema);
const schemas = useChangeStore((state) => state.changes.schemas);
const target = action.kind === 'create' ? undefined : action.table;
const stagedIndex = new Map<string, boolean>();
for (const change of schemas.values()) {
if (change.table !== target?.name) continue;
if (change.op === 'createIndex') stagedIndex.set(change.column, true);
else if (change.op === 'dropIndex') stagedIndex.set(change.column, false);
}
const stage = (change: SchemaChange) => { stageSchema(change); onChanged(zhCN.stagedMessage(schemaSql(change))); };
const create = () => {
if (!identifier.test(name) || columns.some((column) => !identifier.test(column.name))) return setError(zhCN.invalidIdentifier);
if (new Set(columns.map((column) => column.name)).size !== columns.length) return setError(zhCN.duplicateColumns);
stage({ op: 'createTable', table: name, columns });
onClose();
};
const drop = () => {
if (!target) return;
stage({ op: 'dropTable', table: target.name });
onClose();
};
const changeIndex = (column: string, indexed: boolean) => {
if (!target) return;
stage(indexed
? { op: 'dropIndex', table: target.name, column }
: { op: 'createIndex', table: target.name, column });
};
const title = action.kind === 'create' ? zhCN.newTableTitle : `${action.kind === 'drop' ? zhCN.dropTableTitle : zhCN.indexManagerTitle} · ${target?.name}`;
return <Modal title={title} onClose={onClose} footer={<><button onClick={onClose}>{action.kind === 'indexes' ? zhCN.done : zhCN.cancel}</button>{action.kind === 'create' && <button className={styles.primaryButton} onClick={create}>{zhCN.createTable}</button>}{action.kind === 'drop' && <button className={styles.dangerButton} onClick={drop}>{zhCN.dropTablePermanently}</button>}</>}>
{action.kind === 'create' ? <div className={styles.formFields}>
<label>{zhCN.tableName}<input autoFocus aria-label={zhCN.tableName} value={name} onChange={(event) => setName(event.target.value)} /></label>
<p>{zhCN.createTableHint}</p>
{columns.map((column, index) => <div className={styles.columnDefinition} key={index}><input aria-label={zhCN.columnName(index + 1)} value={column.name} disabled={index === 0} onChange={(event) => setColumns(columns.map((item, i) => i === index ? { ...item, name: event.target.value } : item))} /><select aria-label={zhCN.columnType(index + 1)} value={column.type} disabled={index === 0} onChange={(event) => setColumns(columns.map((item, i) => i === index ? { ...item, type: event.target.value as ColumnMeta['type'] } : item))}><option value="int">{zhCN.typeInt}</option><option value="str">{zhCN.typeStr}</option><option value="bool">{zhCN.typeBool}</option></select><button disabled={index === 0} aria-label={zhCN.removeColumn(index + 1)} onClick={() => setColumns(columns.filter((_, i) => i !== index))}>{zhCN.remove}</button></div>)}
<button onClick={() => setColumns([...columns, { name: '', type: 'str' }])}>{zhCN.addColumn}</button>
</div> : action.kind === 'drop' ? <>
<p>{zhCN.dropTableHint(target?.name ?? '', target?.rowCount ?? 0)}</p>
</> : <>
<p>{zhCN.indexHint}</p>
<ul className={styles.indexList}>{target?.columns.map((column) => {
const indexed = stagedIndex.get(column.name) ?? Boolean(column.indexed);
return <li key={column.name}><span>{column.name} <small>{typeLabel(column.type)}</small></span><span>{column.primaryKey || column.name === 'id' ? zhCN.builtinIndex : indexed ? zhCN.uniqueIndex : zhCN.noIndex}</span><button disabled={column.name === 'id' || (!indexed && column.type !== 'int')} onClick={() => changeIndex(column.name, indexed)}>{indexed ? zhCN.deleteIndex : zhCN.createIndex}</button></li>;
})}</ul>
</>}
{error && <p role="alert">{error}</p>}
</Modal>;
}
@@ -1,149 +0,0 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { zhCN } from '../../i18n/zh-CN';
import { Workbench } from './Workbench';
// 工作台布局测试:拖动、方向键、夹紧范围与本地存储持久化。
// 用最简桩节点渲染工作台
function renderWorkbench() {
render(<Workbench explorer={<div />} editor={<div />} bottom={<div />} status={<div />} changeCount={0} canUndo={false} canRedo={false} onCommit={vi.fn()} onRollback={vi.fn()} onUndo={vi.fn()} onRedo={vi.fn()} onSql={vi.fn()} onSettings={vi.fn()} />);
}
// 取侧边栏与船坞两条拖动条
function splitters() {
return { sidebar: screen.getByRole('separator', { name: zhCN.resizeSidebar }), dock: screen.getByRole('separator', { name: zhCN.resizeDock }) };
}
// 取底部船坞默认高度
function defaultDock() { return Math.round(window.innerHeight * 0.22); }
// 取底部船坞高度上限
function dockCeiling() { return Math.max(96, Math.round(window.innerHeight * 0.7)); }
beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear());
it('resizes the sidebar and the dock by 16px with arrow keys', () => {
renderWorkbench();
const { sidebar, dock } = splitters();
expect(sidebar).toHaveAttribute('aria-valuenow', '250');
fireEvent.keyDown(sidebar, { key: 'ArrowRight' });
expect(sidebar).toHaveAttribute('aria-valuenow', '266');
fireEvent.keyDown(sidebar, { key: 'ArrowLeft' });
expect(sidebar).toHaveAttribute('aria-valuenow', '250');
expect(dock).toHaveAttribute('aria-valuenow', String(defaultDock()));
fireEvent.keyDown(dock, { key: 'ArrowUp' });
expect(dock).toHaveAttribute('aria-valuenow', String(defaultDock() + 16));
fireEvent.keyDown(dock, { key: 'ArrowDown' });
expect(dock).toHaveAttribute('aria-valuenow', String(defaultDock()));
});
it('clamps the sidebar to 160..560 and the dock to 96 and above', () => {
renderWorkbench();
const { sidebar, dock } = splitters();
for (let i = 0; i < 30; i += 1) fireEvent.keyDown(sidebar, { key: 'ArrowLeft' });
expect(sidebar).toHaveAttribute('aria-valuenow', '160');
fireEvent.keyDown(sidebar, { key: 'ArrowLeft' });
expect(sidebar).toHaveAttribute('aria-valuenow', '160');
for (let i = 0; i < 40; i += 1) fireEvent.keyDown(sidebar, { key: 'ArrowRight' });
expect(sidebar).toHaveAttribute('aria-valuenow', '560');
fireEvent.keyDown(sidebar, { key: 'ArrowRight' });
expect(sidebar).toHaveAttribute('aria-valuenow', '560');
for (let i = 0; i < 30; i += 1) fireEvent.keyDown(dock, { key: 'ArrowDown' });
expect(dock).toHaveAttribute('aria-valuenow', '96');
fireEvent.keyDown(dock, { key: 'ArrowDown' });
expect(dock).toHaveAttribute('aria-valuenow', '96');
for (let i = 0; i < 60; i += 1) fireEvent.keyDown(dock, { key: 'ArrowUp' });
expect(dock).toHaveAttribute('aria-valuenow', String(dockCeiling()));
});
it('exposes separator roles with orientation, value and bounds', () => {
renderWorkbench();
const { sidebar, dock } = splitters();
expect(sidebar).toHaveAttribute('aria-orientation', 'vertical');
expect(sidebar).toHaveAttribute('aria-valuemin', '160');
expect(sidebar).toHaveAttribute('aria-valuemax', '560');
expect(sidebar).toHaveAttribute('tabindex', '0');
expect(dock).toHaveAttribute('aria-orientation', 'horizontal');
expect(dock).toHaveAttribute('aria-valuemin', '96');
expect(dock).toHaveAttribute('aria-valuemax', String(dockCeiling()));
expect(dock).toHaveAttribute('tabindex', '0');
});
it('stores changed sizes in localStorage under chusql.layout.v1', () => {
renderWorkbench();
const { sidebar, dock } = splitters();
fireEvent.keyDown(sidebar, { key: 'ArrowRight' });
fireEvent.keyDown(dock, { key: 'ArrowUp' });
expect(JSON.parse(localStorage.getItem('chusql.layout.v1') ?? '{}')).toEqual({ sidebarWidth: 266, dockHeight: defaultDock() + 16 });
});
it('restores saved sizes from localStorage on the first render', () => {
localStorage.setItem('chusql.layout.v1', JSON.stringify({ sidebarWidth: 300, dockHeight: 150 }));
renderWorkbench();
const { sidebar, dock } = splitters();
expect(sidebar).toHaveAttribute('aria-valuenow', '300');
expect(dock).toHaveAttribute('aria-valuenow', '150');
});
it('clamps saved sizes that fall outside the allowed range', () => {
localStorage.setItem('chusql.layout.v1', JSON.stringify({ sidebarWidth: 9999, dockHeight: 1 }));
renderWorkbench();
const { sidebar, dock } = splitters();
expect(sidebar).toHaveAttribute('aria-valuenow', '560');
expect(dock).toHaveAttribute('aria-valuenow', '96');
});
it('falls back to default sizes when the stored layout is unreadable', () => {
localStorage.setItem('chusql.layout.v1', '{not json');
renderWorkbench();
const { sidebar, dock } = splitters();
expect(sidebar).toHaveAttribute('aria-valuenow', '250');
expect(dock).toHaveAttribute('aria-valuenow', String(defaultDock()));
});
it('widens the sidebar by 40px when the vertical splitter is dragged right', () => {
renderWorkbench();
const { sidebar } = splitters();
act(() => {
sidebar.dispatchEvent(new MouseEvent('pointerdown', { clientX: 300, bubbles: true }));
});
act(() => {
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 340, bubbles: true }));
});
act(() => {
window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }));
});
expect(sidebar).toHaveAttribute('aria-valuenow', '290');
});
it('makes the dock taller by 40px when the horizontal splitter is dragged up', () => {
renderWorkbench();
const { dock } = splitters();
act(() => {
dock.dispatchEvent(new MouseEvent('pointerdown', { clientY: 500, bubbles: true }));
});
act(() => {
window.dispatchEvent(new MouseEvent('pointermove', { clientY: 460, bubbles: true }));
});
act(() => {
window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }));
});
expect(dock).toHaveAttribute('aria-valuenow', String(defaultDock() + 40));
});
it('stops resizing after the pointer is released', () => {
renderWorkbench();
const { sidebar } = splitters();
act(() => {
sidebar.dispatchEvent(new MouseEvent('pointerdown', { clientX: 300, bubbles: true }));
});
act(() => {
window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }));
});
act(() => {
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 400, bubbles: true }));
});
expect(sidebar).toHaveAttribute('aria-valuenow', '250');
});
@@ -1,121 +0,0 @@
import { Database, RotateCcw, Settings, TerminalSquare, Undo2 } from 'lucide-react';
import { useEffect, useState, type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react';
import { IconButton } from '../common/IconButton';
import { zhCN } from '../../i18n/zh-CN';
import styles from './layout.module.css';
// 工作台外壳:标题栏、活动栏与编辑器/底栏/状态栏布局,面板边界可拖动。
interface WorkbenchProps {
explorer: ReactNode;
editor: ReactNode;
bottom: ReactNode;
status: ReactNode;
changeCount: number;
canUndo: boolean;
canRedo: boolean;
onCommit: () => void;
onRollback: () => void;
onUndo: () => void;
onRedo: () => void;
onSql: () => void;
onSettings: () => void;
}
type SplitterKind = 'sidebar' | 'dock';
interface LayoutSize { sidebar: number; dock: number }
interface DragState { kind: SplitterKind; start: number; size: number }
const STORAGE_KEY = 'chusql.layout.v1';
const SIDEBAR_MIN = 160;
const SIDEBAR_MAX = 560;
const DOCK_MIN = 96;
const KEY_STEP = 16;
// 底部船坞高度上限取窗口高度的七成
function dockMax() { return Math.max(DOCK_MIN, Math.round(window.innerHeight * 0.7)); }
// 把尺寸夹紧到允许区间并取整
function clampSize(kind: SplitterKind, value: number) {
const rounded = Math.round(value);
return kind === 'sidebar' ? Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, rounded)) : Math.min(dockMax(), Math.max(DOCK_MIN, rounded));
}
// 读取本地存储的面板尺寸,异常时用默认值
function readLayout(): LayoutSize {
const fallback = { sidebar: 250, dock: Math.round(window.innerHeight * 0.22) };
try {
const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '') as { sidebarWidth?: unknown; dockHeight?: unknown };
if (typeof raw.sidebarWidth !== 'number' || typeof raw.dockHeight !== 'number') return fallback;
return { sidebar: clampSize('sidebar', raw.sidebarWidth), dock: clampSize('dock', raw.dockHeight) };
} catch { return fallback; }
}
// 写入面板尺寸到本地存储,失败时静默忽略
function writeLayout(size: LayoutSize) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ sidebarWidth: size.sidebar, dockHeight: size.dock })); } catch { return; }
}
export function Workbench(props: WorkbenchProps) {
const [layout, setLayout] = useState<LayoutSize>(readLayout);
const [drag, setDrag] = useState<DragState>();
const { sidebar, dock } = layout;
useEffect(() => { writeLayout(layout); }, [layout]);
useEffect(() => {
if (!drag) return;
const move = (event: globalThis.PointerEvent) => setLayout((current) => {
const delta = drag.kind === 'sidebar' ? event.clientX - drag.start : drag.start - event.clientY;
const next = drag.size + delta;
return drag.kind === 'sidebar' ? { ...current, sidebar: clampSize('sidebar', next) } : { ...current, dock: clampSize('dock', next) };
});
const stop = () => setDrag(undefined);
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', stop);
return () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', stop); };
}, [drag]);
const resize = (kind: SplitterKind, value: number) => setLayout((current) => (kind === 'sidebar' ? { ...current, sidebar: clampSize('sidebar', value) } : { ...current, dock: clampSize('dock', value) }));
const startDrag = (kind: SplitterKind, event: PointerEvent<HTMLButtonElement>) => {
event.preventDefault();
setDrag({ kind, start: kind === 'sidebar' ? event.clientX : event.clientY, size: kind === 'sidebar' ? sidebar : dock });
};
const onSplitterKeyDown = (kind: SplitterKind, event: KeyboardEvent<HTMLButtonElement>) => {
const step = kind === 'sidebar'
? (event.key === 'ArrowRight' ? KEY_STEP : event.key === 'ArrowLeft' ? -KEY_STEP : 0)
: (event.key === 'ArrowUp' ? KEY_STEP : event.key === 'ArrowDown' ? -KEY_STEP : 0);
if (!step) return;
event.preventDefault();
resize(kind, (kind === 'sidebar' ? sidebar : dock) + step);
};
return (
<div className={styles.workbench} style={{ '--sidebar-width': `${sidebar}px`, '--dock-height': `${dock}px` } as CSSProperties}>
<header className={styles.titlebar}>
<span className={styles.brand}><Database size={15} />{zhCN.connection}</span>
<nav>
<IconButton icon={<Database size={14} />} label={zhCN.commit(props.changeCount)} disabled={!props.changeCount} onClick={props.onCommit} />
<IconButton icon={<RotateCcw size={14} />} label={zhCN.rollback} disabled={!props.changeCount} onClick={props.onRollback} />
<IconButton icon={<Undo2 size={14} />} label={zhCN.undo} disabled={!props.canUndo} onClick={props.onUndo} />
<IconButton icon={<Undo2 size={14} transform="rotate(180)" />} label={zhCN.redo} disabled={!props.canRedo} onClick={props.onRedo} />
<IconButton icon={<Settings size={14} />} label={zhCN.settings} onClick={props.onSettings} />
</nav>
</header>
<div className={styles.activitybar} aria-label={zhCN.activityBar}><button aria-label={zhCN.databaseSection}><Database size={20} /></button><button aria-label={zhCN.sqlConsole} onClick={props.onSql}><TerminalSquare size={20} /></button><button aria-label={zhCN.settings} onClick={props.onSettings}><Settings size={20} /></button><GitMark /></div>
{props.explorer}
<main className={styles.editor}>{props.editor}</main>
<section className={styles.bottom}>{props.bottom}</section>
<footer className={styles.statusbar}>{props.status}</footer>
<button type="button" className={`${styles.splitterV}${drag?.kind === 'sidebar' ? ` ${styles.dragging}` : ''}`} role="separator" aria-orientation="vertical" aria-label={zhCN.resizeSidebar} aria-valuenow={sidebar} aria-valuemin={SIDEBAR_MIN} aria-valuemax={SIDEBAR_MAX} tabIndex={0} onPointerDown={(event) => startDrag('sidebar', event)} onKeyDown={(event) => onSplitterKeyDown('sidebar', event)} />
<button type="button" className={`${styles.splitterH}${drag?.kind === 'dock' ? ` ${styles.dragging}` : ''}`} role="separator" aria-orientation="horizontal" aria-label={zhCN.resizeDock} aria-valuenow={dock} aria-valuemin={DOCK_MIN} aria-valuemax={dockMax()} tabIndex={0} onPointerDown={(event) => startDrag('dock', event)} onKeyDown={(event) => onSplitterKeyDown('dock', event)} />
</div>
);
}
function GitMark() {
return <span aria-hidden="true">⌘</span>;
}
@@ -1,15 +0,0 @@
/* 底部面板:标签页、变更列表与结果表格样式 */
.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; }
@@ -1,43 +0,0 @@
/* 工作台整体网格布局与资源管理器样式 */
.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); }
@@ -1,24 +0,0 @@
import { GripVertical, Plus, Trash2 } from 'lucide-react';
import { useState, type CSSProperties, type DragEvent, type ReactNode } from 'react';
import { zhCN } from '../../i18n/zh-CN';
import { IconButton } from '../common/IconButton';
import styles from './settings.module.css';
// 字体链编辑器:增删、拖动排序并实时预览。
interface FontChainEditorProps { label: string; value: string[]; onChange: (fonts: string[]) => void; previewStyle: CSSProperties; children?: ReactNode }
export function FontChainEditor({ label, value, onChange, previewStyle, children }: FontChainEditorProps) {
const [dragged, setDragged] = useState<number>();
const move = (event: DragEvent, target: number) => {
event.preventDefault();
if (dragged === undefined || dragged === target) return;
const next = [...value]; const [font] = next.splice(dragged, 1); next.splice(target, 0, font); onChange(next); setDragged(target);
};
return <fieldset className={styles.fontGroup}><legend>{label}</legend>
<div className={styles.fontList}>{value.map((font, index) => <div className={styles.fontRow} key={index} onDragOver={(event) => move(event, index)}><span draggable onDragStart={() => setDragged(index)} onDragEnd={() => setDragged(undefined)} title={zhCN.moveFont}><GripVertical size={13} /></span><input aria-label={`${label} ${zhCN.fontName} ${index + 1}`} value={font} onChange={(event) => onChange(value.map((item, itemIndex) => itemIndex === index ? event.target.value : item))} /><IconButton icon={<Trash2 size={13} />} compact label={zhCN.removeFont(font)} disabled={value.length === 1} onClick={() => onChange(value.filter((_, itemIndex) => itemIndex !== index))} /></div>)}</div>
<IconButton icon={<Plus size={13} />} label={zhCN.addFallback} onClick={() => onChange([...value, ''])} />
{children}
<div className={styles.preview} style={previewStyle}><strong>{zhCN.preview}</strong><span>{zhCN.previewText}</span></div>
</fieldset>;
}
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff. Show more