大更新:
1、完善SQL语法 2、移除node前端,只保留静态资源 3、写好安装脚本 4、做好linux适配
This commit is contained in:
1 parent
687c012841
commit
df6d348995
166 files changed
+16127
-10573
No files matched your search
@@ -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
|
||||||
|
|
||||||
@@ -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
@@ -2,7 +2,7 @@
|
|||||||
# ChuSQL 单仓库 .gitignore
|
# ChuSQL 单仓库 .gitignore
|
||||||
# chusql-engine/ Haskell (Stack + Cabal)
|
# chusql-engine/ Haskell (Stack + Cabal)
|
||||||
# chusql-storage/ Rust (Cargo)
|
# chusql-storage/ Rust (Cargo)
|
||||||
# chusql-web/ 前端 (Node / TypeScript)
|
# chusql-web/ 前端 (手写 HTML / CSS / JS,无 Node 工具链)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
@@ -38,33 +38,19 @@ target/
|
|||||||
# 运行时数据(Rust 服务默认数据目录 CHUSQL_DATA_DIR=data/),不入库
|
# 运行时数据(Rust 服务默认数据目录 CHUSQL_DATA_DIR=data/),不入库
|
||||||
chusql-storage/data/
|
chusql-storage/data/
|
||||||
|
|
||||||
# 一键启动脚本(script/chusql.ps1)的唯一默认数据目录与日志,不入库
|
# 一键启动脚本的默认数据目录与日志,不入库
|
||||||
localdata/
|
localdata/
|
||||||
|
|
||||||
# 启动器保存的设置(script/chusql.ps1 config set ... 写这里),本机专用,不入库
|
# 前端 IDE 设置(字体等,由 /api/ui-settings 读写),本机专用,不入库
|
||||||
script/chusql.settings.json
|
scripts/chusql.ui.settings.json
|
||||||
|
|
||||||
# 前端 IDE 设置(字体等,由 /api/ui-settings 读写),同样本机专用
|
|
||||||
script/chusql.ui.settings.json
|
|
||||||
|
|
||||||
# Cargo.lock 要入库(本项目是要产出可执行程序的 crate,不是纯库)
|
# Cargo.lock 要入库(本项目是要产出可执行程序的 crate,不是纯库)
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# 前端 / Node —— chusql-web
|
# 前端 —— chusql-web/static 是手写的源文件,必须入库
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
node_modules/
|
# 这里曾经放着 node_modules / .vite / *.tsbuildinfo 等 Node 工具链规则;
|
||||||
.pnpm-store/
|
# 前端已改为纯 HTML + CSS + JS 静态资源,不再需要它们。
|
||||||
build/
|
|
||||||
.next/
|
|
||||||
.nuxt/
|
|
||||||
.vite/
|
|
||||||
.npm-cache/
|
|
||||||
.angular/
|
|
||||||
coverage/
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# 前端构建产物:vite outDir 指向 ../static,由 npm run build 生成,不入库
|
|
||||||
chusql-web/static/
|
|
||||||
|
|
||||||
# 环境变量:只提交 .env.example,本地实际配置不入库
|
# 环境变量:只提交 .env.example,本地实际配置不入库
|
||||||
.env
|
.env
|
||||||
@@ -87,10 +73,16 @@ chusql-web/static/
|
|||||||
*~
|
*~
|
||||||
\#*\#
|
\#*\#
|
||||||
.\#*
|
.\#*
|
||||||
|
agent_credientials
|
||||||
|
|
||||||
# 注意:.zed/tasks.json 是本仓库共享的构建任务(stack build / run / test / repl),
|
# 注意:.zed/tasks.json 是本仓库共享的构建任务(stack build / run / test / repl),
|
||||||
# 需要入库,所以这里不忽略 .zed/
|
# 需要入库,所以这里不忽略 .zed/
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# 项目计划 / 提示词 / 设计决策 / 已知问题(本机文档,不入库)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
plandocs/
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# 操作系统
|
# 操作系统
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
|
|||||||
@@ -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` 的 ✅。
|
## 当前重点
|
||||||
|
- 完善 WAL 重放幂等。
|
||||||
| # | 约束 | 当前值 | 校验方式 |
|
- 完整事务路线见 project-plan.txt 阶段 12。
|
||||||
|---|---|---|---|
|
|
||||||
| 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(复合键 + 字节键 → 非唯一索引)。
|
|
||||||
@@ -2,53 +2,44 @@
|
|||||||
|
|
||||||
本项目是一个简单的数据库项目,服务端采用 Haskell 和 Rust 联合编写,采取 B/S 模式,管理客户端运行在浏览器网页上。
|
本项目是一个简单的数据库项目,服务端采用 Haskell 和 Rust 联合编写,采取 B/S 模式,管理客户端运行在浏览器网页上。
|
||||||
|
|
||||||
## 仓库布局
|
优点:
|
||||||
|
|
||||||
| 目录 | 语言 | 组件 |
|
- 轻量:两个可执行文件加一份静态资源,解包即用,没有 Node / Python 之类的运行时依赖
|
||||||
| ----------------- | -------------------------- | ----------------------------------------------------------------- |
|
- 部署容易:Linux 与 macOS 一条命令装完并启动,Windows 解压后跑一次安装脚本
|
||||||
| `chusql-engine/` | Haskell | 执行引擎:词法分析与AST、表达式求值、关系代数、内存表、服务端接口 |
|
- 配置统一:一个 `chusql.toml` 同时管住存储、Web 与命令行
|
||||||
| `chusql-storage/` | Rust | 存储引擎:磁盘IO、B+树、缓冲池、WAL、崩溃恢复、并发控制 |
|
- 账号统一:Web 管理端与命令行共用同一个管理员和同一张账号表
|
||||||
| `chusql-web/` | Haskell + React/TypeScript | Web 管理端:Scotty REST API + VS Code Dark+ 风格数据库 Web IDE |
|
|
||||||
| `script/` | PowerShell | `chusql.ps1` / `chusql.cmd`:一键启动 / 子命令 / 设置管理 |
|
|
||||||
|
|
||||||
# 架构分层
|
## 安装
|
||||||
|
|
||||||
## Haskell部分
|
Linux / macOS(自动识别当前平台,取对应发行包):
|
||||||
|
|
||||||
作为函数式编程语言,从语言层面契合SQL语句解析和代数数学运算,同时有强大的类型安全系统。
|
```sh
|
||||||
|
curl -fsSL https://github.com/TheBadRoger/ChuSQL/releases/latest/download/install.sh | sh -s -- --component web
|
||||||
|
```
|
||||||
|
|
||||||
- SQL词法分析、AST
|
Windows:下载 `chusql-web-windows-x86_64.zip`,解压后在 PowerShell 里执行
|
||||||
- 语义分析:表/列存在性与类型检查(执行前的关卡)
|
|
||||||
- 事务处理
|
|
||||||
- 查询优化、内存表管理
|
|
||||||
- 关系代数运算
|
|
||||||
- 服务端接口与请求相应
|
|
||||||
- Web 管理端:Scotty REST API、登录会话、静态页面交付
|
|
||||||
|
|
||||||
## 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 |
|
浏览器打开 http://127.0.0.1:7777 ,默认账号 `root`,口令是安装时给的那个。服务启动后只有系统库 `system`(里面是账号表),**没有默认工作库**:自己建一个库再用 `USE <库名>` 选中它。
|
||||||
| ------------------------------------------------ | --------: | --------: | ------------------: |
|
换口令、换端口、改数据目录都在安装时写好的 `chusql.toml` 里。
|
||||||
| 单条 `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** |
|
|
||||||
|
|
||||||
> 最近更新: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
|
|
||||||
@@ -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
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
resolver: lts-24.59
|
|
||||||
|
|
||||||
packages:
|
|
||||||
- .
|
|
||||||
- ../../chusql-engine
|
|
||||||
File renamed without changes.
Generated
-334
@@ -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"
|
|
||||||
@@ -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
|
|
||||||
@@ -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,
|
doListCatalog,
|
||||||
doLookupByColumn,
|
doLookupByColumn,
|
||||||
sendRequest,
|
sendRequest,
|
||||||
|
setPipeName,
|
||||||
)
|
)
|
||||||
import ChuSQL.Syntax.Parser (parseStatement)
|
import ChuSQL.Syntax.Parser (parseStatement)
|
||||||
import Control.Concurrent (threadDelay)
|
import Control.Concurrent (threadDelay)
|
||||||
import Control.Exception (SomeException, bracket, evaluate, try)
|
import Control.Exception (SomeException, bracket, evaluate, try)
|
||||||
import Control.Monad (filterM, unless, when)
|
import Control.Monad (filterM, unless, when)
|
||||||
import Data.List (intercalate, isInfixOf, isPrefixOf)
|
import Data.List (intercalate, isInfixOf)
|
||||||
import GHC.Clock (getMonotonicTime)
|
import GHC.Clock (getMonotonicTime)
|
||||||
import System.Directory (
|
import System.Directory (
|
||||||
createDirectoryIfMissing,
|
createDirectoryIfMissing,
|
||||||
@@ -26,9 +27,10 @@ import System.Directory (
|
|||||||
getTemporaryDirectory,
|
getTemporaryDirectory,
|
||||||
removeDirectoryRecursive,
|
removeDirectoryRecursive,
|
||||||
)
|
)
|
||||||
import System.Environment (getArgs, getEnvironment, setEnv)
|
import System.Environment (getArgs)
|
||||||
import System.Exit (exitFailure)
|
import System.Exit (exitFailure)
|
||||||
import System.FilePath ((</>))
|
import System.FilePath ((</>))
|
||||||
|
import System.Info (os)
|
||||||
import System.Process (
|
import System.Process (
|
||||||
CreateProcess (..),
|
CreateProcess (..),
|
||||||
ProcessHandle,
|
ProcessHandle,
|
||||||
@@ -40,21 +42,19 @@ import System.Process (
|
|||||||
)
|
)
|
||||||
import Text.Printf (printf)
|
import Text.Printf (printf)
|
||||||
|
|
||||||
-- 全链路基准:Haskell 引擎与 Rust 存储进程一起跑,
|
-- 全链路基准:Haskell 引擎与 Rust 存储进程一起跑,语句走完整链路并打印墙上耗时。
|
||||||
-- 语句走完整链路(解析、优化、命名管道、WAL、落盘),
|
|
||||||
-- 用墙上时钟计时,结果打印到标准输出。
|
|
||||||
|
|
||||||
-- | 找 Rust 存储进程:优先 release
|
-- | 找 Rust 存储进程:优先 release
|
||||||
findServer :: FilePath -> IO FilePath
|
findServer :: FilePath -> IO FilePath
|
||||||
findServer "" = do
|
findServer "" = do
|
||||||
let dirs =
|
let dirs =
|
||||||
[ "chusql-storage/target"
|
[ "chusql-storage/target"
|
||||||
, "../../chusql-storage/target"
|
|
||||||
, "../chusql-storage/target"
|
, "../chusql-storage/target"
|
||||||
, "target"
|
|
||||||
]
|
]
|
||||||
modes = ["release", "debug"]
|
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]
|
candidates = [d </> m </> e | d <- dirs, m <- modes, e <- exes]
|
||||||
found <- filterM doesFileExist candidates
|
found <- filterM doesFileExist candidates
|
||||||
case found of
|
case found of
|
||||||
@@ -74,27 +74,12 @@ findServer p = do
|
|||||||
putStrLn ("storage binary not found: " ++ p)
|
putStrLn ("storage binary not found: " ++ p)
|
||||||
exitFailure
|
exitFailure
|
||||||
|
|
||||||
-- | 子进程环境:继承并覆盖 CHUSQL_*
|
-- | 起存储进程并等管道可连:调参走 --config 指的那份配置文件,环境原样继承
|
||||||
childEnv :: [(String, String)] -> IO [(String, String)]
|
startServer :: FilePath -> FilePath -> IO ProcessHandle
|
||||||
childEnv extra = do
|
startServer serverPath configPath = 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")
|
|
||||||
]
|
|
||||||
(_, _, _, ph) <-
|
(_, _, _, ph) <-
|
||||||
createProcess
|
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)
|
ready <- waitReady (200 :: Int)
|
||||||
unless ready $ do
|
unless ready $ do
|
||||||
putStrLn "storage process was not ready within 5 s"
|
putStrLn "storage process was not ready within 5 s"
|
||||||
@@ -194,12 +179,12 @@ batchInsert label n mkSql = do
|
|||||||
-- | 一次点查(原始 IPC,不经过引擎)
|
-- | 一次点查(原始 IPC,不经过引擎)
|
||||||
benchLookup :: String -> String -> Int -> Int -> IO ()
|
benchLookup :: String -> String -> Int -> Int -> IO ()
|
||||||
benchLookup table column key times = do
|
benchLookup table column key times = do
|
||||||
_ <- doLookupByColumn table column key
|
_ <- doLookupByColumn table column (VInt key)
|
||||||
t0 <- getMonotonicTime
|
t0 <- getMonotonicTime
|
||||||
results <- mapM (const (doLookupByColumn table column key)) [1 .. times]
|
results <- mapM (const (doLookupByColumn table column (VInt key))) [1 .. times]
|
||||||
t1 <- getMonotonicTime
|
t1 <- getMonotonicTime
|
||||||
let total = t1 - t0
|
let total = t1 - t0
|
||||||
hit = length [() | Right (IndexRow (Just _)) <- results]
|
hit = length [() | Right (IndexRows (_ : _)) <- results]
|
||||||
printf
|
printf
|
||||||
"%-38s %8.1f ms %7.3f ms/op (hits %d/%d)\n"
|
"%-38s %8.1f ms %7.3f ms/op (hits %d/%d)\n"
|
||||||
"lookup_by_index (raw IPC, by id)"
|
"lookup_by_index (raw IPC, by id)"
|
||||||
@@ -255,7 +240,6 @@ runBatch sqls = do
|
|||||||
chunked :: Int -> Int -> [[Int]]
|
chunked :: Int -> Int -> [[Int]]
|
||||||
chunked size n = go [1 .. n]
|
chunked size n = go [1 .. n]
|
||||||
where
|
where
|
||||||
-- | 每次切下 size 个
|
|
||||||
go [] = []
|
go [] = []
|
||||||
go xs = let (a, b) = splitAt size xs in a : go b
|
go xs = let (a, b) = splitAt size xs in a : go b
|
||||||
|
|
||||||
@@ -391,11 +375,26 @@ main = do
|
|||||||
stamp <- getMonotonicTime
|
stamp <- getMonotonicTime
|
||||||
let pipe = "chusql-fullchain-" ++ show (round (stamp * 1e6) :: Int)
|
let pipe = "chusql-fullchain-" ++ show (round (stamp * 1e6) :: Int)
|
||||||
dataDir = tmp </> pipe
|
dataDir = tmp </> pipe
|
||||||
|
configPath = tmp </> (pipe ++ ".toml")
|
||||||
|
-- TOML 的普通字符串会吃反斜杠,路径统一用正斜杠
|
||||||
|
slashed = map (\c -> if c == '\\' then '/' else c) dataDir
|
||||||
createDirectoryIfMissing True 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 "full-chain benchmark: Haskell engine <--named pipe--> Rust storage\n"
|
||||||
printf "storage : %s\n" serverPath
|
printf "storage : %s\n" serverPath
|
||||||
|
printf "config : %s\n" configPath
|
||||||
printf "pipe : %s\n" pipe
|
printf "pipe : %s\n" pipe
|
||||||
printf "data dir : %s\n" dataDir
|
printf "data dir : %s\n" dataDir
|
||||||
printf "dataset : users = %d rows, orders = %d rows\n" u o
|
printf "dataset : users = %d rows, orders = %d rows\n" u o
|
||||||
@@ -404,7 +403,7 @@ main = do
|
|||||||
bracket
|
bracket
|
||||||
(do
|
(do
|
||||||
t1 <- getMonotonicTime
|
t1 <- getMonotonicTime
|
||||||
ph <- startServer serverPath pipe dataDir
|
ph <- startServer serverPath configPath
|
||||||
t2 <- getMonotonicTime
|
t2 <- getMonotonicTime
|
||||||
printf "\n[startup] storage ready in %.2f s\n" (t2 - t1)
|
printf "\n[startup] storage ready in %.2f s\n" (t2 - t1)
|
||||||
pure ph
|
pure ph
|
||||||
@@ -2,4 +2,4 @@ resolver: lts-24.59
|
|||||||
|
|
||||||
packages:
|
packages:
|
||||||
- .
|
- .
|
||||||
- ../../chusql-engine
|
- ../chusql-engine
|
||||||
File renamed without changes.
@@ -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"))
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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."
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
resolver: lts-24.59
|
||||||
|
|
||||||
|
packages:
|
||||||
|
- .
|
||||||
|
- ../chusql-engine
|
||||||
|
- ../chusql-web
|
||||||
File renamed without changes.
@@ -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")
|
||||||
@@ -28,6 +28,7 @@ library
|
|||||||
, bytestring
|
, bytestring
|
||||||
, hashable
|
, hashable
|
||||||
, megaparsec
|
, megaparsec
|
||||||
|
, network
|
||||||
, parser-combinators
|
, parser-combinators
|
||||||
, scientific
|
, scientific
|
||||||
, text
|
, text
|
||||||
|
|||||||
@@ -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.Op
|
||||||
import ChuSQL.Algebra.Sort (sortRows)
|
import ChuSQL.Algebra.Planner (translate)
|
||||||
|
import ChuSQL.Algebra.Sort (compareValue, sortRows)
|
||||||
import ChuSQL.Model
|
import ChuSQL.Model
|
||||||
import ChuSQL.Storage (IndexResult (..), MonadStorage (..))
|
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 Control.Monad (filterM, foldM)
|
||||||
import qualified Data.HashMap.Strict as HM
|
import qualified Data.HashMap.Strict as HM
|
||||||
|
|
||||||
-- 执行:按算子树算出结果行(Scan 阶段会加别名前缀)。
|
-- 执行:按算子树算出结果行(Scan 阶段会加别名前缀)。
|
||||||
|
-- 外层行 outer 给相关子查询用:子查询里的列先在本层行里找,找不到再落到外层行。
|
||||||
|
|
||||||
-- | 只保留清单里的列
|
-- | 只保留清单里的列,顺序按清单(SELECT 的列序就是结果的列序)
|
||||||
project :: [String] -> Row -> Row
|
project :: [String] -> Row -> Row
|
||||||
project ["*"] 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 :: Maybe String -> Row -> Row
|
||||||
addPrefix mAlias = map (\(k, v) -> (qualify mAlias k, v))
|
addPrefix mAlias = map (\(k, v) -> (qualify mAlias k, v))
|
||||||
|
|
||||||
-- | 扫描一张表
|
prefixRows :: Maybe String -> [Row] -> [Row]
|
||||||
evalScan :: Database -> Maybe String -> String -> Either String [Row]
|
prefixRows mAlias = map (addPrefix mAlias)
|
||||||
evalScan db mAlias tbl = do
|
|
||||||
table <- lookupTable db tbl
|
-- | 值 → 字面量
|
||||||
Right (map (addPrefix mAlias) (tableRows table))
|
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 :: Maybe String -> String -> Value -> Expr
|
||||||
lookupCondition mAlias col k = Eq (Col (qualify mAlias col)) (LitInt k)
|
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
|
data EquiKeys = EquiKeys
|
||||||
{ ekLeft :: String
|
{ ekLeft :: String
|
||||||
, ekRight :: String
|
, ekRight :: String
|
||||||
}
|
}
|
||||||
|
|
||||||
-- | 一批行的列名(空批给空清单,此时一律退回嵌套循环)
|
|
||||||
rowsCols :: [Row] -> [String]
|
|
||||||
rowsCols (r : _) = map fst r
|
|
||||||
rowsCols [] = []
|
|
||||||
|
|
||||||
-- | 认出 a = b 这种两列等值条件,并且能分清谁在左谁在右
|
-- | 认出 a = b 这种两列等值条件,并且能分清谁在左谁在右
|
||||||
equiKeys :: [Row] -> [Row] -> Expr -> Maybe EquiKeys
|
equiKeys :: [String] -> [String] -> Expr -> Maybe EquiKeys
|
||||||
equiKeys lrows rrows (Eq (Col a) (Col b))
|
equiKeys lcols rcols (Eq (Col a) (Col b))
|
||||||
| a /= b, a `elem` lcols, b `elem` rcols = Just (EquiKeys a 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)
|
| a /= b, b `elem` lcols, a `elem` rcols = Just (EquiKeys b a)
|
||||||
where
|
|
||||||
lcols = rowsCols lrows
|
|
||||||
rcols = rowsCols rrows
|
|
||||||
equiKeys _ _ _ = Nothing
|
equiKeys _ _ _ = Nothing
|
||||||
|
|
||||||
-- | 哈希连接,输出顺序同嵌套循环
|
-- | 连接:能认出等值键就走哈希,否则退回嵌套循环;左连接没配上的左行补 NULL
|
||||||
hashJoin :: EquiKeys -> [Row] -> [Row] -> [Row]
|
joinRowsIn ::
|
||||||
hashJoin keys lrows rrows = concatMap probe lrows
|
(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
|
where
|
||||||
buckets =
|
-- 右表为空时行上没有列名,用算子推导出来的列名补 NULL
|
||||||
HM.fromListWith
|
ncols = if null rrows then rcols else rowsCols rrows
|
||||||
(flip (++))
|
|
||||||
[(v, [r]) | r <- rrows, Just v <- [lookup (ekRight keys) r]]
|
-- | 左连接里配不上的左行补右表 NULL;内连接里没有匹配就是没行
|
||||||
probe l = case lookup (ekLeft keys) l of
|
unmatched :: JoinKind -> Row -> [String] -> [Row]
|
||||||
Nothing -> []
|
unmatched InnerJoin _ _ = []
|
||||||
Just v -> [l ++ r | r <- HM.lookupDefault [] v buckets]
|
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]
|
filterPairsM ::
|
||||||
filterPairs cond lrows rrows = go lrows []
|
(MonadStorage m) =>
|
||||||
|
Database ->
|
||||||
|
Row ->
|
||||||
|
JoinKind ->
|
||||||
|
[String] ->
|
||||||
|
Expr ->
|
||||||
|
[Row] ->
|
||||||
|
[Row] ->
|
||||||
|
m (Either String [Row])
|
||||||
|
filterPairsM db outer kind ncols cond lrows rrows = go lrows []
|
||||||
where
|
where
|
||||||
go [] acc = Right (reverse acc)
|
go [] acc = pure (Right (reverse acc))
|
||||||
go (l : ls) acc = do
|
go (l : ls) acc = do
|
||||||
acc' <- foldM (keep l) acc rrows
|
hits <- keep l rrows []
|
||||||
go ls acc'
|
case hits of
|
||||||
keep l acc r = do
|
|
||||||
let row = l ++ r
|
|
||||||
ok <- cond row
|
|
||||||
pure (if ok then row : acc else acc)
|
|
||||||
|
|
||||||
-- | 一次连接:能认出等值键就走哈希,否则退回嵌套循环
|
|
||||||
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
|
|
||||||
|
|
||||||
-- | 纯求值(不需要存储)
|
|
||||||
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
|
|
||||||
|
|
||||||
-- | 单子求值:点查先问存储有无索引
|
|
||||||
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
|
|
||||||
Left e -> pure (Left e)
|
Left e -> pure (Left e)
|
||||||
Right NoIndex -> do
|
Right hs -> do
|
||||||
rows <- evalRelOpM (Scan mAlias t)
|
let hs' = case (kind, hs) of
|
||||||
pure $ do
|
(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]
|
||||||
|
|
||||||
|
-- | 索引连接:右表是裸 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
|
rs <- rows
|
||||||
filterM (evalCondForRow (lookupCondition mAlias col k)) rs
|
case rs of
|
||||||
Right (IndexRow Nothing) -> pure (Right [])
|
[] -> Right LitNull
|
||||||
Right (IndexRow (Just row)) -> pure (Right [addPrefix mAlias row])
|
[r] -> valueLiteral <$> rowValue r
|
||||||
evalRelOpM (Filter p x) = do
|
_ -> Left "scalar subquery returned more than one row"
|
||||||
result <- evalRelOpM x
|
|
||||||
|
-- | 把子查询求值成字面量;子查询的错直接往上传
|
||||||
|
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 op = do
|
||||||
|
(result, _) <- runMemoryStorage (evalRelOpIn db [] op) db
|
||||||
|
result
|
||||||
|
|
||||||
|
-- | 带外层行的执行器
|
||||||
|
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 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
|
pure $ do
|
||||||
rows <- result
|
rows <- res
|
||||||
filterM (evalCondForRow p) rows
|
filterM (evalCondForRow cond . (++ outer)) (prefixRows mAlias 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
|
|
||||||
@@ -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.Model
|
||||||
import ChuSQL.Syntax.AST
|
import ChuSQL.Syntax.AST
|
||||||
import Control.Monad (join)
|
import Control.Monad (join)
|
||||||
|
|
||||||
-- 表达式求值:拿一行数据算出一个值。
|
-- 表达式求值:三值逻辑,NULL 参与运算结果仍是 NULL。
|
||||||
|
|
||||||
-- | 在一行上求出表达式的值
|
-- | 在一行上求出表达式的值
|
||||||
evalExpr :: Expr -> Row -> Either String Value
|
evalExpr :: Expr -> Row -> Either String Value
|
||||||
evalExpr (Col name) row =
|
evalExpr (Col name) row =
|
||||||
maybe (Left ("unknown column: " ++ name)) Right (lookup name row)
|
maybe (Left ("unknown column: " ++ name)) Right (lookup name row)
|
||||||
|
evalExpr LitNull _ = Right VNull
|
||||||
evalExpr (LitInt n) _ = Right (VInt n)
|
evalExpr (LitInt n) _ = Right (VInt n)
|
||||||
|
evalExpr (LitFloat d) _ = Right (VFloat d)
|
||||||
evalExpr (LitStr s) _ = Right (VStr s)
|
evalExpr (LitStr s) _ = Right (VStr s)
|
||||||
evalExpr (LitBool b) _ = Right (VBool b)
|
evalExpr (LitBool b) _ = Right (VBool b)
|
||||||
evalExpr (Gt a b) row = liftBinOp (intOp (>)) a b row
|
evalExpr (LitDate s) _ = Right (VStr s)
|
||||||
evalExpr (Lt a b) row = liftBinOp (intOp (<)) a b row
|
evalExpr (LitTimestamp s) _ = Right (VStr s)
|
||||||
evalExpr (Eq a b) row = liftBinOp eqOp a b row
|
evalExpr (LitBlob s) _ = Right (VStr s)
|
||||||
evalExpr (And a b) row = liftBinOp (boolOp (&&)) a b row
|
evalExpr (Add a b) row = liftArith addArith a b row
|
||||||
evalExpr (Or a b) row = liftBinOp (boolOp (||)) 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 :: Expr -> [String]
|
||||||
colsInExpr (Col c) = [c]
|
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 (Gt a b) = colsInExpr a ++ colsInExpr b
|
||||||
colsInExpr (Lt a b) = colsInExpr a ++ colsInExpr b
|
colsInExpr (Lt a b) = colsInExpr a ++ colsInExpr b
|
||||||
colsInExpr (Eq a b) = colsInExpr a ++ colsInExpr b
|
colsInExpr (Eq a b) = colsInExpr a ++ colsInExpr b
|
||||||
colsInExpr (And a b) = colsInExpr a ++ colsInExpr b
|
colsInExpr (And a b) = colsInExpr a ++ colsInExpr b
|
||||||
colsInExpr (Or 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 _ = []
|
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 :: Expr -> Row -> Either String Bool
|
||||||
evalCondForRow e row = do
|
evalCondForRow e row = do
|
||||||
v <- evalExpr e row
|
v <- evalExpr e row
|
||||||
case v of
|
case v of
|
||||||
VBool b -> Right b
|
VBool b -> Right b
|
||||||
|
VNull -> Right False
|
||||||
_ -> Left "type error: WHERE condition must be a boolean"
|
_ -> Left "type error: WHERE condition must be a boolean"
|
||||||
|
|
||||||
-- | 把二元运算提升到 Either
|
-- | 把二元运算提升到 Either
|
||||||
@@ -46,16 +194,89 @@ liftBinOp ::
|
|||||||
Either String Value
|
Either String Value
|
||||||
liftBinOp f a b row = join (f <$> evalExpr a row <*> evalExpr b row)
|
liftBinOp f a b row = join (f <$> evalExpr a row <*> evalExpr b row)
|
||||||
|
|
||||||
-- | 整数二元运算
|
-- | 整数算术与浮点算术成对出现
|
||||||
intOp :: (Int -> Int -> Bool) -> Value -> Value -> Either String Value
|
data Arith = Arith
|
||||||
intOp f (VInt x) (VInt y) = Right (VBool (f x y))
|
{ arithInt :: Int -> Int -> Either String Int
|
||||||
intOp _ _ _ = Left "type error: expected two integers"
|
, 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
|
liftBool ::
|
||||||
boolOp f (VBool x) (VBool y) = Right (VBool (f x y))
|
(Value -> Value -> Either String Value) ->
|
||||||
boolOp _ _ _ = Left "type error: expected two booleans"
|
Expr ->
|
||||||
|
Expr ->
|
||||||
|
Row ->
|
||||||
|
Either String Value
|
||||||
|
liftBool f = liftBinOp f
|
||||||
|
|
||||||
-- | 相等比较
|
andValues :: Value -> Value -> Either String Value
|
||||||
eqOp :: Value -> Value -> Either String Value
|
andValues (VBool False) _ = Right (VBool False)
|
||||||
eqOp x y = Right (VBool (x == y))
|
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"
|
||||||
@@ -1,30 +1,51 @@
|
|||||||
module ChuSQL.Algebra.Op (RelOp (..), renderPlan) where
|
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
|
data RelOp
|
||||||
= Scan (Maybe String) String
|
= Scan (Maybe String) String (Maybe [String])
|
||||||
| Lookup (Maybe String) String String Int
|
| Lookup (Maybe String) String String Value
|
||||||
|
| Range (Maybe String) String String (Maybe (Value, Bool)) (Maybe (Value, Bool))
|
||||||
| Filter Expr RelOp
|
| Filter Expr RelOp
|
||||||
| Project [String] RelOp
|
| Project [String] RelOp
|
||||||
|
| Compute [(String, Expr)] RelOp
|
||||||
|
| Aggregate [String] [(String, Expr)] RelOp
|
||||||
|
| Unit
|
||||||
| Sort [(String, SortDir)] RelOp
|
| Sort [(String, SortDir)] RelOp
|
||||||
| Limit Int RelOp
|
| Limit Int RelOp
|
||||||
| Join RelOp RelOp Expr
|
| Join JoinKind RelOp RelOp Expr
|
||||||
deriving (Show, Eq)
|
deriving (Show, Eq)
|
||||||
|
|
||||||
|
-- | 连接的显示名
|
||||||
|
kindLabel :: JoinKind -> String
|
||||||
|
kindLabel InnerJoin = "Join"
|
||||||
|
kindLabel LeftJoin = "LeftJoin"
|
||||||
|
|
||||||
-- | 渲染成缩进文本
|
-- | 渲染成缩进文本
|
||||||
renderPlan :: RelOp -> String
|
renderPlan :: RelOp -> String
|
||||||
renderPlan = go 0
|
renderPlan = go 0
|
||||||
where
|
where
|
||||||
go ind op = case op of
|
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
|
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
|
Filter e x -> pad ++ "Filter " ++ show e ++ "\n" ++ go (ind + 1) x
|
||||||
Project c x -> pad ++ "Project " ++ show c ++ "\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
|
Sort s x -> pad ++ "Sort " ++ show s ++ "\n" ++ go (ind + 1) x
|
||||||
Limit n x -> pad ++ "Limit " ++ show n ++ "\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
|
where
|
||||||
pad = replicate (ind * 2) ' '
|
pad = replicate (ind * 2) ' '
|
||||||
@@ -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.Expr (colsInExpr, evalExpr)
|
||||||
import ChuSQL.Algebra.Op (RelOp (..))
|
import ChuSQL.Algebra.Op (RelOp (..))
|
||||||
import ChuSQL.Model
|
import ChuSQL.Model
|
||||||
import ChuSQL.Syntax.AST (Expr (..))
|
import ChuSQL.Syntax.AST (Expr (..), JoinKind (..))
|
||||||
|
import Data.List (nub)
|
||||||
|
|
||||||
-- 查询优化:谓词下推 + 投影下推 + 常量折叠,跑到不动点。
|
-- 查询优化:谓词下推 + 投影下推 + 常量折叠,跑到不动点。
|
||||||
|
|
||||||
-- | 算子会产出哪些列
|
-- | 算子会产出哪些列
|
||||||
relOpCols :: Database -> RelOp -> [String]
|
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
|
case lookup tbl db of
|
||||||
Nothing -> []
|
Nothing -> []
|
||||||
Just t -> map (prefix ++) (colNames t)
|
Just t -> map (prefix ++) (colNames t)
|
||||||
where
|
where
|
||||||
prefix = maybe "" (++ ".") mAlias
|
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 db (Filter _ x) = relOpCols db x
|
||||||
relOpCols _ (Project ["*"] _) = ["*"]
|
relOpCols _ (Project ["*"] _) = ["*"]
|
||||||
relOpCols _ (Project cols _) = cols
|
relOpCols _ (Project cols _) = cols
|
||||||
relOpCols db (Sort _ x) = relOpCols db x
|
relOpCols db (Sort _ x) = relOpCols db x
|
||||||
relOpCols db (Limit _ 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 里
|
-- | need 是否都在 have 里
|
||||||
isSubsetOf :: [String] -> [String] -> Bool
|
isSubsetOf :: [String] -> [String] -> Bool
|
||||||
@@ -51,7 +57,7 @@ applyFilters :: [Expr] -> RelOp -> RelOp
|
|||||||
applyFilters [] op = op
|
applyFilters [] op = op
|
||||||
applyFilters (e : es) op = applyFilters es (Filter e op)
|
applyFilters (e : es) op = applyFilters es (Filter e op)
|
||||||
|
|
||||||
-- | Filter 压在 Join 上时下推
|
-- | Filter 压在内连接上时下推
|
||||||
pushJoin :: Database -> Expr -> RelOp -> RelOp -> Expr -> RelOp
|
pushJoin :: Database -> Expr -> RelOp -> RelOp -> Expr -> RelOp
|
||||||
pushJoin db p l r c =
|
pushJoin db p l r c =
|
||||||
let preds = splitConj p
|
let preds = splitConj p
|
||||||
@@ -61,17 +67,56 @@ pushJoin db p l r c =
|
|||||||
(rPs, bothPs) = partitionPred rCols rest1
|
(rPs, bothPs) = partitionPred rCols rest1
|
||||||
l' = applyFilters lPs l
|
l' = applyFilters lPs l
|
||||||
r' = applyFilters rPs r
|
r' = applyFilters rPs r
|
||||||
joined = Join l' r' c
|
joined = Join InnerJoin l' r' c
|
||||||
in case combineConj bothPs of
|
in case combineConj bothPs of
|
||||||
Nothing -> joined
|
Nothing -> joined
|
||||||
Just p' -> Filter p' 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 下
|
-- | 合并两层 Filter 或挪到 Sort 下
|
||||||
pushOne :: Database -> RelOp -> RelOp
|
pushOne :: Database -> RelOp -> RelOp
|
||||||
pushOne _ (Filter p (Filter q x)) = Filter (And p q) x
|
pushOne _ (Filter p (Filter q x))
|
||||||
pushOne _ (Filter p (Sort spec x)) = Sort spec (Filter p 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
|
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 :: [String] -> Bool
|
||||||
needsAll = elem allColumns
|
needsAll = elem allColumns
|
||||||
@@ -85,29 +130,39 @@ pushProjectLeaf db need leaf
|
|||||||
|
|
||||||
-- | 把需要哪些列往下传
|
-- | 把需要哪些列往下传
|
||||||
pushProject :: Database -> [String] -> RelOp -> RelOp
|
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
|
pushProject db _ (Project cols x) = case pushProject db cols x of
|
||||||
Project cols' y | cols' == cols -> Project cols y
|
Project cols' y | cols' == cols -> Project cols y
|
||||||
other -> Project cols other
|
other -> Project cols other
|
||||||
pushProject db need (Filter p x) = Filter p (pushProject db (need ++ colsInExpr p) x)
|
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 (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 (Limit n x) = Limit n (pushProject db need x)
|
||||||
pushProject db need (Join l r c)
|
pushProject db need (Join kind l r c)
|
||||||
| needsAll need = Join (pushProject db need l) (pushProject db need r) c
|
| needsAll need = Join kind (pushProject db need l) (pushProject db need r) c
|
||||||
| otherwise =
|
| otherwise =
|
||||||
let tot = need ++ colsInExpr c
|
let tot = need ++ colsInExpr c
|
||||||
lCols = relOpCols db l
|
lCols = relOpCols db l
|
||||||
rCols = relOpCols db r
|
rCols = relOpCols db r
|
||||||
lNeed = if needsAll lCols then ["*"] else [x | x <- tot, x `elem` lCols]
|
lNeed = if needsAll lCols then ["*"] else [x | x <- tot, x `elem` lCols]
|
||||||
rNeed = if needsAll rCols then ["*"] else [x | x <- tot, x `elem` rCols]
|
rNeed = if needsAll rCols then ["*"] else [x | x <- tot, x `elem` rCols]
|
||||||
in Join (pushProject db lNeed l) (pushProject db rNeed r) c
|
in Join kind (pushProject db lNeed l) (pushProject db rNeed r) c
|
||||||
pushProject db need leaf@(Scan _ _) = pushProjectLeaf db need leaf
|
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@(Lookup _ _ _ _) = pushProjectLeaf db need leaf
|
||||||
|
pushProject db need leaf@(Range _ _ _ _ _) = pushProjectLeaf db need leaf
|
||||||
|
|
||||||
-- | 折一个表达式节点
|
-- | 折一个表达式节点
|
||||||
foldNode :: Expr -> Expr
|
foldNode :: Expr -> Expr
|
||||||
foldNode e
|
foldNode e
|
||||||
| null (colsInExpr e) = case evalExpr e [] of
|
| null (colsInExpr e) = case evalExpr e [] of
|
||||||
|
Right VNull -> LitNull
|
||||||
Right (VInt n) -> LitInt n
|
Right (VInt n) -> LitInt n
|
||||||
|
Right (VFloat d) -> LitFloat d
|
||||||
Right (VStr s) -> LitStr s
|
Right (VStr s) -> LitStr s
|
||||||
Right (VBool b) -> LitBool b
|
Right (VBool b) -> LitBool b
|
||||||
Left _ -> e
|
Left _ -> e
|
||||||
@@ -115,6 +170,11 @@ foldNode e
|
|||||||
|
|
||||||
-- | 自底向上折整棵表达式
|
-- | 自底向上折整棵表达式
|
||||||
foldConstants :: Expr -> Expr
|
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 (Gt a b) = foldNode (Gt (foldConstants a) (foldConstants b))
|
||||||
foldConstants (Lt a b) = foldNode (Lt (foldConstants a) (foldConstants b))
|
foldConstants (Lt a b) = foldNode (Lt (foldConstants a) (foldConstants b))
|
||||||
foldConstants (Eq a b) = foldNode (Eq (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 :: Database -> RelOp -> RelOp
|
||||||
rewriteNode _ (Filter (Eq (Col k) (LitInt v)) (Scan mAlias t)) =
|
rewriteNode _ (Filter (Eq (Col k) (LitInt v)) (Scan mAlias t Nothing)) =
|
||||||
Lookup mAlias t (unqualify mAlias k) v
|
Lookup mAlias t (unqualify mAlias k) (VInt v)
|
||||||
rewriteNode db (Filter p (Join l r c)) = pushJoin db p l r c
|
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
|
rewriteNode db (Filter p x) = case foldConstants p of
|
||||||
LitBool True -> x
|
LitBool True -> x
|
||||||
p' -> pushOne db (Filter p' x)
|
p' -> pushOne db (Filter p' x)
|
||||||
rewriteNode _ (Project ["*"] x) = x
|
rewriteNode _ (Project ["*"] x) = x
|
||||||
|
rewriteNode _ (Compute items x) = Compute [(label, foldConstants e) | (label, e) <- items] x
|
||||||
rewriteNode db op = pushOne db op
|
rewriteNode db op = pushOne db op
|
||||||
|
|
||||||
-- | 自底向上遍历一次
|
-- | 自底向上遍历一次
|
||||||
@@ -139,9 +218,11 @@ optimizeRelOp db = rewriteNode db . descend
|
|||||||
where
|
where
|
||||||
descend (Filter p x) = Filter p (optimizeRelOp db x)
|
descend (Filter p x) = Filter p (optimizeRelOp db x)
|
||||||
descend (Project cols x) = Project cols (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 (Sort spec x) = Sort spec (optimizeRelOp db x)
|
||||||
descend (Limit n x) = Limit n (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
|
descend x = x
|
||||||
|
|
||||||
-- | 反复跑到不动点
|
-- | 反复跑到不动点
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
module ChuSQL.Algebra.Planner (translate) where
|
module ChuSQL.Algebra.Planner (translate) where
|
||||||
|
|
||||||
|
import ChuSQL.Algebra.Expr (aggregatesIn)
|
||||||
import ChuSQL.Algebra.Op
|
import ChuSQL.Algebra.Op
|
||||||
import ChuSQL.Syntax.AST
|
import ChuSQL.Syntax.AST
|
||||||
|
|
||||||
@@ -7,29 +8,80 @@ import ChuSQL.Syntax.AST
|
|||||||
|
|
||||||
-- | 把 FROM 子句翻成 Scan / Join
|
-- | 把 FROM 子句翻成 Scan / Join
|
||||||
fromToRelOp :: FromClause -> RelOp
|
fromToRelOp :: FromClause -> RelOp
|
||||||
fromToRelOp (FromTable mAlias tbl) = Scan mAlias tbl
|
fromToRelOp FromUnit = Unit
|
||||||
fromToRelOp (FromJoin left mAlias tbl cond) =
|
fromToRelOp (FromTable mAlias tbl) = Scan mAlias tbl Nothing
|
||||||
Join (fromToRelOp left) (Scan mAlias tbl) cond
|
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 :: Statement -> Either String RelOp
|
||||||
translate q = case q of
|
translate q = case q of
|
||||||
Select
|
Select
|
||||||
{ selectCols = cols
|
{ selectCols = cols
|
||||||
, selectFrom = fromC
|
, selectFrom = fromC
|
||||||
, selectWhere = mWhere
|
, selectWhere = mWhere
|
||||||
|
, selectGroupBy = groupBy
|
||||||
, selectOrderBy = orderBy
|
, selectOrderBy = orderBy
|
||||||
, selectLimit = mLimit
|
, selectLimit = mLimit
|
||||||
} -> do
|
} -> do
|
||||||
let base = fromToRelOp fromC
|
let (plan, _) = grouped groupBy [] (source fromC mWhere)
|
||||||
filtered = case mWhere of
|
Right (Project cols (limited orderBy mLimit plan))
|
||||||
Nothing -> base
|
SelectExpr items fromC mWhere groupBy orderBy mLimit -> do
|
||||||
Just w -> Filter w base
|
let (plan, items') = grouped groupBy items (source fromC mWhere)
|
||||||
sorted = case orderBy of
|
Right (Compute items' (limited orderBy mLimit plan))
|
||||||
[] -> filtered
|
|
||||||
spec -> Sort spec filtered
|
|
||||||
limited = case mLimit of
|
|
||||||
Nothing -> sorted
|
|
||||||
Just n -> Limit n sorted
|
|
||||||
Right (Project cols limited)
|
|
||||||
_ -> Left "only SELECT is supported by translate"
|
_ -> Left "only SELECT is supported by translate"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
module ChuSQL.Algebra.Sort (sortRows) where
|
module ChuSQL.Algebra.Sort (sortRows, compareValue) where
|
||||||
|
|
||||||
import ChuSQL.Model
|
import ChuSQL.Model
|
||||||
import ChuSQL.Syntax.AST
|
import ChuSQL.Syntax.AST
|
||||||
@@ -12,9 +12,15 @@ flipOrdering LT = GT
|
|||||||
flipOrdering GT = LT
|
flipOrdering GT = LT
|
||||||
flipOrdering EQ = EQ
|
flipOrdering EQ = EQ
|
||||||
|
|
||||||
-- | 比较两个值
|
-- | 比较两个值:NULL 最小,数值跨整数与小数比较
|
||||||
compareValue :: Value -> Value -> Ordering
|
compareValue :: Value -> Value -> Ordering
|
||||||
|
compareValue VNull VNull = EQ
|
||||||
|
compareValue VNull _ = LT
|
||||||
|
compareValue _ VNull = GT
|
||||||
compareValue (VInt a) (VInt b) = compare a b
|
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 (VStr a) (VStr b) = compare a b
|
||||||
compareValue (VBool a) (VBool b) = compare a b
|
compareValue (VBool a) (VBool b) = compare a b
|
||||||
compareValue _ _ = EQ
|
compareValue _ _ = EQ
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
module ChuSQL.Engine (runStatement, runStatementM, rowsOf) where
|
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.Expr (evalCondForRow, evalExpr)
|
||||||
import ChuSQL.Algebra.Optimize (optimize)
|
import ChuSQL.Algebra.Optimize (optimize)
|
||||||
import ChuSQL.Algebra.Planner (translate)
|
import ChuSQL.Algebra.Planner (translate)
|
||||||
import ChuSQL.Model
|
import ChuSQL.Model
|
||||||
import ChuSQL.Semantic (check)
|
import ChuSQL.Semantic (prepare)
|
||||||
import ChuSQL.Storage (MonadStorage (..))
|
import ChuSQL.Storage (MonadStorage (..))
|
||||||
import ChuSQL.Storage.Memory (MemoryStorage (runMemoryStorage))
|
import ChuSQL.Storage.Memory (MemoryStorage (runMemoryStorage))
|
||||||
import ChuSQL.Syntax.AST
|
import ChuSQL.Syntax.AST
|
||||||
|
import ChuSQL.Syntax.Parser (parseExpression)
|
||||||
|
import Data.Maybe (mapMaybe)
|
||||||
|
|
||||||
-- 引擎入口:语义检查 + 分发执行,以及内存实现与泛型版本。
|
-- 引擎入口:语义检查 + 分发执行,以及内存实现与泛型版本。
|
||||||
|
|
||||||
@@ -25,37 +27,43 @@ rowsOf = fmap snd
|
|||||||
|
|
||||||
-- | 泛型入口:先检查再执行
|
-- | 泛型入口:先检查再执行
|
||||||
runStatementM :: (MonadStorage m) => Statement -> m (Either String [Row])
|
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
|
runStatementM q = do
|
||||||
db <- schema
|
db <- schema
|
||||||
case check db q of
|
case prepare db q of
|
||||||
Left err -> pure (Left err)
|
Left err -> pure (Left err)
|
||||||
Right _ -> runStatementUncheckedM db q
|
Right resolved -> runStatementUncheckedM db resolved
|
||||||
|
|
||||||
-- | 按语句类型分发(已检查过)
|
-- | 按语句类型分发(已检查过)
|
||||||
runStatementUncheckedM :: (MonadStorage m) => Database -> Statement -> m (Either String [Row])
|
runStatementUncheckedM :: (MonadStorage m) => Database -> Statement -> m (Either String [Row])
|
||||||
runStatementUncheckedM db q@Select{} =
|
runStatementUncheckedM db q@Select{} =
|
||||||
case translate q of
|
case translate q of
|
||||||
Left e -> pure (Left e)
|
Left e -> pure (Left e)
|
||||||
Right relOp -> evalRelOpM (optimize db relOp)
|
Right relOp -> evalRelOpM db (optimize db relOp)
|
||||||
runStatementUncheckedM _ (Insert tbl cols rows) =
|
runStatementUncheckedM db q@SelectExpr{} =
|
||||||
case mapM toRow rows of
|
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)
|
Left err -> pure (Left err)
|
||||||
Right rs -> do
|
Right rs -> do
|
||||||
result <- insertMany tbl rs
|
result <- insertMany tbl rs
|
||||||
pure (result >> Right [])
|
pure (result >> Right [])
|
||||||
where
|
runStatementUncheckedM db (Delete tbl mWhere) = do
|
||||||
-- \| 一行字面量求值成一行数据
|
|
||||||
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
|
rowsResult <- scan tbl
|
||||||
case rowsResult of
|
case rowsResult of
|
||||||
Left err -> pure (Left err)
|
Left err -> pure (Left err)
|
||||||
Right rows -> case splitByCondition mWhere rows of
|
Right rows -> do
|
||||||
|
split <- splitByConditionM db mWhere rows
|
||||||
|
case split of
|
||||||
Left err -> pure (Left err)
|
Left err -> pure (Left err)
|
||||||
Right (doomed, kept) ->
|
Right (doomed, kept) ->
|
||||||
case mapM rowId doomed of
|
case mapM rowId doomed of
|
||||||
@@ -66,30 +74,24 @@ runStatementUncheckedM _ (Delete tbl mWhere) = do
|
|||||||
result <- replaceAll tbl kept
|
result <- replaceAll tbl kept
|
||||||
pure (result >> Right [])
|
pure (result >> Right [])
|
||||||
where
|
where
|
||||||
-- \| 这一行的 id(要拿来当删除键)
|
|
||||||
rowId :: Row -> Either String Int
|
rowId :: Row -> Either String Int
|
||||||
rowId r = case lookup "id" r of
|
rowId r = case lookup "id" r of
|
||||||
Just (VInt k) -> Right k
|
Just (VInt k) -> Right k
|
||||||
_ -> Left "row has no integer id"
|
_ -> Left "row has no integer id"
|
||||||
runStatementUncheckedM _ (Update tbl assigns mWhere) = do
|
runStatementUncheckedM db (Update tbl assigns mWhere) =
|
||||||
|
case lookupTable db tbl of
|
||||||
|
Left e -> pure (Left e)
|
||||||
|
Right table -> do
|
||||||
rowsResult <- scan tbl
|
rowsResult <- scan tbl
|
||||||
case rowsResult of
|
case rowsResult of
|
||||||
Left err -> pure (Left err)
|
Left err -> pure (Left err)
|
||||||
Right rows -> do
|
Right rows -> do
|
||||||
let newRows = mapM (updateRow mWhere assigns) rows
|
newRows <- mapM (updateRowM db table mWhere assigns) rows
|
||||||
case newRows of
|
case sequence newRows of
|
||||||
Left err -> pure (Left err)
|
Left err -> pure (Left err)
|
||||||
Right rs -> do
|
Right rs -> do
|
||||||
result <- replaceAll tbl rs
|
result <- replaceAll tbl rs
|
||||||
pure (result >> Right [])
|
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
|
|
||||||
runStatementUncheckedM _ (CreateTable name cols) = do
|
runStatementUncheckedM _ (CreateTable name cols) = do
|
||||||
result <- createTable name cols
|
result <- createTable name cols
|
||||||
pure (result >> Right [])
|
pure (result >> Right [])
|
||||||
@@ -105,23 +107,251 @@ runStatementUncheckedM _ (DropIndex tbl col) = do
|
|||||||
runStatementUncheckedM _ (DropColumn tbl col) = do
|
runStatementUncheckedM _ (DropColumn tbl col) = do
|
||||||
result <- dropColumn tbl col
|
result <- dropColumn tbl col
|
||||||
pure (result >> Right [])
|
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])
|
existingRows :: (MonadStorage m) => String -> Table -> m (Either String [Row])
|
||||||
splitByCondition cond = go [] []
|
existingRows tbl table
|
||||||
|
| needsRows table = scan tbl
|
||||||
|
| otherwise = pure (Right [])
|
||||||
where
|
where
|
||||||
-- \| 逐行过,保持原顺序
|
needsRows t = any (constraintColumn . snd) (tableCols t)
|
||||||
go doomed kept [] = Right (reverse doomed, reverse kept)
|
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
|
go doomed kept (r : rs) = do
|
||||||
hit <- case cond of
|
hit <- case cond of
|
||||||
Nothing -> Right True
|
Nothing -> pure (Right True)
|
||||||
Just e -> evalCondForRow e r
|
Just e -> evalCondForRowM db e r
|
||||||
go (if hit then r : doomed else doomed) (if hit then kept else r : kept) rs
|
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
|
alterTable ::
|
||||||
applyUpdates [] row = Right row
|
(MonadStorage m) =>
|
||||||
applyUpdates ((col, e) : rest) row = do
|
Database ->
|
||||||
v <- evalExpr e row
|
String ->
|
||||||
let row' = map (\(k, val) -> if k == col then (k, v) else (k, val)) row
|
(Table -> Either String ([(String, Column)], [Row])) ->
|
||||||
applyUpdates rest 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)
|
||||||
@@ -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.Hashable (Hashable (..))
|
||||||
import Data.List (stripPrefix)
|
import Data.List (isSuffixOf, stripPrefix)
|
||||||
import Data.Maybe (fromMaybe)
|
import Data.Maybe (fromMaybe)
|
||||||
|
|
||||||
-- 数据模型:列类型、值、行、表、数据库,外加几个查表小工具。
|
-- 数据模型:列类型、列约束、值、行、表、数据库,外加几个查表小工具。
|
||||||
|
|
||||||
data Column
|
-- | 列的基础类型
|
||||||
= TInt
|
data ColumnType
|
||||||
| TStr
|
= CInt
|
||||||
| TBool
|
| CBigInt
|
||||||
|
| CSmallInt
|
||||||
|
| CStr
|
||||||
|
| CVarchar Int
|
||||||
|
| CChar Int
|
||||||
|
| CBool
|
||||||
|
| CFloat
|
||||||
|
| CDouble
|
||||||
|
| CDecimal Int Int
|
||||||
|
| CDate
|
||||||
|
| CTimestamp
|
||||||
|
| CBlob
|
||||||
deriving (Show, Eq)
|
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
|
data Value
|
||||||
= VInt Int
|
= VNull
|
||||||
|
| VInt Int
|
||||||
|
| VFloat Double
|
||||||
| VStr String
|
| VStr String
|
||||||
| VBool Bool
|
| VBool Bool
|
||||||
deriving (Show, Eq)
|
deriving (Show, Eq)
|
||||||
|
|
||||||
instance Hashable Value where
|
instance Hashable Value where
|
||||||
hashWithSalt s (VInt n) = hashWithSalt (hashWithSalt s (0 :: Int)) n
|
hashWithSalt s VNull = hashWithSalt (hashWithSalt s (0 :: Int)) ()
|
||||||
hashWithSalt s (VStr t) = hashWithSalt (hashWithSalt s (1 :: Int)) t
|
hashWithSalt s (VInt n) = hashWithSalt (hashWithSalt s (1 :: Int)) n
|
||||||
hashWithSalt s (VBool b) = hashWithSalt (hashWithSalt s (2 :: Int)) b
|
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)]
|
type Row = [(String, Value)]
|
||||||
|
|
||||||
@@ -38,7 +365,7 @@ type Database = [(String, Table)]
|
|||||||
colNames :: Table -> [String]
|
colNames :: Table -> [String]
|
||||||
colNames = map fst . tableCols
|
colNames = map fst . tableCols
|
||||||
|
|
||||||
-- | 查某列的类型
|
-- | 查某列的定义
|
||||||
colType :: Table -> String -> Maybe Column
|
colType :: Table -> String -> Maybe Column
|
||||||
colType t c = lookup c (tableCols t)
|
colType t c = lookup c (tableCols t)
|
||||||
|
|
||||||
@@ -58,3 +385,12 @@ qualify (Just a) c = a ++ "." ++ c
|
|||||||
unqualify :: Maybe String -> String -> String
|
unqualify :: Maybe String -> String -> String
|
||||||
unqualify Nothing c = c
|
unqualify Nothing c = c
|
||||||
unqualify (Just a) c = fromMaybe c (stripPrefix (a ++ ".") 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
|
||||||
@@ -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.Model
|
||||||
import ChuSQL.Syntax.AST
|
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 :: Maybe String -> [(String, Column)] -> [(String, Column)]
|
||||||
prefixColumns mAlias cols = [(prefix ++ c, ty) | (c, ty) <- cols]
|
prefixColumns mAlias cols = [(prefix ++ c, ty) | (c, ty) <- cols]
|
||||||
where
|
where
|
||||||
prefix = maybe "" (++ ".") mAlias
|
prefix = maybe "" (++ ".") mAlias
|
||||||
|
|
||||||
-- | 收集 FROM 能提供的列
|
-- | 检查期的作用域:本层优先,外层兜底。
|
||||||
checkFrom :: Database -> FromClause -> Either String [(String, Column)]
|
-- 不能把两层平铺成一个列表:同名裸列会出现两次,被误报成 ambiguous。
|
||||||
checkFrom db (FromTable mAlias tbl) = do
|
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
|
t <- lookupTable db tbl
|
||||||
Right (prefixColumns mAlias (tableCols t))
|
Right (prefixColumns (Just (fromMaybe tbl mAlias)) (tableCols t))
|
||||||
checkFrom db (FromJoin left mAlias tbl cond) = do
|
checkFrom db outer (FromJoin _ left mAlias tbl cond) = do
|
||||||
lcols <- checkFrom db left
|
lcols <- checkFrom db outer left
|
||||||
t <- lookupTable db tbl
|
t <- lookupTable db tbl
|
||||||
let env = lcols ++ prefixColumns mAlias (tableCols t)
|
let env = lcols ++ prefixColumns (Just (fromMaybe tbl mAlias)) (tableCols t)
|
||||||
checkBool "ON" env cond
|
checkBool db "ON" (Scope env outer) cond
|
||||||
Right env
|
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 :: String -> String -> [(String, Column)] -> String
|
||||||
missingColumn place c env =
|
missingColumn place c env =
|
||||||
@@ -32,94 +67,287 @@ missingColumn place c env =
|
|||||||
where
|
where
|
||||||
hint
|
hint
|
||||||
| null env = ""
|
| 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 :: String -> Scope -> [String] -> Either String ()
|
||||||
checkColumns place env requested =
|
checkColumns place env = mapM_ (\c -> if c == allColumns then Right () else () <$ scopeAt place env c)
|
||||||
case [c | c <- requested, c /= allColumns, c `notElem` map fst env] of
|
|
||||||
[] -> Right ()
|
|
||||||
(c : _) -> Left (missingColumn place c env)
|
|
||||||
|
|
||||||
-- | 推导表达式的类型
|
-- | 保留子句名与可用列提示
|
||||||
inferExpr :: String -> [(String, Column)] -> Expr -> Either String Column
|
columnAt :: String -> [(String, Column)] -> String -> Either String (String, Column)
|
||||||
inferExpr place env (Col c) = maybe (Left (missingColumn place c env)) Right (lookup c env)
|
columnAt place env c = case resolveColumn c env of
|
||||||
inferExpr _ _ (LitInt _) = Right TInt
|
Right entry -> Right entry
|
||||||
inferExpr _ _ (LitStr _) = Right TStr
|
Left err | "ambiguous column:" `isPrefixOf` err -> Left (place ++ ": " ++ err)
|
||||||
inferExpr _ _ (LitBool _) = Right TBool
|
Left _ -> Left (missingColumn place c env)
|
||||||
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)
|
|
||||||
|
|
||||||
-- | 检查二元运算两边的类型
|
-- | 聚合不能出现在 WHERE 里
|
||||||
operands :: String -> [(String, Column)] -> Column -> Expr -> Expr -> Either String Column
|
checkNoAggregate :: String -> Expr -> Either String ()
|
||||||
operands place env want a b = do
|
checkNoAggregate place e
|
||||||
ta <- inferExpr place env a
|
| hasAggregate e = Left (place ++ ": aggregate functions are not allowed here")
|
||||||
tb <- inferExpr place env b
|
| otherwise = Right ()
|
||||||
if ta == want && tb == want
|
|
||||||
then Right TBool
|
-- | 分组查询里,没被聚合的列必须出现在 GROUP BY 里
|
||||||
else Left (place ++ ": operator needs " ++ show want ++ " on both sides, got " ++ show ta ++ " and " ++ show tb)
|
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 :: Database -> String -> Scope -> Expr -> Either String ()
|
||||||
checkBool place env e = do
|
checkBool db place env e = do
|
||||||
checkColumns place env (colsInExpr e)
|
checkColumns place env (colsInExpr e)
|
||||||
t <- inferExpr place env e
|
t <- inferExpr db place env e
|
||||||
if t == TBool
|
case t of
|
||||||
then Right ()
|
InferNull -> Right ()
|
||||||
else Left (place ++ ": condition must be a boolean, got " ++ show t)
|
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 :: Database -> String -> Scope -> Table -> (String, Expr) -> Either String ()
|
||||||
checkTyped place env t (c, e) = do
|
checkTyped db place env t (c, e) = do
|
||||||
want <- maybe (Left (missingColumn place c (tableCols t))) Right (colType t c)
|
want <- maybe (Left (missingColumn place c (tableCols t))) Right (colType t c)
|
||||||
got <- inferExpr place env e
|
got <- inferExpr db place env e
|
||||||
if want == got
|
case got of
|
||||||
then Right ()
|
InferNull -> Right ()
|
||||||
else Left (place ++ ": column " ++ c ++ " needs " ++ show want ++ ", got " ++ show got)
|
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 :: Database -> Table -> (String, Expr) -> Either String ()
|
||||||
checkValue = checkTyped "INSERT" []
|
checkValue db = checkTyped db "INSERT" (Scope [] [])
|
||||||
|
|
||||||
-- | 检查 UPDATE 的赋值
|
-- | 检查 UPDATE 的赋值
|
||||||
checkAssign :: Table -> (String, Expr) -> Either String ()
|
checkAssign :: Database -> Table -> (String, Expr) -> Either String ()
|
||||||
checkAssign t = checkTyped "UPDATE" (tableCols t) t
|
checkAssign db t = checkTyped db "UPDATE" (Scope (tableCols t) []) t
|
||||||
|
|
||||||
-- | 按语句类型分派检查
|
-- | 按语句类型分派检查
|
||||||
check :: Database -> Statement -> Either String ()
|
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
|
Select
|
||||||
{ selectCols = cols
|
{ selectCols = cols
|
||||||
, selectFrom = fromC
|
, selectFrom = fromC
|
||||||
, selectWhere = mWhere
|
, selectWhere = mWhere
|
||||||
|
, selectGroupBy = groupBy
|
||||||
, selectOrderBy = orderBy
|
, selectOrderBy = orderBy
|
||||||
} -> do
|
} -> do
|
||||||
env <- checkFrom db fromC
|
env <- checkFrom db outer fromC
|
||||||
checkColumns "SELECT" env cols
|
let full = Scope env outer
|
||||||
checkColumns "ORDER BY" env (map fst orderBy)
|
checkColumns "SELECT" full cols
|
||||||
mapM_ (checkBool "WHERE" env) mWhere
|
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
|
Insert tbl cols rows -> do
|
||||||
t <- lookupTable db tbl
|
t <- lookupTable db tbl
|
||||||
checkColumns "INSERT" (tableCols t) cols
|
checkColumns "INSERT" (Scope (tableCols t) []) cols
|
||||||
if null rows
|
if null rows
|
||||||
then Left "INSERT: no values"
|
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
|
Delete tbl mWhere -> do
|
||||||
t <- lookupTable db tbl
|
t <- lookupTable db tbl
|
||||||
mapM_ (checkBool "WHERE" (tableCols t)) mWhere
|
mapM_ (checkBool db "WHERE" (Scope (tableCols t) [])) mWhere
|
||||||
Update tbl assigns mWhere -> do
|
Update tbl assigns mWhere -> do
|
||||||
t <- lookupTable db tbl
|
t <- lookupTable db tbl
|
||||||
checkColumns "UPDATE" (tableCols t) (map fst assigns)
|
checkColumns "UPDATE" (Scope (tableCols t) []) (map fst assigns)
|
||||||
mapM_ (checkAssign t) assigns
|
mapM_ (checkAssign db t) assigns
|
||||||
mapM_ (checkBool "WHERE" (tableCols t)) mWhere
|
mapM_ (checkBool db "WHERE" (Scope (tableCols t) [])) mWhere
|
||||||
CreateTable name cols -> do
|
CreateTable name cols -> do
|
||||||
if null name
|
if null name
|
||||||
then Left "CREATE TABLE: empty table name"
|
then Left "CREATE TABLE: empty table name"
|
||||||
@@ -128,6 +356,7 @@ check db q = case q of
|
|||||||
if length names /= length (nub names)
|
if length names /= length (nub names)
|
||||||
then Left ("CREATE TABLE: duplicate column names in " ++ name)
|
then Left ("CREATE TABLE: duplicate column names in " ++ name)
|
||||||
else Right ()
|
else Right ()
|
||||||
|
checkColumnDefs (map fst cols) cols
|
||||||
DropTable name ->
|
DropTable name ->
|
||||||
if null name
|
if null name
|
||||||
then Left "DROP TABLE: empty table name"
|
then Left "DROP TABLE: empty table name"
|
||||||
@@ -150,9 +379,334 @@ check db q = case q of
|
|||||||
if col == idColumn
|
if col == idColumn
|
||||||
then Left "DROP COLUMN: the built-in id column cannot be dropped"
|
then Left "DROP COLUMN: the built-in id column cannot be dropped"
|
||||||
else Right ()
|
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
|
where
|
||||||
-- \| 表的列名清单
|
|
||||||
tableCols' = map fst . tableCols
|
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 :: String
|
||||||
idColumn = "id"
|
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)
|
||||||
@@ -8,14 +8,29 @@ import ChuSQL.Model (Column, Database, Row, Table (..), Value (..))
|
|||||||
-- 存储层抽象:上层只认 domain 类型,不关心底层实现。
|
-- 存储层抽象:上层只认 domain 类型,不关心底层实现。
|
||||||
|
|
||||||
data IndexResult
|
data IndexResult
|
||||||
= IndexRow (Maybe Row)
|
= IndexRows [Row]
|
||||||
| NoIndex
|
| NoIndex
|
||||||
deriving (Show, Eq)
|
deriving (Show, Eq)
|
||||||
|
|
||||||
class (Monad m) => MonadStorage m where
|
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])
|
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 ())
|
insert :: String -> Row -> m (Either String ())
|
||||||
|
|
||||||
@@ -23,7 +38,6 @@ class (Monad m) => MonadStorage m where
|
|||||||
insertMany :: String -> [Row] -> m (Either String ())
|
insertMany :: String -> [Row] -> m (Either String ())
|
||||||
insertMany t = go
|
insertMany t = go
|
||||||
where
|
where
|
||||||
-- \| 一行一行来,遇到错就停
|
|
||||||
go [] = pure (Right ())
|
go [] = pure (Right ())
|
||||||
go (r : rs) = do
|
go (r : rs) = do
|
||||||
x <- insert t r
|
x <- insert t r
|
||||||
@@ -39,7 +53,6 @@ class (Monad m) => MonadStorage m where
|
|||||||
Left e -> pure (Left e)
|
Left e -> pure (Left e)
|
||||||
Right rs -> replaceAll t [r | r <- rs, not (doomed r)]
|
Right rs -> replaceAll t [r | r <- rs, not (doomed r)]
|
||||||
where
|
where
|
||||||
-- \| 这一行的 id 在待删清单里吗
|
|
||||||
doomed r = case lookup "id" r of
|
doomed r = case lookup "id" r of
|
||||||
Just (VInt k) -> k `elem` ks
|
Just (VInt k) -> k `elem` ks
|
||||||
_ -> False
|
_ -> False
|
||||||
@@ -47,10 +60,19 @@ class (Monad m) => MonadStorage m where
|
|||||||
-- \| 整表替换
|
-- \| 整表替换
|
||||||
replaceAll :: String -> [Row] -> m (Either String ())
|
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)
|
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 ())
|
createTable :: String -> [(String, Column)] -> m (Either String ())
|
||||||
|
|
||||||
@@ -68,6 +90,9 @@ class (Monad m) => MonadStorage m where
|
|||||||
-- \| 删一列,连它的索引与数据一起去掉
|
-- \| 删一列,连它的索引与数据一起去掉
|
||||||
dropColumn :: String -> String -> m (Either String ())
|
dropColumn :: String -> String -> m (Either String ())
|
||||||
|
|
||||||
|
-- \| ALTER 用:整表换列定义与全部行
|
||||||
|
replaceSchema :: String -> [(String, Column)] -> [Row] -> m (Either String ())
|
||||||
|
|
||||||
-- \| 取全库快照(表名 + 列 + 全部行)
|
-- \| 取全库快照(表名 + 列 + 全部行)
|
||||||
snapshot :: m Database
|
snapshot :: m Database
|
||||||
|
|
||||||
@@ -75,5 +100,4 @@ class (Monad m) => MonadStorage m where
|
|||||||
schema :: m Database
|
schema :: m Database
|
||||||
schema = fmap (map withoutRows) snapshot
|
schema = fmap (map withoutRows) snapshot
|
||||||
where
|
where
|
||||||
-- \| 抹掉行,只留表名与列定义
|
|
||||||
withoutRows (name, t) = (name, t {tableRows = []})
|
withoutRows (name, t) = (name, t {tableRows = []})
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
{-# LANGUAGE CPP #-}
|
||||||
{-# LANGUAGE ImportQualifiedPost #-}
|
{-# LANGUAGE ImportQualifiedPost #-}
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
@@ -5,10 +6,14 @@ module ChuSQL.Storage.IPC (
|
|||||||
IPCStorage (..),
|
IPCStorage (..),
|
||||||
Request (..),
|
Request (..),
|
||||||
Response (..),
|
Response (..),
|
||||||
|
Account (..),
|
||||||
SchemaColumn (..),
|
SchemaColumn (..),
|
||||||
TableInfo (..),
|
TableInfo (..),
|
||||||
sendRequest,
|
sendRequest,
|
||||||
closeConnection,
|
closeConnection,
|
||||||
|
defaultPipeName,
|
||||||
|
setPipeName,
|
||||||
|
getPipeName,
|
||||||
doListTables,
|
doListTables,
|
||||||
doLookupByColumn,
|
doLookupByColumn,
|
||||||
doInsert,
|
doInsert,
|
||||||
@@ -21,8 +26,11 @@ module ChuSQL.Storage.IPC (
|
|||||||
|
|
||||||
import ChuSQL.Model
|
import ChuSQL.Model
|
||||||
import ChuSQL.Storage
|
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)
|
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 (FromJSON (..), ToJSON (..), eitherDecodeStrict, encode, object, withObject, (.:), (.:?), (.!=), (.=))
|
||||||
import Data.Aeson qualified as A
|
import Data.Aeson qualified as A
|
||||||
import Data.Aeson.Key qualified as K
|
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.Aeson.Types (Parser)
|
||||||
import Data.ByteString.Char8 qualified as BSC
|
import Data.ByteString.Char8 qualified as BSC
|
||||||
import Data.ByteString.Lazy qualified as BL
|
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 Data.Text qualified as T
|
||||||
import System.Environment (lookupEnv)
|
|
||||||
import System.IO
|
import System.IO
|
||||||
import System.IO.Unsafe (unsafePerformIO)
|
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 请求。
|
-- 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
|
newtype IPCStorage a = IPCStorage
|
||||||
{ runIPCStorage :: IO a
|
{ runIPCStorage :: IO a
|
||||||
@@ -58,13 +77,37 @@ instance Monad IPCStorage where
|
|||||||
connectionRef :: MVar (Maybe (FilePath, Handle))
|
connectionRef :: MVar (Maybe (FilePath, Handle))
|
||||||
connectionRef = unsafePerformIO (newMVar Nothing)
|
connectionRef = unsafePerformIO (newMVar Nothing)
|
||||||
|
|
||||||
-- | 打开管道
|
-- | 打开端点连接:Windows 把管道当文件打开,Unix 连一个 AF_UNIX 套接字
|
||||||
openPipe :: FilePath -> IO Handle
|
openPipe :: FilePath -> IO Handle
|
||||||
|
#if defined(mingw32_HOST_OS)
|
||||||
openPipe path = do
|
openPipe path = do
|
||||||
h <- openFile path ReadWriteMode
|
h <- openFile path ReadWriteMode
|
||||||
hSetBuffering h LineBuffering
|
hSetBuffering h LineBuffering
|
||||||
hSetNewlineMode h noNewlineTranslation
|
hSetNewlineMode h noNewlineTranslation
|
||||||
pure h
|
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 ()
|
closeCached :: Maybe (FilePath, Handle) -> IO ()
|
||||||
@@ -91,11 +134,34 @@ dropConnection = modifyMVar_ connectionRef $ \cached -> do
|
|||||||
closeConnection :: IO ()
|
closeConnection :: IO ()
|
||||||
closeConnection = dropConnection
|
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
|
pipePath :: IO String
|
||||||
|
#if defined(mingw32_HOST_OS)
|
||||||
pipePath = do
|
pipePath = do
|
||||||
name <- maybe "chusql-storage" id <$> lookupEnv "CHUSQL_PIPE"
|
name <- getPipeName
|
||||||
pure ("\\\\.\\pipe\\" ++ name)
|
pure ("\\\\.\\pipe\\" ++ name)
|
||||||
|
#else
|
||||||
|
pipePath = do
|
||||||
|
name <- getPipeName
|
||||||
|
dir <- socketDir
|
||||||
|
pure (dir ++ "/" ++ name ++ ".sock")
|
||||||
|
#endif
|
||||||
|
|
||||||
-- | 发一条请求,管道不通就重连
|
-- | 发一条请求,管道不通就重连
|
||||||
sendRequest :: Request -> IO Response
|
sendRequest :: Request -> IO Response
|
||||||
@@ -124,38 +190,83 @@ roundTrip req path = do
|
|||||||
data SchemaColumn = SchemaColumn
|
data SchemaColumn = SchemaColumn
|
||||||
{ scName :: String
|
{ scName :: String
|
||||||
, scType :: String
|
, scType :: String
|
||||||
|
, scNullable :: Bool
|
||||||
|
, scDefault :: Maybe Value
|
||||||
|
, scAutoIncrement :: Bool
|
||||||
|
, scPrimaryKey :: Bool
|
||||||
|
, scUnique :: Bool
|
||||||
|
, scCheck :: Maybe String
|
||||||
}
|
}
|
||||||
deriving (Show, Eq)
|
deriving (Show, Eq)
|
||||||
|
|
||||||
instance ToJSON SchemaColumn where
|
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
|
instance FromJSON SchemaColumn where
|
||||||
parseJSON = withObject "SchemaColumn" $ \o -> do
|
parseJSON = withObject "SchemaColumn" $ \o -> do
|
||||||
n <- o .: "name"
|
n <- o .: "name"
|
||||||
t <- o .: "ty"
|
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
|
data Request
|
||||||
= ReqPing
|
= 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
|
| ReqScan String
|
||||||
|
| ReqScanColumns String [String]
|
||||||
| ReqInsert String Row
|
| ReqInsert String Row
|
||||||
| ReqInsertBatch String [Row]
|
| ReqInsertBatch String [Row]
|
||||||
| ReqDeleteKeys String [Int]
|
| ReqDeleteKeys String [Int]
|
||||||
| ReqReplaceAll String [Row]
|
| ReqReplaceAll String [Row]
|
||||||
| ReqListTables
|
| ReqListTables
|
||||||
| ReqLookupByColumn String String Int
|
| ReqLookupByColumn String String Value
|
||||||
|
| ReqRangeByIndex String String (Maybe (Value, Bool)) (Maybe (Value, Bool))
|
||||||
| ReqDescribeTable String
|
| ReqDescribeTable String
|
||||||
| ReqCreateTable String [SchemaColumn]
|
| ReqCreateTable String [SchemaColumn]
|
||||||
| ReqDropTable String
|
| ReqDropTable String
|
||||||
| ReqCreateIndex String String
|
| ReqCreateIndex String String
|
||||||
| ReqDropIndex String String
|
| ReqDropIndex String String
|
||||||
| ReqDropColumn String String
|
| ReqDropColumn String String
|
||||||
|
| ReqReplaceSchema String [SchemaColumn] [Row]
|
||||||
| ReqListCatalog
|
| ReqListCatalog
|
||||||
|
|
||||||
instance ToJSON Request where
|
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 ReqPing = object ["method" .= ("ping" :: T.Text)]
|
||||||
toJSON (ReqScan t) = object ["method" .= ("scan" :: T.Text), "table" .= t]
|
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) =
|
toJSON (ReqInsert t r) =
|
||||||
object
|
object
|
||||||
[ "method" .= ("insert" :: T.Text)
|
[ "method" .= ("insert" :: T.Text)
|
||||||
@@ -186,7 +297,17 @@ instance ToJSON Request where
|
|||||||
[ "method" .= ("lookup_by_index" :: T.Text)
|
[ "method" .= ("lookup_by_index" :: T.Text)
|
||||||
, "table" .= t
|
, "table" .= t
|
||||||
, "column" .= c
|
, "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) =
|
toJSON (ReqDescribeTable t) =
|
||||||
object
|
object
|
||||||
@@ -219,6 +340,13 @@ instance ToJSON Request where
|
|||||||
, "table" .= t
|
, "table" .= t
|
||||||
, "column" .= c
|
, "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)]
|
toJSON ReqListCatalog = object ["method" .= ("list_catalog" :: T.Text)]
|
||||||
|
|
||||||
data TableInfo = TableInfo
|
data TableInfo = TableInfo
|
||||||
@@ -230,8 +358,31 @@ data TableInfo = TableInfo
|
|||||||
}
|
}
|
||||||
deriving (Show, Eq)
|
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
|
data Response
|
||||||
= RespPong
|
= RespPong
|
||||||
|
| RespAccounts [Account]
|
||||||
| RespRows [Row]
|
| RespRows [Row]
|
||||||
| RespTables [String]
|
| RespTables [String]
|
||||||
| RespSchema TableInfo
|
| RespSchema TableInfo
|
||||||
@@ -244,6 +395,7 @@ instance FromJSON Response where
|
|||||||
parseJSON = withObject "Response" $ \o -> do
|
parseJSON = withObject "Response" $ \o -> do
|
||||||
status <- o .: "status"
|
status <- o .: "status"
|
||||||
case status :: T.Text of
|
case status :: T.Text of
|
||||||
|
"accounts" -> RespAccounts <$> o .: "accounts"
|
||||||
"pong" -> pure RespPong
|
"pong" -> pure RespPong
|
||||||
"ok" -> pure RespOk
|
"ok" -> pure RespOk
|
||||||
"no_index" -> pure RespNoIndex
|
"no_index" -> pure RespNoIndex
|
||||||
@@ -256,7 +408,6 @@ instance FromJSON Response where
|
|||||||
RespCatalog <$> mapM parseTable xs
|
RespCatalog <$> mapM parseTable xs
|
||||||
other -> fail ("unknown status: " ++ T.unpack other)
|
other -> fail ("unknown status: " ++ T.unpack other)
|
||||||
where
|
where
|
||||||
-- \| 解一张表的 schema
|
|
||||||
parseTable :: A.Value -> Parser TableInfo
|
parseTable :: A.Value -> Parser TableInfo
|
||||||
parseTable = withObject "TableInfo" $ \o -> do
|
parseTable = withObject "TableInfo" $ \o -> do
|
||||||
t <- o .: "table"
|
t <- o .: "table"
|
||||||
@@ -268,7 +419,6 @@ instance FromJSON Response where
|
|||||||
stats <- mapM parseStat (sts :: [A.Value])
|
stats <- mapM parseStat (sts :: [A.Value])
|
||||||
pure (TableInfo t cols cnt idxCols stats)
|
pure (TableInfo t cols cnt idxCols stats)
|
||||||
|
|
||||||
-- \| 解一条列统计
|
|
||||||
parseStat :: A.Value -> Parser (String, Int, Bool)
|
parseStat :: A.Value -> Parser (String, Int, Bool)
|
||||||
parseStat = withObject "ColumnStat" $ \o -> do
|
parseStat = withObject "ColumnStat" $ \o -> do
|
||||||
name <- o .: "name"
|
name <- o .: "name"
|
||||||
@@ -278,16 +428,19 @@ instance FromJSON Response where
|
|||||||
|
|
||||||
-- | 值编码成 JSON
|
-- | 值编码成 JSON
|
||||||
valueToJSON :: Value -> A.Value
|
valueToJSON :: Value -> A.Value
|
||||||
|
valueToJSON VNull = A.Null
|
||||||
valueToJSON (VInt n) = A.Number (fromIntegral n)
|
valueToJSON (VInt n) = A.Number (fromIntegral n)
|
||||||
|
valueToJSON (VFloat d) = A.Number (fromFloatDigits d)
|
||||||
valueToJSON (VStr s) = A.String (T.pack s)
|
valueToJSON (VStr s) = A.String (T.pack s)
|
||||||
valueToJSON (VBool b) = A.Bool b
|
valueToJSON (VBool b) = A.Bool b
|
||||||
|
|
||||||
-- | JSON 解回值
|
-- | JSON 解回值
|
||||||
valueFromJSON :: A.Value -> Parser Value
|
valueFromJSON :: A.Value -> Parser Value
|
||||||
|
valueFromJSON A.Null = pure VNull
|
||||||
valueFromJSON (A.Number n) =
|
valueFromJSON (A.Number n) =
|
||||||
case floatingOrInteger n :: Either Double Integer of
|
case floatingOrInteger n :: Either Double Integer of
|
||||||
Right i -> pure (VInt (fromIntegral i))
|
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.String s) = pure (VStr (T.unpack s))
|
||||||
valueFromJSON (A.Bool b) = pure (VBool b)
|
valueFromJSON (A.Bool b) = pure (VBool b)
|
||||||
valueFromJSON _ = fail "unsupported value type"
|
valueFromJSON _ = fail "unsupported value type"
|
||||||
@@ -301,7 +454,6 @@ rowToJSON r =
|
|||||||
rowFromJSON :: A.Value -> Parser Row
|
rowFromJSON :: A.Value -> Parser Row
|
||||||
rowFromJSON (A.Object o) = mapM toPair (KM.toList o)
|
rowFromJSON (A.Object o) = mapM toPair (KM.toList o)
|
||||||
where
|
where
|
||||||
-- \| 解一个键值对
|
|
||||||
toPair (k, v) = do
|
toPair (k, v) = do
|
||||||
val <- valueFromJSON v
|
val <- valueFromJSON v
|
||||||
pure (K.toString k, val)
|
pure (K.toString k, val)
|
||||||
@@ -356,14 +508,25 @@ doListCatalog = ask "list_catalog" ReqListCatalog $ \resp -> case resp of
|
|||||||
RespCatalog xs -> Just xs
|
RespCatalog xs -> Just xs
|
||||||
_ -> Nothing
|
_ -> 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
|
doLookupByColumn t c k = ask "lookup_by_index" (ReqLookupByColumn t c k) $ \resp -> case resp of
|
||||||
RespRows [] -> Just (IndexRow Nothing)
|
RespRows rows -> Just (IndexRows rows)
|
||||||
RespRows (r : _) -> Just (IndexRow (Just r))
|
|
||||||
RespNoIndex -> Just NoIndex
|
RespNoIndex -> Just NoIndex
|
||||||
_ -> Nothing
|
_ -> 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
|
-- | 发 DescribeTable
|
||||||
doDescribeTable :: String -> IO (Either String TableInfo)
|
doDescribeTable :: String -> IO (Either String TableInfo)
|
||||||
doDescribeTable t = ask "describe_table" (ReqDescribeTable t) $ \resp -> case resp of
|
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 :: String -> String -> IO (Either String ())
|
||||||
doDropColumn t c = ask "drop_column" (ReqDropColumn t c) okOnly
|
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
|
-- | 发 CreateTable
|
||||||
doCreateTable :: String -> [SchemaColumn] -> IO (Either String ())
|
doCreateTable :: String -> [SchemaColumn] -> IO (Either String ())
|
||||||
doCreateTable t cols = ask "create_table" (ReqCreateTable t cols) okOnly
|
doCreateTable t cols = ask "create_table" (ReqCreateTable t cols) okOnly
|
||||||
@@ -392,16 +559,48 @@ doDropTable t = ask "drop_table" (ReqDropTable t) okOnly
|
|||||||
|
|
||||||
-- | 线上 schema 转本地列
|
-- | 线上 schema 转本地列
|
||||||
schemaToColumns :: [SchemaColumn] -> [(String, Column)]
|
schemaToColumns :: [SchemaColumn] -> [(String, Column)]
|
||||||
schemaToColumns = map go
|
schemaToColumns = map toColumn
|
||||||
where
|
|
||||||
go (SchemaColumn n "int") = (n, TInt)
|
toColumn :: SchemaColumn -> (String, Column)
|
||||||
go (SchemaColumn n "str") = (n, TStr)
|
toColumn sc =
|
||||||
go (SchemaColumn n "bool") = (n, TBool)
|
( scName sc
|
||||||
go (SchemaColumn n _) = (n, TStr)
|
, 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
|
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
|
||||||
scan t = IPCStorage (doScan t)
|
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(索引由存储层自己维护)
|
||||||
insert t r = IPCStorage (doInsert t r)
|
insert t r = IPCStorage (doInsert t r)
|
||||||
@@ -418,13 +617,14 @@ instance MonadStorage IPCStorage where
|
|||||||
-- \| 按某一列的索引取一行(没有索引就回 NoIndex)
|
-- \| 按某一列的索引取一行(没有索引就回 NoIndex)
|
||||||
lookupByColumn t c k = IPCStorage (doLookupByColumn t c k)
|
lookupByColumn t c k = IPCStorage (doLookupByColumn t c k)
|
||||||
|
|
||||||
|
-- \| 范围扫描(没有索引就回 Nothing)
|
||||||
|
scanRange t c lo hi = IPCStorage (doScanRange t c lo hi)
|
||||||
|
|
||||||
-- \| 发 CreateTable
|
-- \| 发 CreateTable
|
||||||
createTable name cols = IPCStorage (doCreateTable name (map toWire cols))
|
createTable name cols = IPCStorage (doCreateTable name (map toWire cols))
|
||||||
where
|
|
||||||
-- \| 本地列类型转线上字符串
|
-- \| ALTER:整表换列定义与全部行
|
||||||
toWire (n, TInt) = SchemaColumn n "int"
|
replaceSchema name cols rows = IPCStorage (doReplaceSchema name (map toWire cols) rows)
|
||||||
toWire (n, TStr) = SchemaColumn n "str"
|
|
||||||
toWire (n, TBool) = SchemaColumn n "bool"
|
|
||||||
|
|
||||||
-- \| 发 DropTable
|
-- \| 发 DropTable
|
||||||
dropTable name = IPCStorage (doDropTable name)
|
dropTable name = IPCStorage (doDropTable name)
|
||||||
@@ -445,7 +645,6 @@ instance MonadStorage IPCStorage where
|
|||||||
Left _ -> pure []
|
Left _ -> pure []
|
||||||
Right xs -> mapM loadEntry xs
|
Right xs -> mapM loadEntry xs
|
||||||
where
|
where
|
||||||
-- \| 加载一张表,行来自 scan
|
|
||||||
loadEntry info = do
|
loadEntry info = do
|
||||||
rows <- doScan (tiTable info)
|
rows <- doScan (tiTable info)
|
||||||
pure
|
pure
|
||||||
@@ -455,7 +654,9 @@ instance MonadStorage IPCStorage where
|
|||||||
|
|
||||||
-- \| 只问数据字典要结构,一行数据都不拉过来
|
-- \| 只问数据字典要结构,一行数据都不拉过来
|
||||||
schema = IPCStorage $ do
|
schema = IPCStorage $ do
|
||||||
result <- doListCatalog
|
result <- ask "all_catalogs" ReqAllCatalog $ \resp -> case resp of
|
||||||
|
RespCatalog infos -> Just infos
|
||||||
|
_ -> Nothing
|
||||||
pure $ case result of
|
pure $ case result of
|
||||||
Left _ -> []
|
Left _ -> []
|
||||||
Right xs ->
|
Right xs ->
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ instance MonadStorage MemoryStorage where
|
|||||||
-- \| 原样返回当前库
|
-- \| 原样返回当前库
|
||||||
snapshot = MemoryStorage $ \db -> Right (db, db)
|
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 :: String -> Table -> Database -> Database
|
||||||
replaceTable name table =
|
replaceTable name table =
|
||||||
|
|||||||
@@ -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]
|
{ selectCols :: [String]
|
||||||
, selectFrom :: FromClause
|
, selectFrom :: FromClause
|
||||||
, selectWhere :: Maybe Expr
|
, 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)]
|
, selectOrderBy :: [(String, SortDir)]
|
||||||
, selectLimit :: Maybe Int
|
, selectLimit :: Maybe Int
|
||||||
}
|
}
|
||||||
@@ -20,23 +29,83 @@ data Statement
|
|||||||
| CreateIndex String String
|
| CreateIndex String String
|
||||||
| DropIndex String String
|
| DropIndex String String
|
||||||
| DropColumn 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)
|
deriving (Show, Eq)
|
||||||
|
|
||||||
data FromClause
|
data FromClause
|
||||||
= FromTable (Maybe String) String
|
= 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)
|
deriving (Show, Eq)
|
||||||
|
|
||||||
data Expr
|
data Expr
|
||||||
= Col String
|
= Col String
|
||||||
|
| LitNull
|
||||||
| LitInt Int
|
| LitInt Int
|
||||||
|
| LitFloat Double
|
||||||
| LitStr String
|
| LitStr String
|
||||||
| LitBool Bool
|
| 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
|
| Gt Expr Expr
|
||||||
| Lt Expr Expr
|
| Lt Expr Expr
|
||||||
| Eq Expr Expr
|
| Eq Expr Expr
|
||||||
| And Expr Expr
|
| And Expr Expr
|
||||||
| Or 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)
|
deriving (Show, Eq)
|
||||||
|
|
||||||
data SortDir
|
data SortDir
|
||||||
@@ -51,6 +120,7 @@ makeSelect cols tbl w =
|
|||||||
{ selectCols = cols
|
{ selectCols = cols
|
||||||
, selectFrom = FromTable Nothing tbl
|
, selectFrom = FromTable Nothing tbl
|
||||||
, selectWhere = w
|
, selectWhere = w
|
||||||
|
, selectGroupBy = []
|
||||||
, selectOrderBy = []
|
, selectOrderBy = []
|
||||||
, selectLimit = Nothing
|
, selectLimit = Nothing
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
{-# LANGUAGE ImportQualifiedPost #-}
|
{-# 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 ChuSQL.Syntax.AST
|
||||||
|
import Control.Monad (void)
|
||||||
import Control.Monad.Combinators.Expr (Operator (..), makeExprParser)
|
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.List (intercalate)
|
||||||
import Data.Maybe (fromMaybe)
|
import Data.Maybe (fromMaybe)
|
||||||
import Data.Void (Void)
|
import Data.Void (Void)
|
||||||
@@ -19,7 +20,7 @@ type Parser = Parsec Void String
|
|||||||
|
|
||||||
-- | 跳过空白
|
-- | 跳过空白
|
||||||
sc :: Parser ()
|
sc :: Parser ()
|
||||||
sc = Lxr.space space1 empty empty
|
sc = Lxr.space space1 (Lxr.skipLineComment "--") (Lxr.skipBlockComment "/*" "*/")
|
||||||
|
|
||||||
-- | 词后面吃掉空白
|
-- | 词后面吃掉空白
|
||||||
lexeme :: Parser a -> Parser a
|
lexeme :: Parser a -> Parser a
|
||||||
@@ -42,6 +43,10 @@ identifier = lexeme $ do
|
|||||||
rest <- many (satisfy isIdentChar)
|
rest <- many (satisfy isIdentChar)
|
||||||
return (first : rest)
|
return (first : rest)
|
||||||
|
|
||||||
|
-- | 账号名:裸名字或单引号字符串(账号名允许点与短横线,字符串形式才写得出来)
|
||||||
|
userName :: Parser String
|
||||||
|
userName = identifier <|> stringLit
|
||||||
|
|
||||||
-- | 名字里允许的字符
|
-- | 名字里允许的字符
|
||||||
isIdentChar :: Char -> Bool
|
isIdentChar :: Char -> Bool
|
||||||
isIdentChar ch = isAlphaNum ch || ch == '_'
|
isIdentChar ch = isAlphaNum ch || ch == '_'
|
||||||
@@ -50,6 +55,10 @@ isIdentChar ch = isAlphaNum ch || ch == '_'
|
|||||||
integer :: Parser Int
|
integer :: Parser Int
|
||||||
integer = lexeme Lxr.decimal
|
integer = lexeme Lxr.decimal
|
||||||
|
|
||||||
|
-- | 读一个浮点数(必须有小数点或指数)
|
||||||
|
floatLit :: Parser Double
|
||||||
|
floatLit = lexeme Lxr.float
|
||||||
|
|
||||||
-- | 读一个字符串字面量
|
-- | 读一个字符串字面量
|
||||||
stringLit :: Parser String
|
stringLit :: Parser String
|
||||||
stringLit = lexeme $ do
|
stringLit = lexeme $ do
|
||||||
@@ -66,6 +75,15 @@ stringChar =
|
|||||||
, satisfy (/= '\'')
|
, 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 :: Parser SortDir
|
||||||
sortDir = (Desc <$ keyword "desc") <|> (Asc <$ keyword "asc") <|> pure Asc
|
sortDir = (Desc <$ keyword "desc") <|> (Asc <$ keyword "asc") <|> pure Asc
|
||||||
@@ -83,7 +101,6 @@ qualifiedName = do
|
|||||||
rest <- many (symbol "." *> identifier)
|
rest <- many (symbol "." *> identifier)
|
||||||
return (intercalate "." (first : rest))
|
return (intercalate "." (first : rest))
|
||||||
|
|
||||||
|
|
||||||
-- | 读一个表达式
|
-- | 读一个表达式
|
||||||
expr :: Parser Expr
|
expr :: Parser Expr
|
||||||
expr = makeExprParser atom operatorTable
|
expr = makeExprParser atom operatorTable
|
||||||
@@ -91,11 +108,17 @@ expr = makeExprParser atom operatorTable
|
|||||||
-- | 运算符优先级(从紧到松)
|
-- | 运算符优先级(从紧到松)
|
||||||
operatorTable :: [[Operator Parser Expr]]
|
operatorTable :: [[Operator Parser Expr]]
|
||||||
operatorTable =
|
operatorTable =
|
||||||
[
|
[ [Prefix (Neg <$ symbol "-")]
|
||||||
[ InfixN (Gt <$ symbol ">")
|
, [InfixL (Mul <$ symbol "*"), InfixL (Div <$ symbol "/")]
|
||||||
|
, [InfixL (Add <$ symbol "+"), InfixL (Sub <$ symbol "-")]
|
||||||
|
, [ InfixN (Gt <$ symbol ">")
|
||||||
, InfixN (Lt <$ symbol "<")
|
, InfixN (Lt <$ symbol "<")
|
||||||
, InfixN (Eq <$ 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 (And <$ keyword "AND")]
|
||||||
, [InfixL (Or <$ keyword "OR")]
|
, [InfixL (Or <$ keyword "OR")]
|
||||||
]
|
]
|
||||||
@@ -104,14 +127,79 @@ operatorTable =
|
|||||||
atom :: Parser Expr
|
atom :: Parser Expr
|
||||||
atom =
|
atom =
|
||||||
choice
|
choice
|
||||||
[ LitBool True <$ keyword "TRUE"
|
[ LitNull <$ keyword "null"
|
||||||
|
, LitBool True <$ keyword "TRUE"
|
||||||
, LitBool False <$ keyword "FALSE"
|
, LitBool False <$ keyword "FALSE"
|
||||||
|
, try (LitDate <$> (keyword "date" *> stringLit))
|
||||||
|
, try (LitTimestamp <$> (keyword "timestamp" *> stringLit))
|
||||||
|
, try (LitBlob <$> blobLit)
|
||||||
|
, LitFloat <$> try floatLit
|
||||||
, LitInt <$> integer
|
, LitInt <$> integer
|
||||||
, LitStr <$> stringLit
|
, LitStr <$> stringLit
|
||||||
|
, try aggregateCall
|
||||||
|
, try existsPredicate
|
||||||
, Col <$> qualifiedName
|
, Col <$> qualifiedName
|
||||||
|
, try scalarSubquery
|
||||||
, between (symbol "(") (symbol ")") expr
|
, 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)
|
orderItem :: Parser (String, SortDir)
|
||||||
@@ -127,6 +215,13 @@ orderByClause = do
|
|||||||
keyword "by"
|
keyword "by"
|
||||||
sepBy1 orderItem (symbol ",")
|
sepBy1 orderItem (symbol ",")
|
||||||
|
|
||||||
|
-- | GROUP BY 的列表
|
||||||
|
groupByClause :: Parser [String]
|
||||||
|
groupByClause = do
|
||||||
|
keyword "group"
|
||||||
|
keyword "by"
|
||||||
|
sepBy1 qualifiedName (symbol ",")
|
||||||
|
|
||||||
-- | SET 里的一条赋值
|
-- | SET 里的一条赋值
|
||||||
assignment :: Parser (String, Expr)
|
assignment :: Parser (String, Expr)
|
||||||
assignment = do
|
assignment = do
|
||||||
@@ -135,16 +230,21 @@ assignment = do
|
|||||||
e <- expr
|
e <- expr
|
||||||
return (col, e)
|
return (col, e)
|
||||||
|
|
||||||
|
|
||||||
reservedWords :: [String]
|
reservedWords :: [String]
|
||||||
reservedWords =
|
reservedWords =
|
||||||
[ "select"
|
[ "select"
|
||||||
|
, "as"
|
||||||
, "from"
|
, "from"
|
||||||
, "where"
|
, "where"
|
||||||
, "order"
|
, "order"
|
||||||
, "by"
|
, "by"
|
||||||
, "limit"
|
, "limit"
|
||||||
, "join"
|
, "join"
|
||||||
|
, "left"
|
||||||
|
, "outer"
|
||||||
|
, "inner"
|
||||||
|
, "exists"
|
||||||
|
, "in"
|
||||||
, "on"
|
, "on"
|
||||||
, "and"
|
, "and"
|
||||||
, "or"
|
, "or"
|
||||||
@@ -161,7 +261,23 @@ reservedWords =
|
|||||||
, "index"
|
, "index"
|
||||||
, "drop"
|
, "drop"
|
||||||
, "alter"
|
, "alter"
|
||||||
|
, "add"
|
||||||
|
, "rename"
|
||||||
|
, "to"
|
||||||
, "column"
|
, "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 :: Parser (Maybe String, String)
|
||||||
tableRef = do
|
tableRef = do
|
||||||
tbl <- identifier
|
tbl <- tableName
|
||||||
mAlias <- optional aliasName
|
mAlias <- optional ((keyword "as" *> aliasName) <|> aliasName)
|
||||||
return (mAlias, tbl)
|
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
|
-- | 一个 JOIN ... ON
|
||||||
joinClause :: Parser (Maybe String, String, Expr)
|
joinClause :: Parser (JoinKind, Maybe String, String, Expr)
|
||||||
joinClause = do
|
joinClause = do
|
||||||
keyword "join"
|
kind <- joinKind
|
||||||
(mAlias, tbl) <- tableRef
|
(mAlias, tbl) <- tableRef
|
||||||
keyword "on"
|
keyword "on"
|
||||||
cond <- expr
|
cond <- expr
|
||||||
return (mAlias, tbl, cond)
|
return (kind, mAlias, tbl, cond)
|
||||||
|
|
||||||
-- | FROM 子句(可含多个 JOIN)
|
-- | FROM 子句(可含多个 JOIN)
|
||||||
fromClause :: Parser FromClause
|
fromClause :: Parser FromClause
|
||||||
fromClause = do
|
fromClause = do
|
||||||
(mAlias, tbl) <- tableRef
|
(mAlias, tbl) <- tableRef
|
||||||
joins <- many joinClause
|
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 的列清单
|
-- | SELECT 的列清单
|
||||||
selectList :: Parser [String]
|
selectList :: Parser [(String, Expr)]
|
||||||
selectList =
|
selectList =
|
||||||
choice
|
choice
|
||||||
[ ["*"] <$ symbol "*"
|
[ [("*", Col "*")] <$ symbol "*"
|
||||||
, (:) <$> qualifiedName <*> many (symbol "," *> qualifiedName)
|
, 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
|
-- | 读 CREATE TABLE
|
||||||
createTableStatement :: Parser Statement
|
createTableStatement :: Parser Statement
|
||||||
createTableStatement = do
|
createTableStatement = do
|
||||||
keyword "create"
|
keyword "create"
|
||||||
keyword "table"
|
keyword "table"
|
||||||
name <- identifier
|
name <- tableName
|
||||||
cols <- between (symbol "(") (symbol ")") (sepBy1 columnDef (symbol ","))
|
cols <- between (symbol "(") (symbol ")") (sepBy1 columnDef (symbol ","))
|
||||||
return (CreateTable name cols)
|
return (CreateTable name cols)
|
||||||
|
|
||||||
@@ -216,7 +344,7 @@ dropTableStatement :: Parser Statement
|
|||||||
dropTableStatement = do
|
dropTableStatement = do
|
||||||
keyword "drop"
|
keyword "drop"
|
||||||
keyword "table"
|
keyword "table"
|
||||||
name <- identifier
|
name <- tableName
|
||||||
return (DropTable name)
|
return (DropTable name)
|
||||||
|
|
||||||
-- | 读 CREATE INDEX(列名即索引名)
|
-- | 读 CREATE INDEX(列名即索引名)
|
||||||
@@ -225,7 +353,7 @@ createIndexStatement = do
|
|||||||
keyword "create"
|
keyword "create"
|
||||||
keyword "index"
|
keyword "index"
|
||||||
keyword "on"
|
keyword "on"
|
||||||
tbl <- identifier
|
tbl <- tableName
|
||||||
col <- between (symbol "(") (symbol ")") identifier
|
col <- between (symbol "(") (symbol ")") identifier
|
||||||
return (CreateIndex tbl col)
|
return (CreateIndex tbl col)
|
||||||
|
|
||||||
@@ -235,64 +363,223 @@ dropIndexStatement = do
|
|||||||
keyword "drop"
|
keyword "drop"
|
||||||
keyword "index"
|
keyword "index"
|
||||||
keyword "on"
|
keyword "on"
|
||||||
tbl <- identifier
|
tbl <- tableName
|
||||||
col <- between (symbol "(") (symbol ")") identifier
|
col <- between (symbol "(") (symbol ")") identifier
|
||||||
return (DropIndex tbl col)
|
return (DropIndex tbl col)
|
||||||
|
|
||||||
-- | 读 ALTER TABLE ... DROP COLUMN
|
-- | 读 ALTER TABLE:加列、删列、改名、改类型、改默认值、改可空
|
||||||
alterDropColumnStatement :: Parser Statement
|
alterTableStatement :: Parser Statement
|
||||||
alterDropColumnStatement = do
|
alterTableStatement = do
|
||||||
keyword "alter"
|
keyword "alter"
|
||||||
keyword "table"
|
keyword "table"
|
||||||
tbl <- identifier
|
tbl <- tableName
|
||||||
|
choice
|
||||||
|
[ do
|
||||||
|
keyword "add"
|
||||||
|
void (optional (keyword "column"))
|
||||||
|
(name, col) <- columnDef
|
||||||
|
pure (AddColumn tbl (name, col))
|
||||||
|
, do
|
||||||
keyword "drop"
|
keyword "drop"
|
||||||
keyword "column"
|
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
|
col <- identifier
|
||||||
return (DropColumn tbl col)
|
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 :: Parser (String, Column)
|
||||||
columnDef = do
|
columnDef = do
|
||||||
name <- identifier
|
name <- identifier
|
||||||
ty <- columnType
|
ty <- columnTypeP
|
||||||
return (name, ty)
|
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
|
columnTypeP :: Parser ColumnType
|
||||||
columnType =
|
columnTypeP =
|
||||||
(keyword "integer" >> pure TInt)
|
choice
|
||||||
<|> (keyword "int" >> pure TInt)
|
[ keyword "integer" >> pure CInt
|
||||||
<|> (keyword "varchar" >> pure TStr)
|
, keyword "int" >> pure CInt
|
||||||
<|> (keyword "text" >> pure TStr)
|
, keyword "bigint" >> pure CBigInt
|
||||||
<|> (keyword "str" >> pure TStr)
|
, keyword "smallint" >> pure CSmallInt
|
||||||
<|> (keyword "boolean" >> pure TBool)
|
, (keyword "varchar" >>) (maybe CStr CVarchar <$> optional sizeParen)
|
||||||
<|> (keyword "bool" >> pure TBool)
|
, (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
|
-- | 读 SELECT
|
||||||
selectStatement :: Parser Statement
|
selectStatement :: Parser Statement
|
||||||
selectStatement = do
|
selectStatement = do
|
||||||
keyword "select"
|
keyword "select"
|
||||||
cols <- selectList
|
cols <- selectList
|
||||||
keyword "from"
|
fromC <- fromMaybe FromUnit <$> optional (keyword "from" *> fromClause)
|
||||||
fromC <- fromClause
|
|
||||||
mWhere <- optional (keyword "where" *> expr)
|
mWhere <- optional (keyword "where" *> expr)
|
||||||
|
groupBy <- fromMaybe [] <$> optional groupByClause
|
||||||
orderBy <- fromMaybe [] <$> optional orderByClause
|
orderBy <- fromMaybe [] <$> optional orderByClause
|
||||||
mLimit <- optional limitClause
|
mLimit <- optional limitClause
|
||||||
return
|
pure $ if all (\(name, e) -> e == Col name) cols
|
||||||
Select
|
then Select (map fst cols) fromC mWhere groupBy orderBy mLimit
|
||||||
{ selectCols = cols
|
else SelectExpr cols fromC mWhere groupBy orderBy mLimit
|
||||||
, selectFrom = fromC
|
|
||||||
, selectWhere = mWhere
|
|
||||||
, selectOrderBy = orderBy
|
|
||||||
, selectLimit = mLimit
|
|
||||||
}
|
|
||||||
|
|
||||||
-- | 读 INSERT,可一次插多行
|
-- | 读 INSERT,可一次插多行
|
||||||
insertStatement :: Parser Statement
|
insertStatement :: Parser Statement
|
||||||
insertStatement = do
|
insertStatement = do
|
||||||
keyword "INSERT"
|
keyword "INSERT"
|
||||||
keyword "INTO"
|
keyword "INTO"
|
||||||
tbl <- identifier
|
tbl <- tableName
|
||||||
cols <-
|
cols <-
|
||||||
between
|
between
|
||||||
(symbol "(")
|
(symbol "(")
|
||||||
@@ -304,14 +591,14 @@ insertStatement = do
|
|||||||
|
|
||||||
-- | 一行值:一对括号里的若干字面量
|
-- | 一行值:一对括号里的若干字面量
|
||||||
valueRow :: Parser [Expr]
|
valueRow :: Parser [Expr]
|
||||||
valueRow = between (symbol "(") (symbol ")") (sepBy1 atom (symbol ","))
|
valueRow = between (symbol "(") (symbol ")") (sepBy1 expr (symbol ","))
|
||||||
|
|
||||||
-- | 读 DELETE
|
-- | 读 DELETE
|
||||||
deleteStatement :: Parser Statement
|
deleteStatement :: Parser Statement
|
||||||
deleteStatement = do
|
deleteStatement = do
|
||||||
keyword "DELETE"
|
keyword "DELETE"
|
||||||
keyword "FROM"
|
keyword "FROM"
|
||||||
tbl <- identifier
|
tbl <- tableName
|
||||||
mWhere <- optional (keyword "WHERE" *> expr)
|
mWhere <- optional (keyword "WHERE" *> expr)
|
||||||
return (Delete tbl mWhere)
|
return (Delete tbl mWhere)
|
||||||
|
|
||||||
@@ -319,7 +606,7 @@ deleteStatement = do
|
|||||||
updateStatement :: Parser Statement
|
updateStatement :: Parser Statement
|
||||||
updateStatement = do
|
updateStatement = do
|
||||||
keyword "update"
|
keyword "update"
|
||||||
tbl <- identifier
|
tbl <- tableName
|
||||||
keyword "set"
|
keyword "set"
|
||||||
assigns <- sepBy1 assignment (symbol ",")
|
assigns <- sepBy1 assignment (symbol ",")
|
||||||
mWhere <- optional (keyword "where" *> expr)
|
mWhere <- optional (keyword "where" *> expr)
|
||||||
@@ -332,14 +619,38 @@ parseStatement input =
|
|||||||
Left err -> Left (errorBundlePretty err)
|
Left err -> Left (errorBundlePretty err)
|
||||||
Right q -> Right q
|
Right q -> Right q
|
||||||
where
|
where
|
||||||
-- \| 依次尝试各种语句
|
|
||||||
statementP =
|
statementP =
|
||||||
selectStatement
|
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
|
<|> insertStatement
|
||||||
<|> deleteStatement
|
<|> deleteStatement
|
||||||
<|> updateStatement
|
<|> updateStatement
|
||||||
|
<|> try createUserStatement
|
||||||
|
<|> try alterUserStatement
|
||||||
|
<|> try dropUserStatement
|
||||||
|
<|> try createRoleStatement
|
||||||
|
<|> try dropRoleStatement
|
||||||
|
<|> try grantStatement
|
||||||
|
<|> try revokeStatement
|
||||||
<|> try createIndexStatement
|
<|> try createIndexStatement
|
||||||
<|> try createTableStatement
|
<|> try createTableStatement
|
||||||
<|> try dropIndexStatement
|
<|> try dropIndexStatement
|
||||||
<|> try alterDropColumnStatement
|
<|> try alterTableStatement
|
||||||
<|> dropTableStatement
|
<|> 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
File diff suppressed because it is too large.
Load diff
@@ -1,40 +1,42 @@
|
|||||||
# ChuSQL 存储服务配置示例
|
# ChuSQL 存储服务配置示例
|
||||||
#
|
#
|
||||||
# 用法:把本文件复制成 chusql-storage.toml(放在启动 server 时的当前目录下),
|
# 用法:安装时把本文件放到固定的用户配置目录(Windows 是 %APPDATA%\ChuSQL\chusql.toml,
|
||||||
# 或者让环境变量 CHUSQL_CONFIG 指向任意路径的这个文件。
|
# 其它平台是 ${XDG_CONFIG_HOME:-$HOME/.config}/ChuSQL/chusql.toml);
|
||||||
|
# 临时换一份就用命令行参数 --config <path>(没有环境变量通道了)。
|
||||||
#
|
#
|
||||||
# 每一项都可以删掉:没写的项回退到内置默认值(下面注释里的值就是默认值)。
|
# 每一项都可以删掉:没写的项回退到内置默认值(下面注释里的值就是默认值)。
|
||||||
# 优先级:环境变量 > 本文件 > 内置默认值。
|
# 优先级:本文件 > 内置默认值(没有配置键走环境变量了)。
|
||||||
# 启动时 server 会把"读了哪个文件、每个选项从哪来"打进日志([INFO] 开头那几行)。
|
# 启动时 server 会把"读了哪个文件、每个选项从哪来"打进日志([INFO] 开头那几行)。
|
||||||
|
|
||||||
[page]
|
[page]
|
||||||
# 页大小(字节):512 ~ 65536 之间的 2 的幂。
|
# 页大小(字节):512 ~ 65536 之间的 2 的幂。
|
||||||
# 注意:改它会让已有数据文件(.db)读不出来(文件长度对不上页大小会直接报错)。
|
# 注意:改它会让已有数据文件(.db)读不出来(文件长度对不上页大小会直接报错)。
|
||||||
# 环境变量:CHUSQL_PAGE_SIZE
|
|
||||||
size = 4096
|
size = 4096
|
||||||
|
|
||||||
[btree]
|
[btree]
|
||||||
# B+ 树一个节点最多几个孩子(>= 3,且必须能放进一页:12 + (order-1)*8 + order*8 <= size)。
|
# B+ 树一个节点最多几个孩子(>= 3,且必须能放进一页:12 + (order-1)*8 + order*8 <= size)。
|
||||||
# 注意:改它会让已有索引文件(.idx)对不上文件头里记的值,打开时报错。
|
# 注意:改它会让已有索引文件(.idx)对不上文件头里记的值,打开时报错。
|
||||||
# 环境变量:CHUSQL_BTREE_ORDER
|
|
||||||
order = 4
|
order = 4
|
||||||
|
|
||||||
|
[buffer]
|
||||||
|
# 缓冲池里最多放几页(>= 1)。
|
||||||
|
pool_size = 1024
|
||||||
|
|
||||||
[storage]
|
[storage]
|
||||||
# 数据目录:一张表一个 <表名>.db,一个索引一个 <表名>.idx。
|
# 数据目录:一张表一个 <表名>.db,一个索引一个 <表名>.idx。
|
||||||
# 环境变量:CHUSQL_DATA_DIR
|
# 不写就跟随平台惯例(Windows %LOCALAPPDATA%\ChuSQL\data,
|
||||||
data_dir = "../localdata"
|
# Unix ${XDG_DATA_HOME:-$HOME/.local/share}/chusql/data,跟安装脚本装的位置一致);
|
||||||
|
# 要固定就写绝对路径,例如:
|
||||||
|
# data_dir = "/srv/chusql/data"
|
||||||
|
|
||||||
[server]
|
[server]
|
||||||
# 命名管道名(Windows 上实际是 \\.\pipe\<这个名字>)。
|
# 命名管道名(Windows 上实际是 \\.\pipe\<这个名字>)。
|
||||||
# 环境变量:CHUSQL_PIPE
|
pipe_name = "chusql-joint"
|
||||||
pipe_name = "chusql-storage"
|
|
||||||
|
|
||||||
[log]
|
[log]
|
||||||
# 日志等级:off / error / warn / info / debug。debug 会多打每个请求和连接。
|
# 日志等级:off / error / warn / info / debug。debug 会多打每个请求和连接。
|
||||||
# 环境变量:CHUSQL_LOG
|
|
||||||
level = "info"
|
level = "info"
|
||||||
|
|
||||||
# 颜色不用写在文件里,用环境变量控制:CHUSQL_LOG_COLOR = auto(默认,stderr 是终端才着色)
|
# 颜色没有配置项也没有环境变量:要写的那条流是终端就着色,重定向到文件就不着色。
|
||||||
# / always / never
|
|
||||||
# 行格式:[等级 本地日期时间] [来源] 消息;来源 core=服务自身、pipe=由管道请求触发
|
# 行格式:[等级 本地日期时间] [来源] 消息;来源 core=服务自身、pipe=由管道请求触发
|
||||||
# ERROR 加粗红、WARN 加粗橙、DEBUG 加粗不变色、INFO 不加样式
|
# ERROR 加粗红、WARN 加粗橙、DEBUG 加粗不变色、INFO 不加样式
|
||||||
File diff suppressed because it is too large.
Load diff
+302
-291
@@ -3,84 +3,77 @@ use std::path::Path;
|
|||||||
|
|
||||||
use crate::page::{Page, PageFile, PageId};
|
use crate::page::{Page, PageFile, PageId};
|
||||||
|
|
||||||
// 磁盘 B+ 树:节点占一页,文件头记页大小与 order。
|
// 磁盘 B+ 树:键是变长字节串,允许重复键,叶子链支持范围扫描。
|
||||||
|
|
||||||
const META_PAGE: PageId = 0;
|
const META_PAGE: PageId = 0;
|
||||||
const META_MAGIC: &[u8; 4] = b"CBTR";
|
const META_MAGIC: &[u8; 4] = b"CBK2";
|
||||||
const OFF_META_PAGE_SIZE: usize = 4;
|
const OFF_PAGE_SIZE: usize = 4;
|
||||||
const OFF_META_ORDER: usize = 8;
|
const OFF_MAX_KEY: usize = 8;
|
||||||
const OFF_META_ROOT: usize = 10;
|
const OFF_ROOT: usize = 10;
|
||||||
|
const OFF_ORDER: usize = 18;
|
||||||
|
|
||||||
const NODE_LEAF: u8 = 0;
|
const NODE_LEAF: u8 = 0;
|
||||||
const NODE_INTERNAL: u8 = 1;
|
const NODE_INTERNAL: u8 = 1;
|
||||||
const OFF_TYPE: usize = 0;
|
const OFF_KIND: usize = 0;
|
||||||
const OFF_COUNT: usize = 1;
|
const OFF_COUNT: usize = 1;
|
||||||
const OFF_NEXT: usize = 3;
|
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 {
|
pub const MAX_KEY_BYTES: usize = 64;
|
||||||
OFF_KEYS + max_keys(order) * 8 + order * 8
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 最多几个键
|
/// 一个节点最多几个键
|
||||||
fn max_keys(order: usize) -> usize {
|
fn max_keys(order: usize) -> usize {
|
||||||
order - 1
|
order - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 值区域的起点
|
/// 一个格子最多占多少字节
|
||||||
fn off_payload(order: usize) -> usize {
|
fn max_cell_bytes() -> usize {
|
||||||
OFF_KEYS + max_keys(order) * 8
|
CELL_FIXED + MAX_KEY_BYTES
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 这个 order 放得进一页吗
|
/// 这个 order 放得进一页吗
|
||||||
pub fn fits_in_page(page_size: usize, order: usize) -> bool {
|
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 {
|
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 {
|
fn read_u8(p: &Page, off: usize) -> u8 {
|
||||||
p.data[off]
|
p.data[off]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 写一个 u8
|
|
||||||
fn write_u8(p: &mut Page, off: usize, v: u8) {
|
fn write_u8(p: &mut Page, off: usize, v: u8) {
|
||||||
p.data[off] = v;
|
p.data[off] = v;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读一个小端 u16
|
|
||||||
fn read_u16(p: &Page, off: usize) -> u16 {
|
fn read_u16(p: &Page, off: usize) -> u16 {
|
||||||
u16::from_le_bytes([p.data[off], p.data[off + 1]])
|
u16::from_le_bytes([p.data[off], p.data[off + 1]])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 写一个小端 u16
|
|
||||||
fn write_u16(p: &mut Page, off: usize, v: u16) {
|
fn write_u16(p: &mut Page, off: usize, v: u16) {
|
||||||
p.data[off..off + 2].copy_from_slice(&v.to_le_bytes());
|
p.data[off..off + 2].copy_from_slice(&v.to_le_bytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读一个小端 u32
|
|
||||||
fn read_u32(p: &Page, off: usize) -> 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::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) {
|
fn write_u32(p: &mut Page, off: usize, v: u32) {
|
||||||
p.data[off..off + 4].copy_from_slice(&v.to_le_bytes());
|
p.data[off..off + 4].copy_from_slice(&v.to_le_bytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读一个小端 u64
|
|
||||||
fn read_u64(p: &Page, off: usize) -> u64 {
|
fn read_u64(p: &Page, off: usize) -> u64 {
|
||||||
let mut b = [0u8; 8];
|
let mut b = [0u8; 8];
|
||||||
b.copy_from_slice(&p.data[off..off + 8]);
|
b.copy_from_slice(&p.data[off..off + 8]);
|
||||||
u64::from_le_bytes(b)
|
u64::from_le_bytes(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 写一个小端 u64
|
|
||||||
fn write_u64(p: &mut Page, off: usize, v: u64) {
|
fn write_u64(p: &mut Page, off: usize, v: u64) {
|
||||||
p.data[off..off + 8].copy_from_slice(&v.to_le_bytes());
|
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) {
|
fn write_meta(page: &mut Page, page_size: usize, order: usize, root: PageId) {
|
||||||
page.zero();
|
page.zero();
|
||||||
page.data[0..4].copy_from_slice(META_MAGIC);
|
page.data[0..4].copy_from_slice(META_MAGIC);
|
||||||
write_u32(page, OFF_META_PAGE_SIZE, page_size as u32);
|
write_u32(page, OFF_PAGE_SIZE, page_size as u32);
|
||||||
write_u16(page, OFF_META_ORDER, order as u16);
|
write_u16(page, OFF_MAX_KEY, MAX_KEY_BYTES as u16);
|
||||||
write_u64(page, OFF_META_ROOT, root);
|
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)",
|
"not a chusql index file (bad magic)",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let stored_page = read_u32(page, OFF_META_PAGE_SIZE) as usize;
|
let stored_page = read_u32(page, OFF_PAGE_SIZE) as usize;
|
||||||
let stored_order = read_u16(page, OFF_META_ORDER) as usize;
|
let stored_order = read_u16(page, OFF_ORDER) as usize;
|
||||||
if stored_page != page_size || stored_order != order {
|
if stored_page != page_size || stored_order != order {
|
||||||
return Err(io::Error::new(
|
return Err(io::Error::new(
|
||||||
io::ErrorKind::InvalidData,
|
io::ErrorKind::InvalidData,
|
||||||
@@ -116,89 +110,114 @@ fn check_meta(page: &Page, page_size: usize, order: usize) -> io::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
enum NodeData {
|
/// 一个节点:叶子存行位置,内部节点存孩子页号
|
||||||
Leaf {
|
struct Node {
|
||||||
keys: Vec<i64>,
|
kind: u8,
|
||||||
values: Vec<u64>,
|
|
||||||
next: PageId,
|
next: PageId,
|
||||||
},
|
keys: Vec<Vec<u8>>,
|
||||||
Internal {
|
values: Vec<u64>,
|
||||||
keys: Vec<i64>,
|
}
|
||||||
children: Vec<PageId>,
|
|
||||||
},
|
/// 一个格子占多少字节
|
||||||
|
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 {
|
fn get_node(p: &Page) -> Node {
|
||||||
let ty = read_u8(p, OFF_TYPE);
|
let kind = read_u8(p, OFF_KIND);
|
||||||
let n = read_u16(p, OFF_COUNT) as usize;
|
let count = read_u16(p, OFF_COUNT) as usize;
|
||||||
let payload = off_payload(order);
|
let trailer = read_u64(p, OFF_NEXT);
|
||||||
if ty == NODE_LEAF {
|
let mut keys = Vec::with_capacity(count);
|
||||||
let next = read_u64(p, OFF_NEXT);
|
let mut values = Vec::with_capacity(count + 1);
|
||||||
let mut keys = Vec::with_capacity(n);
|
for i in 0..count {
|
||||||
let mut values = Vec::with_capacity(n);
|
let off = read_u16(p, HEADER + i * SLOT) as usize;
|
||||||
for i in 0..n {
|
let klen = read_u16(p, off) as usize;
|
||||||
keys.push(read_u64(p, OFF_KEYS + i * 8) as i64);
|
keys.push(p.data[off + 2..off + 2 + klen].to_vec());
|
||||||
values.push(read_u64(p, payload + i * 8));
|
values.push(read_u64(p, off + 2 + klen));
|
||||||
}
|
}
|
||||||
NodeData::Leaf { keys, values, next }
|
if kind == NODE_INTERNAL {
|
||||||
|
values.push(trailer);
|
||||||
|
Node { kind, next: 0, keys, values }
|
||||||
} else {
|
} else {
|
||||||
let mut keys = Vec::with_capacity(n);
|
Node { kind, next: trailer, keys, values }
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 把节点写回一页
|
/// 叶子里的插入位置:同键按 value 排,保证顺序稳定
|
||||||
fn write_node(p: &mut Page, node: &NodeData, order: usize) {
|
fn leaf_slot(keys: &[Vec<u8>], values: &[u64], key: &[u8], value: u64) -> usize {
|
||||||
p.zero();
|
let mut i = keys.partition_point(|k| k.as_slice() < key);
|
||||||
let payload = off_payload(order);
|
while i < keys.len() && keys[i].as_slice() == key && values[i] < value {
|
||||||
match node {
|
i += 1;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
i
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 二分选孩子
|
/// 内部节点里选哪个孩子
|
||||||
fn child_index(keys: &[i64], key: i64) -> usize {
|
fn child_slot(keys: &[Vec<u8>], key: &[u8]) -> usize {
|
||||||
let mut lo = 0;
|
keys.partition_point(|k| k.as_slice() <= key)
|
||||||
let mut hi = keys.len();
|
}
|
||||||
while lo < hi {
|
|
||||||
let mid = (lo + hi) / 2;
|
/// 切开一个节点:返回上提的键与右半节点
|
||||||
if keys[mid] <= key {
|
fn split_node(node: &mut Node) -> (Vec<u8>, Node) {
|
||||||
lo = mid + 1;
|
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 {
|
} else {
|
||||||
hi = mid;
|
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 },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lo
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/// 磁盘 B+ 树
|
||||||
pub struct DiskBTree {
|
pub struct DiskBTree {
|
||||||
file: PageFile,
|
file: PageFile,
|
||||||
order: usize,
|
order: usize,
|
||||||
@@ -235,21 +254,44 @@ impl DiskBTree {
|
|||||||
Ok(DiskBTree { file, order })
|
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>> {
|
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) })
|
Ok(if r == 0 { None } else { Some(r) })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 写根页号
|
|
||||||
fn set_root(&mut self, r: Option<PageId>) -> io::Result<()> {
|
fn set_root(&mut self, r: Option<PageId>) -> io::Result<()> {
|
||||||
self.file
|
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, node: &Node) -> io::Result<PageId> {
|
||||||
fn alloc(&mut self) -> io::Result<PageId> {
|
let page = self.file.append_page()?;
|
||||||
Ok(self.file.append_page()?.id)
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 空页,用来拼节点
|
/// 树是空的吗(只看最左叶子,不用整树遍历)
|
||||||
fn new_page(&self, id: PageId) -> Page {
|
pub fn is_empty(&mut self) -> io::Result<bool> {
|
||||||
Page::new(id, self.file.page_size())
|
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];
|
||||||
/// 按 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),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 递归查找
|
/// 按 key 取第一个位置
|
||||||
fn get_rec(&mut self, page_id: PageId, key: i64) -> io::Result<Option<u64>> {
|
pub fn get(&mut self, key: &[u8]) -> io::Result<Option<u64>> {
|
||||||
let order = self.order;
|
Ok(self.get_all(key)?.into_iter().next())
|
||||||
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);
|
/// 按 key 取全部位置(重复键按值升序)
|
||||||
self.get_rec(children[i], 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()? {
|
match self.root()? {
|
||||||
None => {
|
None => {
|
||||||
let id = self.alloc()?;
|
let node = Node {
|
||||||
let node = NodeData::Leaf {
|
kind: NODE_LEAF,
|
||||||
keys: vec![key],
|
|
||||||
values: vec![value],
|
|
||||||
next: 0,
|
next: 0,
|
||||||
|
keys: vec![key.to_vec()],
|
||||||
|
values: vec![value],
|
||||||
};
|
};
|
||||||
let mut p = self.new_page(id);
|
let id = self.alloc(&node)?;
|
||||||
write_node(&mut p, &node, self.order);
|
|
||||||
self.file.write_page(&p)?;
|
|
||||||
self.set_root(Some(id))
|
self.set_root(Some(id))
|
||||||
}
|
}
|
||||||
Some(r) => {
|
Some(root) => match self.insert_rec(root, key, value)? {
|
||||||
let (_, split) = self.insert_rec(r, key, value)?;
|
None => Ok(()),
|
||||||
if let Some((sep, right_id)) = split {
|
|
||||||
let root_id = self.alloc()?;
|
|
||||||
let node = NodeData::Internal {
|
|
||||||
keys: vec![sep],
|
|
||||||
children: vec![r, 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)) => {
|
Some((sep, right_id)) => {
|
||||||
keys.insert(i, sep);
|
let node = Node {
|
||||||
children.insert(i + 1, right_id);
|
kind: NODE_INTERNAL,
|
||||||
if keys.len() <= limit {
|
next: 0,
|
||||||
self.file.update_page(page_id, |p| {
|
keys: vec![sep],
|
||||||
write_node(p, &NodeData::Internal { keys, children }, order)
|
values: vec![root, right_id],
|
||||||
})?;
|
};
|
||||||
Ok((page_id, None))
|
let id = self.alloc(&node)?;
|
||||||
} else {
|
self.set_root(Some(id))
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
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();
|
let mut out = Vec::new();
|
||||||
if let Some(r) = self.root()? {
|
while id != 0 {
|
||||||
self.walk(r, &mut out)?;
|
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)
|
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<()> {
|
pub fn flush(&mut self) -> io::Result<()> {
|
||||||
self.file.flush()
|
self.file.flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 当前缓存页数
|
||||||
|
pub fn cached_pages(&self) -> usize {
|
||||||
|
self.file.cached_pages()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+121
-11
@@ -1,12 +1,13 @@
|
|||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::collections::{BTreeMap, HashSet};
|
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use std::io;
|
use std::io;
|
||||||
|
use std::io::Write;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
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)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct TableSchema {
|
pub struct TableSchema {
|
||||||
|
#[serde(default)]
|
||||||
|
pub system: bool,
|
||||||
pub columns: Vec<SchemaColumn>,
|
pub columns: Vec<SchemaColumn>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub dropped_columns: BTreeSet<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub compacted: bool,
|
||||||
|
#[serde(default)]
|
||||||
pub row_count: u64,
|
pub row_count: u64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub indexes: Vec<IndexSchema>,
|
pub indexes: Vec<IndexSchema>,
|
||||||
@@ -63,7 +70,13 @@ impl Catalog {
|
|||||||
/// 写回文件
|
/// 写回文件
|
||||||
pub fn save<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
|
pub fn save<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
|
||||||
let bytes = serde_json::to_vec_pretty(self).map_err(io::Error::other)?;
|
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
|
/// 一张表的 schema
|
||||||
@@ -93,7 +106,10 @@ impl Catalog {
|
|||||||
self.tables.insert(
|
self.tables.insert(
|
||||||
table.to_string(),
|
table.to_string(),
|
||||||
TableSchema {
|
TableSchema {
|
||||||
|
system: false,
|
||||||
columns,
|
columns,
|
||||||
|
dropped_columns: BTreeSet::new(),
|
||||||
|
compacted: false,
|
||||||
row_count: 0,
|
row_count: 0,
|
||||||
indexes: Vec::new(),
|
indexes: Vec::new(),
|
||||||
stats: BTreeMap::new(),
|
stats: BTreeMap::new(),
|
||||||
@@ -102,6 +118,14 @@ impl Catalog {
|
|||||||
Ok(())
|
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 {
|
pub fn record_insert(&mut self, table: &str, row: &Row) -> bool {
|
||||||
let changed = {
|
let changed = {
|
||||||
@@ -140,8 +164,11 @@ impl Catalog {
|
|||||||
entry.stats.clear();
|
entry.stats.clear();
|
||||||
entry.row_count = 0;
|
entry.row_count = 0;
|
||||||
}
|
}
|
||||||
rows.iter()
|
let mut changed = false;
|
||||||
.fold(false, |changed, r| self.record_insert(table, r) || changed)
|
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(
|
return Err(io::Error::new(
|
||||||
io::ErrorKind::NotFound,
|
io::ErrorKind::NotFound,
|
||||||
format!("unknown table: {}", table),
|
format!("unknown table: {}", table),
|
||||||
@@ -174,6 +202,35 @@ impl Catalog {
|
|||||||
Ok(())
|
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> {
|
pub fn remove_column(&mut self, table: &str, column: &str) -> io::Result<bool> {
|
||||||
let entry = self
|
let entry = self
|
||||||
@@ -186,11 +243,33 @@ impl Catalog {
|
|||||||
entry.columns.len() != before
|
entry.columns.len() != before
|
||||||
};
|
};
|
||||||
entry.stats.remove(column);
|
entry.stats.remove(column);
|
||||||
|
entry.dropped_columns.insert(column.to_string());
|
||||||
|
entry.compacted = false;
|
||||||
entry.indexes.retain(|i| i.column != column);
|
entry.indexes.retain(|i| i.column != column);
|
||||||
self.seen.remove(table);
|
self.seen.remove(table);
|
||||||
Ok(had_column)
|
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<()> {
|
pub fn add_index(&mut self, table: &str, column: &str) -> io::Result<()> {
|
||||||
let entry = self
|
let entry = self
|
||||||
@@ -232,13 +311,14 @@ impl Catalog {
|
|||||||
fn add_missing_columns(entry: &mut TableSchema, row: &Row) -> bool {
|
fn add_missing_columns(entry: &mut TableSchema, row: &Row) -> bool {
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
for (k, v) in row {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(ty) = infer_type(v) {
|
if let Some(ty) = infer_type(v) {
|
||||||
entry.columns.push(SchemaColumn {
|
entry.columns.push(SchemaColumn {
|
||||||
name: k.clone(),
|
name: k.clone(),
|
||||||
ty,
|
ty,
|
||||||
|
..Default::default()
|
||||||
});
|
});
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
@@ -247,15 +327,45 @@ fn add_missing_columns(entry: &mut TableSchema, row: &Row) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 由 JSON 值猜列类型
|
/// 由 JSON 值猜列类型
|
||||||
fn infer_type(v: &serde_json::Value) -> Option<ColumnType> {
|
fn infer_type(v: &serde_json::Value) -> Option<String> {
|
||||||
match v {
|
match v {
|
||||||
serde_json::Value::Number(_) => Some(ColumnType::Int),
|
serde_json::Value::Number(_) => Some("int".to_string()),
|
||||||
serde_json::Value::String(_) => Some(ColumnType::Str),
|
serde_json::Value::String(_) => Some("str".to_string()),
|
||||||
serde_json::Value::Bool(_) => Some(ColumnType::Bool),
|
serde_json::Value::Bool(_) => Some("bool".to_string()),
|
||||||
_ => None,
|
_ => 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 {
|
fn value_hash(v: &serde_json::Value) -> u64 {
|
||||||
let mut h = DefaultHasher::new();
|
let mut h = DefaultHasher::new();
|
||||||
|
|||||||
+151
-75
@@ -4,21 +4,103 @@ use serde::Deserialize;
|
|||||||
|
|
||||||
use crate::log::Level;
|
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_POOL_SIZE: usize = 1024;
|
||||||
pub const DEFAULT_PAGE_SIZE: usize = 4096;
|
pub const DEFAULT_PAGE_SIZE: usize = 4096;
|
||||||
pub const DEFAULT_BTREE_ORDER: usize = 4;
|
pub const DEFAULT_BTREE_ORDER: usize = 4;
|
||||||
pub const DEFAULT_DATA_DIR: &str = "../localdata";
|
pub const DEFAULT_PIPE_NAME: &str = "chusql-joint";
|
||||||
pub const DEFAULT_PIPE_NAME: &str = "chusql-storage";
|
|
||||||
pub const DEFAULT_LOG_LEVEL: &str = "info";
|
pub const DEFAULT_LOG_LEVEL: &str = "info";
|
||||||
|
|
||||||
const MIN_PAGE_SIZE: usize = 512;
|
const MIN_PAGE_SIZE: usize = 512;
|
||||||
const MAX_PAGE_SIZE: usize = 65536;
|
const MAX_PAGE_SIZE: usize = 65536;
|
||||||
const MIN_BTREE_ORDER: usize = 3;
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub page_size: usize,
|
pub page_size: usize,
|
||||||
@@ -35,7 +117,7 @@ impl Default for Config {
|
|||||||
page_size: DEFAULT_PAGE_SIZE,
|
page_size: DEFAULT_PAGE_SIZE,
|
||||||
btree_order: DEFAULT_BTREE_ORDER,
|
btree_order: DEFAULT_BTREE_ORDER,
|
||||||
pool_size: DEFAULT_POOL_SIZE,
|
pool_size: DEFAULT_POOL_SIZE,
|
||||||
data_dir: PathBuf::from(DEFAULT_DATA_DIR),
|
data_dir: default_data_dir(),
|
||||||
pipe_name: DEFAULT_PIPE_NAME.to_string(),
|
pipe_name: DEFAULT_PIPE_NAME.to_string(),
|
||||||
log_level: Level::Info,
|
log_level: Level::Info,
|
||||||
}
|
}
|
||||||
@@ -46,7 +128,6 @@ impl Default for Config {
|
|||||||
pub enum Origin {
|
pub enum Origin {
|
||||||
Default,
|
Default,
|
||||||
File(PathBuf),
|
File(PathBuf),
|
||||||
Env(&'static str),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Origin {
|
impl Origin {
|
||||||
@@ -55,7 +136,6 @@ impl Origin {
|
|||||||
match self {
|
match self {
|
||||||
Origin::Default => "default".to_string(),
|
Origin::Default => "default".to_string(),
|
||||||
Origin::File(p) => format!("file {}", p.display()),
|
Origin::File(p) => format!("file {}", p.display()),
|
||||||
Origin::Env(k) => format!("env {}", k),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,7 +148,6 @@ pub struct Loaded {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
struct FileConfig {
|
struct FileConfig {
|
||||||
page: Option<FilePage>,
|
page: Option<FilePage>,
|
||||||
btree: Option<FileBtree>,
|
btree: Option<FileBtree>,
|
||||||
@@ -114,12 +193,19 @@ struct FileLog {
|
|||||||
level: Option<String>,
|
level: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 按真实环境加载
|
/// 加载配置:explicit 有值就用它(读不到要报错),否则用固定安装路径(存在才读)
|
||||||
pub fn load() -> Result<Loaded, String> {
|
pub fn load(explicit: Option<&str>) -> Result<Loaded, String> {
|
||||||
let path = match std::env::var("CHUSQL_CONFIG") {
|
let explicit = explicit.map(str::trim).filter(|p| !p.is_empty());
|
||||||
Ok(p) if !p.trim().is_empty() => Some(PathBuf::from(p)),
|
let path = match explicit {
|
||||||
_ => {
|
Some(p) => {
|
||||||
let p = PathBuf::from(DEFAULT_CONFIG_PATH);
|
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 }
|
if p.is_file() { Some(p) } else { None }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -130,15 +216,11 @@ pub fn load() -> Result<Loaded, String> {
|
|||||||
),
|
),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
resolve(text.as_deref(), path, &|k| std::env::var(k).ok())
|
resolve(text.as_deref(), path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 解析文本并套环境变量
|
/// 解析文本:文件 -> 默认
|
||||||
pub fn resolve(
|
pub fn resolve(text: Option<&str>, path: Option<PathBuf>) -> Result<Loaded, String> {
|
||||||
text: Option<&str>,
|
|
||||||
path: Option<PathBuf>,
|
|
||||||
env: &dyn Fn(&str) -> Option<String>,
|
|
||||||
) -> Result<Loaded, String> {
|
|
||||||
let file: FileConfig = match text {
|
let file: FileConfig = match text {
|
||||||
None => FileConfig::default(),
|
None => FileConfig::default(),
|
||||||
Some(t) => toml::from_str(t).map_err(|e| match &path {
|
Some(t) => toml::from_str(t).map_err(|e| match &path {
|
||||||
@@ -148,70 +230,44 @@ pub fn resolve(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let file_origin = Origin::File(
|
let file_origin = Origin::File(
|
||||||
path.clone()
|
path.clone().unwrap_or_else(default_config_path),
|
||||||
.unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_PATH)),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let (page_size, page_origin) = pick(
|
let (page_size, page_origin) = pick(
|
||||||
"CHUSQL_PAGE_SIZE",
|
|
||||||
file.page.and_then(|p| p.size),
|
file.page.and_then(|p| p.size),
|
||||||
DEFAULT_PAGE_SIZE,
|
DEFAULT_PAGE_SIZE,
|
||||||
&file_origin,
|
&file_origin,
|
||||||
env,
|
);
|
||||||
|s| {
|
|
||||||
s.trim()
|
|
||||||
.parse::<usize>()
|
|
||||||
.map_err(|_| format!("not a number: {}", s))
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
let (btree_order, order_origin) = pick(
|
let (btree_order, order_origin) = pick(
|
||||||
"CHUSQL_BTREE_ORDER",
|
|
||||||
file.btree.and_then(|b| b.order),
|
file.btree.and_then(|b| b.order),
|
||||||
DEFAULT_BTREE_ORDER,
|
DEFAULT_BTREE_ORDER,
|
||||||
&file_origin,
|
&file_origin,
|
||||||
env,
|
);
|
||||||
|s| {
|
|
||||||
s.trim()
|
|
||||||
.parse::<usize>()
|
|
||||||
.map_err(|_| format!("not a number: {}", s))
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
let (pool_size, pool_origin) = pick(
|
let (pool_size, pool_origin) = pick(
|
||||||
"CHUSQL_POOL_SIZE",
|
|
||||||
file.buffer.and_then(|b| b.pool_size),
|
file.buffer.and_then(|b| b.pool_size),
|
||||||
DEFAULT_POOL_SIZE,
|
DEFAULT_POOL_SIZE,
|
||||||
&file_origin,
|
&file_origin,
|
||||||
env,
|
);
|
||||||
|s| {
|
|
||||||
s.trim()
|
|
||||||
.parse::<usize>()
|
|
||||||
.map_err(|_| format!("not a number: {}", s))
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
let (data_dir, dir_origin) = pick(
|
let (data_dir, dir_origin) = pick(
|
||||||
"CHUSQL_DATA_DIR",
|
|
||||||
file.storage.and_then(|s| s.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,
|
&file_origin,
|
||||||
env,
|
);
|
||||||
|s| Ok(s.trim().to_string()),
|
|
||||||
)?;
|
|
||||||
let (pipe_name, pipe_origin) = pick(
|
let (pipe_name, pipe_origin) = pick(
|
||||||
"CHUSQL_PIPE",
|
|
||||||
file.server.and_then(|s| s.pipe_name),
|
file.server.and_then(|s| s.pipe_name),
|
||||||
DEFAULT_PIPE_NAME.to_string(),
|
DEFAULT_PIPE_NAME.to_string(),
|
||||||
&file_origin,
|
&file_origin,
|
||||||
env,
|
);
|
||||||
|s| Ok(s.trim().to_string()),
|
|
||||||
)?;
|
|
||||||
let (log_level, log_origin) = pick(
|
let (log_level, log_origin) = pick(
|
||||||
"CHUSQL_LOG",
|
|
||||||
file.log.and_then(|l| l.level),
|
file.log.and_then(|l| l.level),
|
||||||
DEFAULT_LOG_LEVEL.to_string(),
|
DEFAULT_LOG_LEVEL.to_string(),
|
||||||
&file_origin,
|
&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)
|
let log_level = Level::parse(&log_level)
|
||||||
.ok_or_else(|| format!("log.level is not one of off/error/warn/info/debug: {}", 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>(
|
fn pick<T>(file_value: Option<T>, default: T, file_origin: &Origin) -> (T, Origin) {
|
||||||
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)));
|
|
||||||
}
|
|
||||||
if let Some(value) = file_value {
|
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(())
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+220
-40
@@ -1,6 +1,8 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use serde::de::{IgnoredAny, MapAccess, Visitor};
|
||||||
|
use serde::Deserializer;
|
||||||
|
|
||||||
use crate::btree::DiskBTree;
|
use crate::btree::DiskBTree;
|
||||||
use crate::page::PageId;
|
use crate::page::PageId;
|
||||||
@@ -13,6 +15,32 @@ const HEADER_SIZE: usize = 2;
|
|||||||
const SLOT_SIZE: usize = 8;
|
const SLOT_SIZE: usize = 8;
|
||||||
const SLOT_DIR_START: usize = HEADER_SIZE;
|
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
|
/// 读一个小端 u16
|
||||||
fn read_u16(buf: &[u8], off: usize) -> u16 {
|
fn read_u16(buf: &[u8], off: usize) -> u16 {
|
||||||
u16::from_le_bytes([buf[off], buf[off + 1]])
|
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;
|
pub struct HeapPage;
|
||||||
|
|
||||||
impl HeapPage {
|
impl HeapPage {
|
||||||
@@ -146,6 +194,7 @@ struct NamedIndex {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct HeapTable {
|
pub struct HeapTable {
|
||||||
|
hidden_columns: HashSet<String>,
|
||||||
file: PageFile,
|
file: PageFile,
|
||||||
indexes: Vec<NamedIndex>,
|
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> {
|
pub fn open<P: AsRef<Path>>(path: P, page_size: usize, pool_size: usize) -> io::Result<Self> {
|
||||||
Ok(HeapTable {
|
Ok(HeapTable {
|
||||||
|
hidden_columns: HashSet::new(),
|
||||||
file: PageFile::with_options(path, page_size, pool_size)?,
|
file: PageFile::with_options(path, page_size, pool_size)?,
|
||||||
indexes: Vec::new(),
|
indexes: Vec::new(),
|
||||||
})
|
})
|
||||||
@@ -167,12 +217,13 @@ impl HeapTable {
|
|||||||
btree_order: usize,
|
btree_order: usize,
|
||||||
pool_size: usize,
|
pool_size: usize,
|
||||||
) -> io::Result<Self> {
|
) -> io::Result<Self> {
|
||||||
let mut t = HeapTable {
|
Self::open_with_indexes(
|
||||||
file: PageFile::with_options(path, page_size, pool_size)?,
|
path,
|
||||||
indexes: Vec::new(),
|
&[("id".to_string(), index_path.as_ref().to_path_buf())],
|
||||||
};
|
page_size,
|
||||||
t.attach_index("id", index_path, page_size, btree_order, pool_size)?;
|
btree_order,
|
||||||
Ok(t)
|
pool_size,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 打开表并挂上多组索引
|
/// 打开表并挂上多组索引
|
||||||
@@ -184,12 +235,30 @@ impl HeapTable {
|
|||||||
pool_size: usize,
|
pool_size: usize,
|
||||||
) -> io::Result<Self> {
|
) -> io::Result<Self> {
|
||||||
let mut t = HeapTable {
|
let mut t = HeapTable {
|
||||||
|
hidden_columns: HashSet::new(),
|
||||||
file: PageFile::with_options(path, page_size, pool_size)?,
|
file: PageFile::with_options(path, page_size, pool_size)?,
|
||||||
indexes: Vec::new(),
|
indexes: Vec::new(),
|
||||||
};
|
};
|
||||||
|
let mut filled = false;
|
||||||
for (col, p) in indexes {
|
for (col, p) in indexes {
|
||||||
|
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)?;
|
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)
|
Ok(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,19 +300,17 @@ impl HeapTable {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut seen: HashSet<i64> = HashSet::new();
|
let mut seen: HashSet<Vec<u8>> = HashSet::new();
|
||||||
let mut entries: Vec<(i64, u64)> = Vec::new();
|
let mut entries: Vec<(Vec<u8>, u64)> = Vec::new();
|
||||||
for (page_id, slot, r) in self.scan_with_positions()? {
|
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;
|
continue;
|
||||||
};
|
};
|
||||||
if !seen.insert(k) {
|
// 行身份索引仍然唯一;其余列允许重复值
|
||||||
|
if column == "id" && !seen.insert(k.clone()) {
|
||||||
return Err(io::Error::new(
|
return Err(io::Error::new(
|
||||||
io::ErrorKind::InvalidInput,
|
io::ErrorKind::InvalidInput,
|
||||||
format!(
|
format!("column \"{}\" has duplicate values", column),
|
||||||
"column \"{}\" has duplicate value {} -- non-unique indexes are not supported yet",
|
|
||||||
column, k
|
|
||||||
),
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
entries.push((k, pack_position(page_id, slot)));
|
entries.push((k, pack_position(page_id, slot)));
|
||||||
@@ -253,7 +320,31 @@ impl HeapTable {
|
|||||||
let i = self.indexes.len() - 1;
|
let i = self.indexes.len() - 1;
|
||||||
self.indexes[i].tree.clear()?;
|
self.indexes[i].tree.clear()?;
|
||||||
for (k, pos) in entries {
|
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(())
|
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<()> {
|
pub fn insert_row(&mut self, row: &Row) -> io::Result<()> {
|
||||||
self.insert_row_returning_position(row).map(|_| ())
|
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)> {
|
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() {
|
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;
|
continue;
|
||||||
};
|
};
|
||||||
if idx.tree.get(k)?.is_some() {
|
if idx.column == "id" && idx.tree.get(&k)?.is_some() {
|
||||||
return Err(io::Error::new(
|
return Err(io::Error::new(
|
||||||
io::ErrorKind::InvalidInput,
|
io::ErrorKind::InvalidInput,
|
||||||
format!("duplicate key {} on indexed column \"{}\"", k, idx.column),
|
"duplicate value on the id index".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
pending.push((i, k));
|
pending.push((i, k));
|
||||||
@@ -293,7 +393,7 @@ impl HeapTable {
|
|||||||
let (page_id, slot) = self.insert_returning_position(row)?;
|
let (page_id, slot) = self.insert_returning_position(row)?;
|
||||||
let pos = pack_position(page_id, slot);
|
let pos = pack_position(page_id, slot);
|
||||||
for (i, k) in pending {
|
for (i, k) in pending {
|
||||||
self.indexes[i].tree.insert(k, pos)?;
|
self.indexes[i].tree.insert(&k, pos)?;
|
||||||
}
|
}
|
||||||
Ok((page_id, slot))
|
Ok((page_id, slot))
|
||||||
}
|
}
|
||||||
@@ -306,17 +406,22 @@ impl HeapTable {
|
|||||||
Ok(())
|
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)?;
|
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!(
|
return Err(io::Error::other(format!(
|
||||||
"tuple too large: {} bytes",
|
"tuple too large: {} bytes",
|
||||||
tuple.len()
|
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()?;
|
let n = self.file.num_pages()?;
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
let last_id = n - 1;
|
let last_id = n - 1;
|
||||||
@@ -341,16 +446,84 @@ impl HeapTable {
|
|||||||
self.get_by_column_key("id", key)
|
self.get_by_column_key("id", key)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 按某一列的索引取一行
|
/// 按某一列的整数索引取一行
|
||||||
pub fn get_by_column_key(&mut self, column: &str, key: i64) -> io::Result<Option<Row>> {
|
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 {
|
let Some(idx) = self.indexes.iter_mut().find(|i| i.column == column) else {
|
||||||
return Ok(None);
|
return Ok(Vec::new());
|
||||||
};
|
|
||||||
let Some(pos) = idx.tree.get(key)? else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
};
|
||||||
|
let positions = idx.tree.get_all(key)?;
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for pos in positions {
|
||||||
let (page_id, slot) = unpack_position(pos);
|
let (page_id, slot) = unpack_position(pos);
|
||||||
self.read_at(page_id, slot)
|
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 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 {
|
match bytes {
|
||||||
None => Ok(None),
|
None => Ok(None),
|
||||||
Some(bytes) => {
|
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))
|
Ok(Some(row))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -373,9 +546,10 @@ impl HeapTable {
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
if let Some(r) = &row {
|
if let Some(r) = &row {
|
||||||
|
let pos = pack_position(page_id, slot);
|
||||||
for idx in &mut self.indexes {
|
for idx in &mut self.indexes {
|
||||||
if let Some(k) = row_int(r, &idx.column) {
|
if let Some(k) = row_key(r, &idx.column) {
|
||||||
idx.tree.delete(k)?;
|
idx.tree.delete(&k, pos)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -392,7 +566,7 @@ impl HeapTable {
|
|||||||
.indexes
|
.indexes
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.find(|i| i.column == "id")
|
.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 {
|
if let Some(p) = pos {
|
||||||
positions.push(unpack_position(p));
|
positions.push(unpack_position(p));
|
||||||
}
|
}
|
||||||
@@ -401,13 +575,11 @@ impl HeapTable {
|
|||||||
let mut wanted: Vec<i64> = keys.to_vec();
|
let mut wanted: Vec<i64> = keys.to_vec();
|
||||||
wanted.sort_unstable();
|
wanted.sort_unstable();
|
||||||
for (page_id, slot, r) in self.scan_with_positions()? {
|
for (page_id, slot, r) in self.scan_with_positions()? {
|
||||||
if let Some(k) = row_int(&r, "id") {
|
if row_int(&r, "id").is_some_and(|k| wanted.binary_search(&k).is_ok()) {
|
||||||
if wanted.binary_search(&k).is_ok() {
|
|
||||||
positions.push((page_id, slot));
|
positions.push((page_id, slot));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let mut deleted: Vec<Row> = Vec::new();
|
let mut deleted: Vec<Row> = Vec::new();
|
||||||
for (page_id, slot) in positions {
|
for (page_id, slot) in positions {
|
||||||
@@ -420,8 +592,12 @@ impl HeapTable {
|
|||||||
|
|
||||||
/// 全表扫描
|
/// 全表扫描
|
||||||
pub fn scan(&mut self) -> io::Result<Vec<Row>> {
|
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
|
Ok(self
|
||||||
.scan_with_positions()?
|
.scan_projected_with_positions(columns)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(_, _, r)| r)
|
.map(|(_, _, r)| r)
|
||||||
.collect())
|
.collect())
|
||||||
@@ -429,6 +605,10 @@ impl HeapTable {
|
|||||||
|
|
||||||
/// 全表扫描,同时给出每行的位置(建索引用)
|
/// 全表扫描,同时给出每行的位置(建索引用)
|
||||||
pub fn scan_with_positions(&mut self) -> io::Result<Vec<(PageId, u16, Row)>> {
|
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 n = self.file.num_pages()?;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for i in 0..n {
|
for i in 0..n {
|
||||||
@@ -442,7 +622,7 @@ impl HeapTable {
|
|||||||
v
|
v
|
||||||
})?;
|
})?;
|
||||||
for (slot, bytes) in tuples {
|
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));
|
out.push((i, slot, row));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -459,8 +639,8 @@ impl HeapTable {
|
|||||||
let (page_id, slot) = self.insert_returning_position(r)?;
|
let (page_id, slot) = self.insert_returning_position(r)?;
|
||||||
let pos = pack_position(page_id, slot);
|
let pos = pack_position(page_id, slot);
|
||||||
for idx in &mut self.indexes {
|
for idx in &mut self.indexes {
|
||||||
if let Some(k) = row_int(r, &idx.column) {
|
if let Some(k) = row_key(r, &idx.column) {
|
||||||
idx.tree.insert(k, pos)?;
|
idx.tree.insert(&k, pos)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +2,9 @@
|
|||||||
|
|
||||||
pub mod btree;
|
pub mod btree;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod endpoint;
|
||||||
pub mod heap;
|
pub mod heap;
|
||||||
|
pub mod initsql;
|
||||||
pub mod log;
|
pub mod log;
|
||||||
pub mod page;
|
pub mod page;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
|
|||||||
+28
-28
@@ -82,7 +82,9 @@ impl Level {
|
|||||||
|
|
||||||
static LEVEL: AtomicU8 = AtomicU8::new(Level::Info as u8);
|
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) {
|
pub fn set_level(level: Level) {
|
||||||
@@ -100,22 +102,29 @@ pub fn init() {
|
|||||||
enable_vt();
|
enable_vt();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 写出一行
|
/// 写出一行:warn / error 走 stderr,info / debug 走 stdout。
|
||||||
pub fn write(channel: Channel, level: Level, args: fmt::Arguments) {
|
pub fn write(channel: Channel, level: Level, args: fmt::Arguments) {
|
||||||
let msg = args.to_string();
|
let msg = args.to_string();
|
||||||
let line = render(level, channel, color_enabled(), &now(), &msg);
|
let to_stderr = streams_to_stderr(level);
|
||||||
let stderr = std::io::stderr();
|
let line = render(level, channel, color_enabled(to_stderr), &now(), &msg);
|
||||||
let mut out = stderr.lock();
|
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);
|
let _ = writeln!(out, "{}", line);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 是否着色(终端或强制)
|
/// 这个等级写 stderr 吗:warn / error 是,info / debug 写 stdout
|
||||||
fn color_enabled() -> bool {
|
fn streams_to_stderr(level: Level) -> bool {
|
||||||
*COLOR.get_or_init(|| match std::env::var("CHUSQL_LOG_COLOR").as_deref().map(str::trim) {
|
matches!(level, Level::Error | Level::Warn)
|
||||||
Ok("always") | Ok("yes") | Ok("1") => true,
|
}
|
||||||
Ok("never") | Ok("no") | Ok("0") => false,
|
|
||||||
_ => std::io::stderr().is_terminal(),
|
/// 是否着色:只按要写的那条流是不是终端判断,没有环境变量开关
|
||||||
})
|
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);
|
let (year, month, day) = civil_from_days((secs / 86_400) as i64);
|
||||||
Stamp {
|
Stamp {
|
||||||
year: year as u16,
|
year: year as u16,
|
||||||
month: month as u16,
|
month,
|
||||||
day: day as u16,
|
day,
|
||||||
hour: ((secs % 86_400) / 3600) as u16,
|
hour: ((secs % 86_400) / 3600) as u16,
|
||||||
minute: ((secs % 3_600) / 60) as u16,
|
minute: ((secs % 3_600) / 60) as u16,
|
||||||
second: (secs % 60) as u16,
|
second: (secs % 60) as u16,
|
||||||
@@ -346,19 +355,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn render_without_color_is_plain() {
|
fn warnings_and_errors_go_to_stderr() {
|
||||||
let stamp = Stamp {
|
assert!(streams_to_stderr(Level::Error));
|
||||||
year: 2026,
|
assert!(streams_to_stderr(Level::Warn));
|
||||||
month: 9,
|
assert!(!streams_to_stderr(Level::Info));
|
||||||
day: 24,
|
assert!(!streams_to_stderr(Level::Debug));
|
||||||
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"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,18 +5,55 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
pub type Row = HashMap<String, serde_json::Value>;
|
pub type Row = HashMap<String, serde_json::Value>;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
pub const USERS_TABLE: &str = "__chusql_users";
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum ColumnType {
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
Int,
|
pub struct Account {
|
||||||
Str,
|
pub id: i64,
|
||||||
Bool,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct SchemaColumn {
|
pub struct SchemaColumn {
|
||||||
pub name: String,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -35,9 +72,20 @@ pub struct IndexWire {
|
|||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(tag = "method", rename_all = "snake_case")]
|
#[serde(tag = "method", rename_all = "snake_case")]
|
||||||
pub enum Request {
|
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,
|
Ping,
|
||||||
Scan {
|
Scan {
|
||||||
table: String,
|
table: String,
|
||||||
|
#[serde(default)]
|
||||||
|
columns: Option<Vec<String>>,
|
||||||
},
|
},
|
||||||
Insert {
|
Insert {
|
||||||
table: String,
|
table: String,
|
||||||
@@ -55,7 +103,19 @@ pub enum Request {
|
|||||||
table: String,
|
table: String,
|
||||||
#[serde(default = "default_id_column")]
|
#[serde(default = "default_id_column")]
|
||||||
column: String,
|
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 {
|
ReplaceAll {
|
||||||
table: String,
|
table: String,
|
||||||
@@ -85,6 +145,51 @@ pub enum Request {
|
|||||||
table: String,
|
table: String,
|
||||||
column: 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` 算
|
/// 没写 column 时按 `id` 算
|
||||||
@@ -95,6 +200,7 @@ fn default_id_column() -> String {
|
|||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(tag = "status", rename_all = "snake_case")]
|
#[serde(tag = "status", rename_all = "snake_case")]
|
||||||
pub enum Response {
|
pub enum Response {
|
||||||
|
Accounts { accounts: Vec<Account> },
|
||||||
Pong,
|
Pong,
|
||||||
Rows { rows: Vec<Row> },
|
Rows { rows: Vec<Row> },
|
||||||
Tables { tables: Vec<String> },
|
Tables { tables: Vec<String> },
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use std::sync::Mutex;
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::protocol::Row;
|
use crate::protocol::{Account, Row, SchemaColumn, USERS_TABLE};
|
||||||
|
|
||||||
// 预写日志:先记操作并 fsync,再改数据;重启重放残留。
|
// 预写日志:先记操作并 fsync,再改数据;重启重放残留。
|
||||||
|
|
||||||
@@ -14,9 +14,16 @@ const OP_REPLACE_ALL: u8 = 2;
|
|||||||
const OP_INSERT_BATCH: u8 = 3;
|
const OP_INSERT_BATCH: u8 = 3;
|
||||||
const OP_DELETE_KEYS: u8 = 4;
|
const OP_DELETE_KEYS: u8 = 4;
|
||||||
const OP_DROP_COLUMN: u8 = 5;
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub enum WalOp {
|
pub enum WalOp {
|
||||||
|
HideColumn { table: String, column: String },
|
||||||
|
Compact { table: String, rows: Vec<Row> },
|
||||||
|
Accounts { accounts: Vec<Account> },
|
||||||
Insert {
|
Insert {
|
||||||
table: String,
|
table: String,
|
||||||
row: Row,
|
row: Row,
|
||||||
@@ -38,6 +45,11 @@ pub enum WalOp {
|
|||||||
column: String,
|
column: String,
|
||||||
rows: Vec<Row>,
|
rows: Vec<Row>,
|
||||||
},
|
},
|
||||||
|
ReplaceSchema {
|
||||||
|
table: String,
|
||||||
|
columns: Vec<SchemaColumn>,
|
||||||
|
rows: Vec<Row>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -46,6 +58,12 @@ struct DropColumnPayload {
|
|||||||
rows: Vec<Row>,
|
rows: Vec<Row>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct ReplaceSchemaPayload {
|
||||||
|
columns: Vec<SchemaColumn>,
|
||||||
|
rows: Vec<Row>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Wal {
|
pub struct Wal {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
handle: Mutex<Option<File>>,
|
handle: Mutex<Option<File>>,
|
||||||
@@ -134,6 +152,14 @@ fn truncate(f: &mut File) -> io::Result<()> {
|
|||||||
/// 把操作编成字节
|
/// 把操作编成字节
|
||||||
fn encode(op: &WalOp) -> io::Result<Vec<u8>> {
|
fn encode(op: &WalOp) -> io::Result<Vec<u8>> {
|
||||||
let (op_type, table, payload) = match op {
|
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 } => {
|
WalOp::Insert { table, row } => {
|
||||||
let p = serde_json::to_vec(row).map_err(io::Error::other)?;
|
let p = serde_json::to_vec(row).map_err(io::Error::other)?;
|
||||||
(OP_INSERT, table.clone(), p)
|
(OP_INSERT, table.clone(), p)
|
||||||
@@ -158,6 +184,14 @@ fn encode(op: &WalOp) -> io::Result<Vec<u8>> {
|
|||||||
.map_err(io::Error::other)?;
|
.map_err(io::Error::other)?;
|
||||||
(OP_DROP_COLUMN, table.clone(), p)
|
(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();
|
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 payload = &body[11 + table_len..];
|
||||||
|
|
||||||
let op = match op_type {
|
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 => {
|
OP_INSERT => {
|
||||||
let row: Row =
|
let row: Row =
|
||||||
serde_json::from_slice(payload).map_err(|e| format!("bad insert payload: {}", e))?;
|
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,
|
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)),
|
other => return Err(format!("unknown op type: {}", other)),
|
||||||
};
|
};
|
||||||
Ok(Some(op))
|
Ok(Some(op))
|
||||||
|
|||||||
+147
-102
@@ -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::config::{DEFAULT_BTREE_ORDER, DEFAULT_PAGE_SIZE};
|
||||||
use chusql_storage::page::DEFAULT_POOL_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]
|
#[test]
|
||||||
fn empty_tree_get() {
|
fn empty_tree_get() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let mut t = DiskBTree::open(
|
let mut t = open(dir.path(), "t.idx");
|
||||||
dir.path().join("t.idx"),
|
assert_eq!(t.get(&key(1)).unwrap(), None);
|
||||||
DEFAULT_PAGE_SIZE,
|
|
||||||
DEFAULT_BTREE_ORDER,
|
|
||||||
DEFAULT_POOL_SIZE,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(t.get(1).unwrap(), None);
|
|
||||||
assert_eq!(t.iter_all().unwrap(), vec![]);
|
assert_eq!(t.iter_all().unwrap(), vec![]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,18 +26,12 @@ fn empty_tree_get() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn insert_then_get() {
|
fn insert_then_get() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let mut t = DiskBTree::open(
|
let mut t = open(dir.path(), "t.idx");
|
||||||
dir.path().join("t.idx"),
|
|
||||||
DEFAULT_PAGE_SIZE,
|
|
||||||
DEFAULT_BTREE_ORDER,
|
|
||||||
DEFAULT_POOL_SIZE,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
for i in 0..100 {
|
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 {
|
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();
|
let mut t = DiskBTree::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, DEFAULT_POOL_SIZE).unwrap();
|
||||||
for i in 0..50 {
|
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();
|
let mut t = DiskBTree::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, DEFAULT_POOL_SIZE).unwrap();
|
||||||
for i in 0..50 {
|
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]
|
#[test]
|
||||||
fn update_existing() {
|
fn duplicate_keys_keep_every_value() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let mut t = DiskBTree::open(
|
let mut t = open(dir.path(), "dup.idx");
|
||||||
dir.path().join("t.idx"),
|
t.insert(b"same", 30).unwrap();
|
||||||
DEFAULT_PAGE_SIZE,
|
t.insert(b"same", 10).unwrap();
|
||||||
DEFAULT_BTREE_ORDER,
|
t.insert(b"same", 20).unwrap();
|
||||||
DEFAULT_POOL_SIZE,
|
assert_eq!(t.get_all(b"same").unwrap(), vec![10, 20, 30]);
|
||||||
)
|
assert_eq!(t.get(b"same").unwrap(), Some(10));
|
||||||
.unwrap();
|
assert!(t.delete(b"same", 10).unwrap());
|
||||||
t.insert(5, 100).unwrap();
|
assert_eq!(t.get_all(b"same").unwrap(), vec![20, 30]);
|
||||||
t.insert(5, 200).unwrap();
|
assert!(!t.delete(b"same", 10).unwrap());
|
||||||
assert_eq!(t.get(5).unwrap(), Some(200));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 倒序插入后遍历仍升序
|
/// 倒序插入后遍历仍升序
|
||||||
#[test]
|
#[test]
|
||||||
fn iter_all_sorted() {
|
fn iter_all_sorted() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let mut t = DiskBTree::open(
|
let mut t = open(dir.path(), "t.idx");
|
||||||
dir.path().join("t.idx"),
|
|
||||||
DEFAULT_PAGE_SIZE,
|
|
||||||
DEFAULT_BTREE_ORDER,
|
|
||||||
DEFAULT_POOL_SIZE,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
for i in (0..50).rev() {
|
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();
|
let all = t.iter_all().unwrap();
|
||||||
assert_eq!(all.len(), 50);
|
assert_eq!(all.len(), 50);
|
||||||
for (i, (k, _)) in all.iter().enumerate() {
|
for (i, (k, _)) in all.iter().enumerate() {
|
||||||
assert_eq!(*k, i as i64);
|
assert_eq!(k.as_slice(), key(i as i64).as_slice());
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 倒序插 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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +89,7 @@ fn config_mismatch_errors() {
|
|||||||
let path = dir.path().join("t.idx");
|
let path = dir.path().join("t.idx");
|
||||||
{
|
{
|
||||||
let mut t = DiskBTree::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, DEFAULT_POOL_SIZE).unwrap();
|
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, DEFAULT_PAGE_SIZE, 8, DEFAULT_POOL_SIZE).is_err());
|
||||||
assert!(DiskBTree::open(&path, 8192, DEFAULT_BTREE_ORDER, 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() {
|
fn order_too_large_for_page_errors() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
assert!(DiskBTree::open(dir.path().join("t.idx"), 512, 300, DEFAULT_POOL_SIZE).is_err());
|
assert!(DiskBTree::open(dir.path().join("t.idx"), 512, 300, DEFAULT_POOL_SIZE).is_err());
|
||||||
|
assert!(!fits_in_page(64, DEFAULT_BTREE_ORDER));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删掉键之后查不到,别人不受影响
|
/// 删掉一个键值之后查不到,别人不受影响
|
||||||
#[test]
|
#[test]
|
||||||
fn delete_removes_only_that_key() {
|
fn delete_removes_only_that_key() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let mut t = DiskBTree::open(
|
let mut t = open(dir.path(), "t.idx");
|
||||||
dir.path().join("t.idx"),
|
|
||||||
DEFAULT_PAGE_SIZE,
|
|
||||||
DEFAULT_BTREE_ORDER,
|
|
||||||
DEFAULT_POOL_SIZE,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
for i in 0..200 {
|
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!(t.delete(&key(50), 51).unwrap(), "deleting an existing entry should return true");
|
||||||
assert_eq!(t.get(50).unwrap(), None);
|
assert_eq!(t.get(&key(50)).unwrap(), None);
|
||||||
assert_eq!(t.get(49).unwrap(), Some(50));
|
assert_eq!(t.get(&key(49)).unwrap(), Some(50));
|
||||||
assert_eq!(t.get(51).unwrap(), Some(52));
|
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();
|
let all = t.iter_all().unwrap();
|
||||||
assert_eq!(all.len(), 199);
|
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]
|
#[test]
|
||||||
fn insert_after_delete_still_works() {
|
fn insert_after_delete_still_works() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let mut t = DiskBTree::open(
|
let mut t = open(dir.path(), "t.idx");
|
||||||
dir.path().join("t.idx"),
|
|
||||||
DEFAULT_PAGE_SIZE,
|
|
||||||
DEFAULT_BTREE_ORDER,
|
|
||||||
DEFAULT_POOL_SIZE,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
for i in 0..300 {
|
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) {
|
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 {
|
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 {
|
for i in 0..300 {
|
||||||
let want = if i % 2 == 0 { None } else { Some(i as u64) };
|
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 {
|
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));
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use chusql_storage::catalog::Catalog;
|
use chusql_storage::catalog::Catalog;
|
||||||
use chusql_storage::protocol::{ColumnType, Row};
|
use chusql_storage::protocol::Row;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
// 数据字典测试:补列、去重、未知表。
|
// 数据字典测试:补列、去重、未知表。
|
||||||
@@ -14,13 +14,13 @@ fn row(pairs: &[(&str, serde_json::Value)]) -> Row {
|
|||||||
m
|
m
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 空文件得到空字典
|
/// 空文件得到空字典,未知表返回 None
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_file_gives_empty_catalog() {
|
fn empty_file_gives_empty_catalog_and_unknown_table_returns_none() { let dir = tempfile::tempdir().unwrap();
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let path = dir.path().join("catalog.json");
|
let path = dir.path().join("catalog.json");
|
||||||
let c = Catalog::load(&path).unwrap();
|
let c = Catalog::load(&path).unwrap();
|
||||||
assert_eq!(c.table_names().len(), 0);
|
assert_eq!(c.table_names().len(), 0);
|
||||||
|
assert!(c.describe("nope").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 补列之后能查到 schema
|
/// 补列之后能查到 schema
|
||||||
@@ -40,9 +40,9 @@ fn ensure_then_describe() {
|
|||||||
assert!(names.contains(&"id"));
|
assert!(names.contains(&"id"));
|
||||||
assert!(names.contains(&"name"));
|
assert!(names.contains(&"name"));
|
||||||
let id_col = s.columns.iter().find(|c| c.name == "id").unwrap();
|
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();
|
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);
|
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]
|
#[test]
|
||||||
fn remove_column_drops_definition_stats_and_index() {
|
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.len(), 1);
|
||||||
assert_eq!(s.columns[0].name, "id");
|
assert_eq!(s.columns[0].name, "id");
|
||||||
assert!(s.indexes.is_empty(), "index on code should be gone");
|
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();
|
let again = c.remove_column("t", "code").unwrap();
|
||||||
assert!(!again);
|
assert!(!again);
|
||||||
|
|
||||||
assert!(c.remove_column("nope", "code").is_err());
|
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(), "第二次没有可改的");
|
||||||
|
}
|
||||||
@@ -1,23 +1,32 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use chusql_storage::config::{
|
use chusql_storage::config::{
|
||||||
self, Config, DEFAULT_BTREE_ORDER, DEFAULT_DATA_DIR, DEFAULT_PAGE_SIZE, DEFAULT_PIPE_NAME,
|
self, Config, DEFAULT_BTREE_ORDER, DEFAULT_PAGE_SIZE, DEFAULT_PIPE_NAME, Origin,
|
||||||
Origin,
|
|
||||||
};
|
};
|
||||||
use chusql_storage::log::Level;
|
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]
|
#[test]
|
||||||
fn defaults_without_file() {
|
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_eq!(loaded.config, Config::default());
|
||||||
assert!(loaded.config_path.is_none());
|
assert!(loaded.config_path.is_none());
|
||||||
assert!(loaded.origins.iter().all(|(_, _, o)| *o == Origin::Default));
|
assert!(loaded.origins.iter().all(|(_, _, o)| *o == Origin::Default));
|
||||||
assert_eq!(loaded.config.page_size, DEFAULT_PAGE_SIZE);
|
assert_eq!(loaded.config.page_size, DEFAULT_PAGE_SIZE);
|
||||||
assert_eq!(loaded.config.btree_order, DEFAULT_BTREE_ORDER);
|
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.pipe_name, DEFAULT_PIPE_NAME);
|
||||||
assert_eq!(loaded.config.log_level, Level::Info);
|
assert_eq!(loaded.config.log_level, Level::Info);
|
||||||
}
|
}
|
||||||
@@ -25,13 +34,14 @@ fn defaults_without_file() {
|
|||||||
/// 只覆盖文件里写了的项
|
/// 只覆盖文件里写了的项
|
||||||
#[test]
|
#[test]
|
||||||
fn file_overrides_only_written_keys() {
|
fn file_overrides_only_written_keys() {
|
||||||
|
let _guard = env_guard();
|
||||||
let path = PathBuf::from("t.toml");
|
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();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(loaded.config.page_size, 8192);
|
assert_eq!(loaded.config.page_size, 8192);
|
||||||
assert_eq!(loaded.config.btree_order, DEFAULT_BTREE_ORDER);
|
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()));
|
assert_eq!(loaded.config_path, Some(path.clone()));
|
||||||
|
|
||||||
let page = loaded.origins.iter().find(|(n, _, _)| *n == "page.size").unwrap();
|
let page = loaded.origins.iter().find(|(n, _, _)| *n == "page.size").unwrap();
|
||||||
@@ -43,9 +53,10 @@ fn file_overrides_only_written_keys() {
|
|||||||
/// 六项全写都生效
|
/// 六项全写都生效
|
||||||
#[test]
|
#[test]
|
||||||
fn file_all_keys() {
|
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 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 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.page_size, 8192);
|
||||||
assert_eq!(loaded.config.btree_order, 32);
|
assert_eq!(loaded.config.btree_order, 32);
|
||||||
@@ -61,71 +72,161 @@ fn file_all_keys() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 环境变量优先于文件
|
/// 环境变量完全无效:这条通道已经删掉,设了 CHUSQL_* 也只认文件/内置默认
|
||||||
#[test]
|
#[test]
|
||||||
fn env_beats_file() {
|
fn env_vars_are_ignored_entirely() {
|
||||||
let text = "[storage]\ndata_dir = \"from-file\"\n";
|
let _guard = env_guard();
|
||||||
let env = |k: &str| (k == "CHUSQL_DATA_DIR").then(|| "from-env".to_string());
|
unsafe {
|
||||||
let loaded = config::resolve(Some(text), Some(PathBuf::from("t.toml")), &env).unwrap();
|
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
|
let dir = loaded
|
||||||
.origins
|
.origins
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(n, _, _)| *n == "storage.data_dir")
|
.find(|(n, _, _)| *n == "storage.data_dir")
|
||||||
.unwrap();
|
.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]
|
#[test]
|
||||||
fn rejects_bad_log_level() {
|
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);
|
assert!(err.contains("log.level"), "got {}", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 页大小非法要报错
|
/// 页大小或 order 非法都要报错
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_bad_page_size() {
|
fn rejects_bad_numeric_limits() {
|
||||||
let err = config::resolve(Some("[page]\nsize = 3000\n"), None, &|_| None).unwrap_err();
|
let _guard = env_guard();
|
||||||
assert!(err.contains("page.size"), "got {}", err);
|
for text in ["[page]\nsize = 3000\n", "[page]\nsize = 128\n"] {
|
||||||
|
let err = config::resolve(Some(text), None).unwrap_err();
|
||||||
let err = config::resolve(Some("[page]\nsize = 128\n"), None, &|_| None).unwrap_err();
|
assert!(err.contains("page.size"), "{}: {}", text, err);
|
||||||
assert!(err.contains("page.size"), "got {}", err);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// order 非法要报错
|
let err = config::resolve(Some("[btree]\norder = 2\n"), None).unwrap_err();
|
||||||
#[test]
|
|
||||||
fn rejects_bad_btree_order() {
|
|
||||||
let err = config::resolve(Some("[btree]\norder = 2\n"), None, &|_| None).unwrap_err();
|
|
||||||
assert!(err.contains("btree.order"), "got {}", 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);
|
assert!(err.contains("does not fit"), "got {}", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 未知配置键要报错
|
/// 未知配置键要报错
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_unknown_key() {
|
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);
|
assert!(err.contains("bad config"), "got {}", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 环境变量非法要报错
|
/// 坏的页面尺寸只能来自文件
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_bad_env_value() {
|
fn rejects_bad_page_size_from_file() {
|
||||||
let env = |k: &str| (k == "CHUSQL_PAGE_SIZE").then(|| "big".to_string());
|
let _guard = env_guard();
|
||||||
let err = config::resolve(None, None, &env).unwrap_err();
|
unsafe { std::env::set_var("CHUSQL_PAGE_SIZE", "big") };
|
||||||
assert!(err.contains("CHUSQL_PAGE_SIZE"), "got {}", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 来源文案正确
|
let err = config::resolve(Some("[page]\nsize = 3000\n"), None).unwrap_err();
|
||||||
#[test]
|
assert!(err.contains("page.size"), "got {}", err);
|
||||||
fn origin_description() {
|
|
||||||
assert_eq!(Origin::Default.describe(), "default");
|
unsafe { std::env::remove_var("CHUSQL_PAGE_SIZE") };
|
||||||
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())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -14,20 +14,62 @@ fn row(pairs: &[(&str, serde_json::Value)]) -> Row {
|
|||||||
m
|
m
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 插一行再扫描读回
|
/// 索引文件被清空(强杀或截断)后打开,会按堆数据补齐
|
||||||
#[test]
|
#[test]
|
||||||
fn insert_and_scan_one_row() {
|
fn empty_index_file_is_rebuilt_from_rows() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let path = dir.path().join("t.db");
|
let data = dir.path().join("t.db");
|
||||||
let mut t = HeapTable::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_POOL_SIZE).unwrap();
|
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"))]))
|
/// 字符串列能真正建索引:按值点查,NULL 不入索引
|
||||||
.unwrap();
|
#[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!(t.get_by_string_key("name", "bob").unwrap().unwrap()["id"], json!(2));
|
||||||
assert_eq!(rows.len(), 1);
|
assert!(t.get_by_string_key("name", "dave").unwrap().is_none());
|
||||||
assert_eq!(rows[0]["id"], json!(1));
|
assert!(t.get_by_string_key("name", "").unwrap().is_none(), "NULL 不入索引");
|
||||||
assert_eq!(rows[0]["name"], json!("Alice"));
|
}
|
||||||
|
|
||||||
|
/// 允许重复值建索引:点查把重复的行都取回来
|
||||||
|
#[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 行跨页顺序不变
|
/// 500 行跨页顺序不变
|
||||||
@@ -94,22 +136,6 @@ fn reopen_and_scan() {
|
|||||||
assert_eq!(rows[1]["id"], json!(2));
|
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]
|
#[test]
|
||||||
fn replace_all_rebuilds_index() {
|
fn replace_all_rebuilds_index() {
|
||||||
@@ -246,40 +272,34 @@ fn secondary_index_is_maintained() {
|
|||||||
t.delete_by_keys(&[2]).unwrap();
|
t.delete_by_keys(&[2]).unwrap();
|
||||||
assert!(t.get_by_column_key("code", 7002).unwrap().is_none());
|
assert!(t.get_by_column_key("code", 7002).unwrap().is_none());
|
||||||
|
|
||||||
assert!(t.insert_row(&row(&[("id", json!(4)), ("code", json!(7001))])).is_err());
|
t.insert_row(&row(&[("id", json!(4)), ("code", json!(7001))])).unwrap();
|
||||||
let mut t2 = HeapTable::open(&dir.path().join("d.db"), DEFAULT_PAGE_SIZE, DEFAULT_POOL_SIZE).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(&[
|
t2.insert_rows(&[
|
||||||
row(&[("id", json!(1)), ("age", json!(30))]),
|
row(&[("id", json!(1)), ("age", json!(30))]),
|
||||||
row(&[("id", json!(2)), ("age", json!(30))]),
|
row(&[("id", json!(2)), ("age", json!(30))]),
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let err = t2.build_index(
|
t2.build_index(
|
||||||
"age",
|
"age",
|
||||||
&dir.path().join("d.age.idx"),
|
&dir.path().join("d.age.idx"),
|
||||||
DEFAULT_PAGE_SIZE,
|
DEFAULT_PAGE_SIZE,
|
||||||
chusql_storage::config::DEFAULT_BTREE_ORDER,
|
chusql_storage::config::DEFAULT_BTREE_ORDER,
|
||||||
DEFAULT_POOL_SIZE,
|
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();
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
t.insert_row(&row(&[("id", json!(1))])).unwrap();
|
t2.get_all_by_column_key("age", 30).unwrap().len(),
|
||||||
let r = t.insert_row(&row(&[("id", json!(1))]));
|
2,
|
||||||
assert!(r.is_err());
|
"duplicate values are all indexed"
|
||||||
assert_eq!(t.scan().unwrap().len(), 1, "the failed row should not reach the heap");
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,25 +49,6 @@ fn append_grows_file() {
|
|||||||
assert_eq!(pf.num_pages().unwrap(), 3);
|
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]
|
#[test]
|
||||||
fn overwrite_page() {
|
fn overwrite_page() {
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -13,77 +13,51 @@ fn row(pairs: &[(&str, serde_json::Value)]) -> Row {
|
|||||||
m
|
m
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 写读 Insert 操作
|
/// 三种操作写完都能原样读回
|
||||||
#[test]
|
#[test]
|
||||||
fn append_and_read_insert() {
|
fn append_and_read_round_trips() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let wal = Wal::new(dir.path().join("wal.log"));
|
let wal = Wal::new(dir.path().join("wal.log"));
|
||||||
|
|
||||||
let op = WalOp::Insert {
|
let ops = [
|
||||||
|
WalOp::Insert {
|
||||||
table: "users".into(),
|
table: "users".into(),
|
||||||
row: row(&[("id", json!(1)), ("name", json!("Alice"))]),
|
row: row(&[("id", json!(1)), ("name", json!("Alice"))]),
|
||||||
};
|
},
|
||||||
wal.append(&op).unwrap();
|
WalOp::ReplaceAll {
|
||||||
|
|
||||||
let back = wal.read().unwrap().unwrap();
|
|
||||||
assert_eq!(back, op);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 写读 ReplaceAll 操作
|
|
||||||
#[test]
|
|
||||||
fn append_and_read_replace_all() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let wal = Wal::new(dir.path().join("wal.log"));
|
|
||||||
|
|
||||||
let op = WalOp::ReplaceAll {
|
|
||||||
table: "users".into(),
|
table: "users".into(),
|
||||||
rows: vec![
|
rows: vec![
|
||||||
row(&[("id", json!(1)), ("name", json!("Alice"))]),
|
row(&[("id", json!(1)), ("name", json!("Alice"))]),
|
||||||
row(&[("id", json!(2)), ("name", json!("Bob"))]),
|
row(&[("id", json!(2)), ("name", json!("Bob"))]),
|
||||||
],
|
],
|
||||||
};
|
},
|
||||||
wal.append(&op).unwrap();
|
WalOp::DropColumn {
|
||||||
|
|
||||||
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(),
|
table: "users".into(),
|
||||||
column: "age".into(),
|
column: "age".into(),
|
||||||
rows: vec![
|
rows: vec![
|
||||||
row(&[("id", json!(1)), ("name", json!("Alice"))]),
|
row(&[("id", json!(1)), ("name", json!("Alice"))]),
|
||||||
row(&[("id", json!(2)), ("name", json!("Bob"))]),
|
row(&[("id", json!(2)), ("name", json!("Bob"))]),
|
||||||
],
|
],
|
||||||
};
|
},
|
||||||
|
];
|
||||||
|
for op in ops {
|
||||||
|
wal.clear().unwrap();
|
||||||
wal.append(&op).unwrap();
|
wal.append(&op).unwrap();
|
||||||
|
assert_eq!(wal.read().unwrap().unwrap(), op);
|
||||||
let back = wal.read().unwrap().unwrap();
|
}
|
||||||
assert_eq!(back, op);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 文件不存在返回 None
|
/// 文件不存在或为空都返回 None
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_file_returns_none() {
|
fn read_without_content_returns_none() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let wal = Wal::new(dir.path().join("wal.log"));
|
let missing = Wal::new(dir.path().join("missing.log"));
|
||||||
assert!(wal.read().unwrap().is_none());
|
assert!(missing.read().unwrap().is_none());
|
||||||
}
|
|
||||||
|
|
||||||
/// 空文件返回 None
|
|
||||||
#[test]
|
|
||||||
fn empty_file_returns_none() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let path = dir.path().join("wal.log");
|
let path = dir.path().join("wal.log");
|
||||||
std::fs::write(&path, b"").unwrap();
|
std::fs::write(&path, b"").unwrap();
|
||||||
let wal = Wal::new(&path);
|
let empty = Wal::new(&path);
|
||||||
assert!(wal.read().unwrap().is_none());
|
assert!(empty.read().unwrap().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 清空后读不到
|
/// 清空后读不到
|
||||||
@@ -124,18 +98,3 @@ fn truncated_tail_is_ignored() {
|
|||||||
assert!(wal.read().unwrap().is_none());
|
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
@@ -2,22 +2,25 @@
|
|||||||
|
|
||||||
module Main (main) where
|
module Main (main) where
|
||||||
|
|
||||||
import ChuSQL.Web.Api (AppEnv (..), Live (..), newAppEnv, setLive, webApp)
|
import ChuSQL.Web.API (AppEnv (..), Live (..), newAppEnvAt, setLive, webApp)
|
||||||
import ChuSQL.Web.Auth (SessionPolicy (..), newSessionStore)
|
import ChuSQL.Web.Auth (Credential (..), SessionPolicy (..), newSessionStore)
|
||||||
import ChuSQL.Web.Backend (Backend, ipcBackend)
|
import ChuSQL.Web.Backend (Backend, ipcBackend)
|
||||||
import ChuSQL.Web.Demo (SeedReport (..), seedDemo)
|
import ChuSQL.Web.Demo (SeedReport (..), seedDemo)
|
||||||
import ChuSQL.Web.Config (
|
import ChuSQL.Web.Config (
|
||||||
WebConfig (..),
|
WebConfig (..),
|
||||||
defaultPassword,
|
defaultPassword,
|
||||||
defaultUser,
|
defaultUser,
|
||||||
loadWebConfig,
|
loadWebConfigAt,
|
||||||
resolveCredential,
|
resolveCredential,
|
||||||
|
resolvePipeName,
|
||||||
resolveStaticDir,
|
resolveStaticDir,
|
||||||
staticDirCandidates,
|
staticDirCandidates,
|
||||||
usingDefaultCredentials,
|
usingDefaultCredentials,
|
||||||
)
|
)
|
||||||
import ChuSQL.Web.RateLimit (newRateLimiter)
|
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 (
|
import ChuSQL.Web.StorageProcess (
|
||||||
StorageProcess (..),
|
StorageProcess (..),
|
||||||
findStorageServer,
|
findStorageServer,
|
||||||
@@ -25,8 +28,9 @@ import ChuSQL.Web.StorageProcess (
|
|||||||
stopStorageProcess,
|
stopStorageProcess,
|
||||||
waitForStorage,
|
waitForStorage,
|
||||||
)
|
)
|
||||||
import Control.Exception (IOException, bracket, try)
|
import Control.Exception (IOException, bracket, onException, try)
|
||||||
import Data.Char (toLower)
|
import Data.Char (toLower)
|
||||||
|
import qualified Data.Map.Strict as Map
|
||||||
import Data.String (fromString)
|
import Data.String (fromString)
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
@@ -42,7 +46,8 @@ import Text.Read (readMaybe)
|
|||||||
-- Web 管理端入口:读配置、备凭据、拉起存储进程,最后起 Warp。
|
-- Web 管理端入口:读配置、备凭据、拉起存储进程,最后起 Warp。
|
||||||
|
|
||||||
data Options = Options
|
data Options = Options
|
||||||
{ optHost :: Maybe String
|
{ optConfig :: Maybe FilePath
|
||||||
|
, optHost :: Maybe String
|
||||||
, optPort :: Maybe Int
|
, optPort :: Maybe Int
|
||||||
, optStatic :: Maybe FilePath
|
, optStatic :: Maybe FilePath
|
||||||
, optStorage :: Maybe FilePath
|
, optStorage :: Maybe FilePath
|
||||||
@@ -65,7 +70,8 @@ data Options = Options
|
|||||||
emptyOptions :: Options
|
emptyOptions :: Options
|
||||||
emptyOptions =
|
emptyOptions =
|
||||||
Options
|
Options
|
||||||
{ optHost = Nothing
|
{ optConfig = Nothing
|
||||||
|
, optHost = Nothing
|
||||||
, optPort = Nothing
|
, optPort = Nothing
|
||||||
, optStatic = Nothing
|
, optStatic = Nothing
|
||||||
, optStorage = Nothing
|
, optStorage = Nothing
|
||||||
@@ -91,6 +97,7 @@ parseArgs = go emptyOptions
|
|||||||
go opts [] = Right opts
|
go opts [] = Right opts
|
||||||
go opts ("--help" : rest) = go opts{optHelp = True} rest
|
go opts ("--help" : rest) = go opts{optHelp = True} rest
|
||||||
go opts ("-h" : 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 ("--host" : v : rest) = go opts{optHost = Just v} rest
|
||||||
go opts ("--static" : v : rest) = go opts{optStatic = Just v} rest
|
go opts ("--static" : v : rest) = go opts{optStatic = Just v} rest
|
||||||
go opts ("--storage" : v : rest) = go opts{optStorage = Just v} rest
|
go opts ("--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 opts ("--cookie-secure" : v : rest) = go opts{optCookieSecure = Just (isTrue v)} rest
|
||||||
go _ (a : _) = Left ("unrecognized argument or missing value: " ++ a)
|
go _ (a : _) = Left ("unrecognized argument or missing value: " ++ a)
|
||||||
|
|
||||||
-- | 读一个整数开关
|
|
||||||
withInt :: String -> String -> (Int -> Either String Options) -> Either String Options
|
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)
|
withInt name v k = maybe (Left (name ++ " needs an integer, got: " ++ v)) k (readMaybe v)
|
||||||
|
|
||||||
-- | 布尔开关认 1/true/yes/on
|
|
||||||
isTrue :: String -> Bool
|
isTrue :: String -> Bool
|
||||||
isTrue s = map toLower s `elem` ["1", "true", "yes", "on"]
|
isTrue s = map toLower s `elem` ["1", "true", "yes", "on"]
|
||||||
|
|
||||||
@@ -122,27 +127,36 @@ usage :: IO ()
|
|||||||
usage = do
|
usage = do
|
||||||
putStrLn "usage: chusql-web [options]"
|
putStrLn "usage: chusql-web [options]"
|
||||||
putStrLn ""
|
putStrLn ""
|
||||||
putStrLn " --host H listen address (CHUSQL_WEB_HOST, default 127.0.0.1)"
|
putStrLn "config: chusql.toml sits at a fixed place and its sections hold every knob below;"
|
||||||
putStrLn " --port N listen port (CHUSQL_WEB_PORT, default 7777)"
|
putStrLn " Windows: %APPDATA%\\ChuSQL\\chusql.toml"
|
||||||
putStrLn " --static DIR static asset directory (CHUSQL_WEB_STATIC, default static)"
|
putStrLn " other: $XDG_CONFIG_HOME/ChuSQL/chusql.toml (or ~/.config/ChuSQL/chusql.toml)"
|
||||||
putStrLn " --user NAME account name (CHUSQL_WEB_USER, default admin)"
|
putStrLn " [web] host/port/static_dir/user/password and the limits; [server] pipe_name;"
|
||||||
putStrLn " --storage SERVER Rust storage exe to spawn (CHUSQL_STORAGE_SERVER)"
|
putStrLn " [storage] data_dir; [page] size; [btree] order; [buffer] pool_size; [log] level"
|
||||||
putStrLn " --cookie-secure BOOL add Secure to the cookie (CHUSQL_WEB_COOKIE_SECURE, default false)"
|
|
||||||
putStrLn " --body-limit N max request body bytes (CHUSQL_WEB_BODY_LIMIT, default 65536)"
|
|
||||||
putStrLn " --session-idle N session idle timeout, sec (CHUSQL_WEB_SESSION_IDLE, default 28800)"
|
|
||||||
putStrLn " --session-max N session max age, sec (CHUSQL_WEB_SESSION_MAX, default 86400)"
|
|
||||||
putStrLn " --login-max-attempts N failed logins before 429 (CHUSQL_WEB_LOGIN_MAX_ATTEMPTS, default 5)"
|
|
||||||
putStrLn " --login-window N lockout window, sec (CHUSQL_WEB_LOGIN_WINDOW, default 300)"
|
|
||||||
putStrLn " --page-size N rows per page by default (CHUSQL_WEB_PAGE_SIZE, default 25)"
|
|
||||||
putStrLn " --max-page-size N rows per page ceiling (CHUSQL_WEB_MAX_PAGE_SIZE, default 500)"
|
|
||||||
putStrLn " --max-rows N rows returned per query (CHUSQL_WEB_MAX_ROWS, default 1000)"
|
|
||||||
putStrLn " --max-sql-length N max SQL characters (CHUSQL_WEB_MAX_SQL_LENGTH, default 20000)"
|
|
||||||
putStrLn " --seed BOOL load demo data when the db is empty (CHUSQL_WEB_SEED, default false)"
|
|
||||||
putStrLn ""
|
putStrLn ""
|
||||||
putStrLn "password: CHUSQL_WEB_PASSWORD_HASH > CHUSQL_WEB_PASSWORD > built-in demo password"
|
putStrLn " --config FILE read that config file instead ([web] host ...)"
|
||||||
putStrLn ("without any of them the demo account is " ++ T.unpack defaultUser ++ " / " ++ T.unpack defaultPassword)
|
putStrLn " --host H listen address ([web] host, default 127.0.0.1)"
|
||||||
putStrLn "storage knobs for the spawned process: CHUSQL_PAGE_SIZE / CHUSQL_BTREE_ORDER /"
|
putStrLn " --port N listen port ([web] port, default 7777)"
|
||||||
putStrLn " CHUSQL_BUFFER_POOL_SIZE / CHUSQL_LOG / CHUSQL_PIPE / CHUSQL_DATA_DIR"
|
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
|
applyOptions :: WebConfig -> Options -> WebConfig
|
||||||
@@ -168,20 +182,19 @@ applyOptions cfg opts =
|
|||||||
, wcSeedDemo = orElse (optSeedDemo opts) (wcSeedDemo cfg)
|
, wcSeedDemo = orElse (optSeedDemo opts) (wcSeedDemo cfg)
|
||||||
}
|
}
|
||||||
where
|
where
|
||||||
-- | 开关给了用开关的,否则保留原值
|
|
||||||
orElse :: Maybe a -> a -> a
|
orElse :: Maybe a -> a -> a
|
||||||
orElse (Just x) _ = x
|
orElse (Just x) _ = x
|
||||||
orElse Nothing y = y
|
orElse Nothing y = y
|
||||||
|
|
||||||
-- | 配了存储进程路径就拉起来,没配就假设外面已经跑着
|
-- | 配了存储进程路径就拉起来,没配就假设外面已经跑着
|
||||||
startStorageIfWanted :: WebConfig -> IO (Either String (Maybe StorageProcess))
|
startStorageIfWanted :: WebConfig -> FilePath -> IO (Either String (Maybe StorageProcess))
|
||||||
startStorageIfWanted cfg = case wcStorageServer cfg of
|
startStorageIfWanted cfg configPath = case wcStorageServer cfg of
|
||||||
Nothing -> pure (Right Nothing)
|
Nothing -> pure (Right Nothing)
|
||||||
Just given -> do
|
Just given -> do
|
||||||
found <- findStorageServer (Just given)
|
found <- findStorageServer (Just given)
|
||||||
case found of
|
case found of
|
||||||
Left e -> pure (Left e)
|
Left e -> pure (Left e)
|
||||||
Right bin -> fmap (fmap Just) (startStorageProcess bin)
|
Right bin -> fmap (fmap Just) (startStorageProcess bin configPath)
|
||||||
|
|
||||||
-- | 入口
|
-- | 入口
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
@@ -200,16 +213,20 @@ main = do
|
|||||||
-- | 起服务
|
-- | 起服务
|
||||||
run :: Options -> IO ()
|
run :: Options -> IO ()
|
||||||
run opts = do
|
run opts = do
|
||||||
base <- loadWebConfig
|
configPath <- resolveConfigPath (optConfig opts)
|
||||||
|
base <- loadWebConfigAt configPath
|
||||||
let cfg = applyOptions base opts
|
let cfg = applyOptions base opts
|
||||||
|
setPipeName (resolvePipeName cfg)
|
||||||
foundStatic <- resolveStaticDir (wcStaticDir cfg)
|
foundStatic <- resolveStaticDir (wcStaticDir cfg)
|
||||||
case foundStatic of
|
case foundStatic of
|
||||||
Nothing -> do
|
Nothing -> do
|
||||||
putStrLn ("static directory not found; looked in: " ++ unwords (staticDirCandidates (wcStaticDir cfg)))
|
putStrLn ("static directory not found; looked in: " ++ unwords (staticDirCandidates (wcStaticDir cfg)))
|
||||||
exitFailure
|
exitFailure
|
||||||
Just staticDir -> do
|
Just staticDir -> do
|
||||||
cred <- resolveCredential cfg
|
let settingsFile = configPath
|
||||||
storage <- startStorageIfWanted cfg
|
saved <- readSettingsFile settingsFile
|
||||||
|
cred <- resolveCredential cfg saved
|
||||||
|
storage <- startStorageIfWanted cfg configPath
|
||||||
case storage of
|
case storage of
|
||||||
Left err -> do
|
Left err -> do
|
||||||
putStrLn err
|
putStrLn err
|
||||||
@@ -226,17 +243,18 @@ run opts = do
|
|||||||
getCurrentTime
|
getCurrentTime
|
||||||
(wcLoginMaxAttempts cfg)
|
(wcLoginMaxAttempts cfg)
|
||||||
(fromIntegral (wcLoginWindow 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 =
|
let env =
|
||||||
env0
|
env0
|
||||||
{ aeCookieSecure = wcCookieSecure cfg
|
{ aeCookieSecure = wcCookieSecure cfg
|
||||||
, aeEffective = effectiveSettings cfg
|
, aeEffective = effectiveSettings cfg
|
||||||
, aeLog = \msg -> TIO.putStrLn ("[web] " <> msg)
|
, aeLog = TIO.putStrLn
|
||||||
}
|
}
|
||||||
setLive env (liveFromConfig cfg)
|
setLive env (liveFromConfig cfg)
|
||||||
app <- webApp env
|
app <- webApp env
|
||||||
printBanner cfg staticDir maybeStorage
|
printBanner cfg configPath staticDir maybeStorage
|
||||||
reportCredential cfg
|
reportCredential cred saved
|
||||||
waitForStorageReport
|
waitForStorageReport
|
||||||
seedIfWanted cfg backend
|
seedIfWanted cfg backend
|
||||||
let settings =
|
let settings =
|
||||||
@@ -273,15 +291,17 @@ serve settings app = do
|
|||||||
exitFailure
|
exitFailure
|
||||||
|
|
||||||
-- | 口令是从哪来的(用了内置默认口令就明确警告)
|
-- | 口令是从哪来的(用了内置默认口令就明确警告)
|
||||||
reportCredential :: WebConfig -> IO ()
|
reportCredential :: Credential -> Map.Map Text Text -> IO ()
|
||||||
reportCredential cfg
|
reportCredential cred saved
|
||||||
| usingDefaultCredentials cfg = do
|
| usingDefaultCredentials saved = do
|
||||||
putStrLn ""
|
putStrLn ""
|
||||||
putStrLn " !! DEMO CREDENTIALS IN USE:"
|
putStrLn " !! Administrator credentials are not configured; the built-in demo password is in use:"
|
||||||
putStrLn (" !! user " ++ T.unpack (wcUser cfg) ++ " / password " ++ T.unpack defaultPassword)
|
putStrLn (" !! user " ++ T.unpack (credUser cred) ++ " / password " ++ T.unpack defaultPassword)
|
||||||
putStrLn " !! set CHUSQL_WEB_PASSWORD (or CHUSQL_WEB_PASSWORD_HASH) before exposing this server."
|
putStrLn " !! set user / password in the [web] section of chusql.toml and restart"
|
||||||
putStrLn ""
|
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 就补一份演示数据
|
-- | 配了 seed 就补一份演示数据
|
||||||
seedIfWanted :: WebConfig -> Backend -> IO ()
|
seedIfWanted :: WebConfig -> Backend -> IO ()
|
||||||
@@ -290,10 +310,10 @@ seedIfWanted cfg backend
|
|||||||
| otherwise = do
|
| otherwise = do
|
||||||
result <- seedDemo backend
|
result <- seedDemo backend
|
||||||
case result of
|
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 ->
|
Right report ->
|
||||||
TIO.putStrLn
|
TIO.putStrLn
|
||||||
( "[web] demo data: "
|
( "demo data: "
|
||||||
<> (if null (srCreated report) then "already present" else "created " <> T.pack (unwords (srCreated report)))
|
<> (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 (srSkipped report) then "" else ", kept " <> T.pack (unwords (srSkipped report)))
|
||||||
<> (if null (srIndexes report) then "" else ", index on " <> T.pack (unwords (srIndexes report)))
|
<> (if null (srIndexes report) then "" else ", index on " <> T.pack (unwords (srIndexes report)))
|
||||||
@@ -305,15 +325,16 @@ waitForStorageReport = do
|
|||||||
up <- waitForStorage 20
|
up <- waitForStorage 20
|
||||||
putStrLn
|
putStrLn
|
||||||
( if up
|
( 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)"
|
else "storage: DOWN (no ping reply; start the Rust storage process or pass --storage)"
|
||||||
)
|
)
|
||||||
|
|
||||||
-- | 启动横幅
|
-- | 启动横幅
|
||||||
printBanner :: WebConfig -> FilePath -> Maybe StorageProcess -> IO ()
|
printBanner :: WebConfig -> FilePath -> FilePath -> Maybe StorageProcess -> IO ()
|
||||||
printBanner cfg staticDir maybeStorage = do
|
printBanner cfg configPath staticDir maybeStorage = do
|
||||||
putStrLn "==================== ChuSQL Web ===================="
|
putStrLn "==================== ChuSQL Web ===================="
|
||||||
putStrLn ("url: http://" ++ wcHost cfg ++ ":" ++ show (wcPort cfg) ++ "/")
|
putStrLn ("url: http://" ++ wcHost cfg ++ ":" ++ show (wcPort cfg) ++ "/")
|
||||||
|
putStrLn ("config file: " ++ configPath)
|
||||||
putStrLn ("static dir: " ++ staticDir)
|
putStrLn ("static dir: " ++ staticDir)
|
||||||
putStrLn
|
putStrLn
|
||||||
( "limits: body "
|
( "limits: body "
|
||||||
@@ -340,5 +361,5 @@ printBanner cfg staticDir maybeStorage = do
|
|||||||
++ "s"
|
++ "s"
|
||||||
)
|
)
|
||||||
case maybeStorage of
|
case maybeStorage of
|
||||||
Just sp -> putStrLn ("storage proc: " ++ spPipe sp ++ " data dir " ++ spDataDir sp)
|
Just sp -> putStrLn ("storage proc: spawned, talking on pipe " ++ spPipe sp)
|
||||||
Nothing -> putStrLn "storage proc: assumed to run elsewhere (pipe name from CHUSQL_PIPE)"
|
Nothing -> putStrLn "storage proc: assumed to run elsewhere (pipe name from [server] pipe_name)"
|
||||||
@@ -10,17 +10,20 @@ build-type: Simple
|
|||||||
library
|
library
|
||||||
hs-source-dirs: src
|
hs-source-dirs: src
|
||||||
exposed-modules: ChuSQL.Web.Actions
|
exposed-modules: ChuSQL.Web.Actions
|
||||||
ChuSQL.Web.Api
|
ChuSQL.Web.Accounts
|
||||||
|
ChuSQL.Web.API
|
||||||
ChuSQL.Web.Auth
|
ChuSQL.Web.Auth
|
||||||
ChuSQL.Web.Backend
|
ChuSQL.Web.Backend
|
||||||
ChuSQL.Web.Config
|
ChuSQL.Web.Config
|
||||||
ChuSQL.Web.Demo
|
ChuSQL.Web.Demo
|
||||||
|
ChuSQL.Web.Privileges
|
||||||
ChuSQL.Web.RateLimit
|
ChuSQL.Web.RateLimit
|
||||||
ChuSQL.Web.Secure
|
ChuSQL.Web.Secure
|
||||||
ChuSQL.Web.Settings
|
ChuSQL.Web.Settings
|
||||||
ChuSQL.Web.Static
|
ChuSQL.Web.Static
|
||||||
ChuSQL.Web.StorageProcess
|
ChuSQL.Web.StorageProcess
|
||||||
ChuSQL.Web.UiSettings
|
ChuSQL.Web.TOML
|
||||||
|
ChuSQL.Web.UISettings
|
||||||
build-depends: base >= 4.14 && < 5
|
build-depends: base >= 4.14 && < 5
|
||||||
, aeson
|
, aeson
|
||||||
, bytestring
|
, bytestring
|
||||||
@@ -48,6 +51,7 @@ executable chusql-web
|
|||||||
, bytestring
|
, bytestring
|
||||||
, chusql-engine
|
, chusql-engine
|
||||||
, chusql-web
|
, chusql-web
|
||||||
|
, containers
|
||||||
, crypton
|
, crypton
|
||||||
, directory
|
, directory
|
||||||
, filepath
|
, filepath
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// CLI 占位入口:功能尚未实现,仅提示用户。
|
|
||||||
|
|
||||||
console.log('ChuSQL CLI is not implemented yet. Coming soon.');
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
Generated
-2226
File diff suppressed because it is too large.
Load diff
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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
Reference in new issue
Block a user