From df6d34899504dc199c9bde4e18821f9bcb1b8a05 Mon Sep 17 00:00:00 2001 From: LIm1tZz7 <36764786+TheBadRoger@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:48:38 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=A7=E6=9B=B4=E6=96=B0=EF=BC=9A=201?= =?UTF-8?q?=E3=80=81=E5=AE=8C=E5=96=84SQL=E8=AF=AD=E6=B3=95=202=E3=80=81?= =?UTF-8?q?=E7=A7=BB=E9=99=A4node=E5=89=8D=E7=AB=AF=EF=BC=8C=E5=8F=AA?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E9=9D=99=E6=80=81=E8=B5=84=E6=BA=90=203?= =?UTF-8?q?=E3=80=81=E5=86=99=E5=A5=BD=E5=AE=89=E8=A3=85=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=204=E3=80=81=E5=81=9A=E5=A5=BDlinux=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/linux.yml | 100 + .github/workflows/release.yml | 221 ++ .gitignore | 34 +- AGENTS.md | 140 +- README.md | 71 +- benchmark/chusql-engine/chusql-bench.cabal | 14 - benchmark/chusql-engine/src/Main.hs | 159 -- benchmark/chusql-engine/stack.yaml | 5 - .../{fullchain => }/chusql-fullchain.cabal | 0 benchmark/chusql-storage/Cargo.lock | 334 --- benchmark/chusql-storage/Cargo.toml | 16 - benchmark/chusql-storage/benches/storage.rs | 337 --- benchmark/{fullchain => }/src/Main.hs | 65 +- benchmark/{fullchain => }/stack.yaml | 2 +- benchmark/{chusql-engine => }/stack.yaml.lock | 0 chusql-cli/app/Main.hs | 206 ++ chusql-cli/app/Repl.hs | 192 ++ chusql-cli/chusql-cli.cabal | 57 + chusql-cli/src/ChuSQL/CLI/Format.hs | 102 + chusql-cli/src/ChuSQL/CLI/Script.hs | 123 + chusql-cli/src/ChuSQL/CLI/Session.hs | 270 ++ chusql-cli/stack.yaml | 6 + .../fullchain => chusql-cli}/stack.yaml.lock | 0 chusql-cli/test/Spec.hs | 251 ++ chusql-engine/chusql-engine.cabal | 1 + chusql-engine/src/ChuSQL/Algebra/Eval.hs | 547 +++- chusql-engine/src/ChuSQL/Algebra/Expr.hs | 257 +- chusql-engine/src/ChuSQL/Algebra/Op.hs | 33 +- chusql-engine/src/ChuSQL/Algebra/Optimize.hs | 115 +- chusql-engine/src/ChuSQL/Algebra/Planner.hs | 82 +- chusql-engine/src/ChuSQL/Algebra/Sort.hs | 10 +- chusql-engine/src/ChuSQL/Engine.hs | 364 ++- chusql-engine/src/ChuSQL/Model.hs | 360 ++- chusql-engine/src/ChuSQL/Semantic.hs | 692 ++++- chusql-engine/src/ChuSQL/Storage.hs | 36 +- chusql-engine/src/ChuSQL/Storage/IPC.hs | 263 +- chusql-engine/src/ChuSQL/Storage/Memory.hs | 5 + chusql-engine/src/ChuSQL/Syntax/AST.hs | 76 +- chusql-engine/src/ChuSQL/Syntax/Parser.hs | 427 +++- chusql-engine/test/Spec.hs | 1250 ++++++--- chusql-storage/chusql-storage.toml.example | 26 +- chusql-storage/src/bin/chusql-storage.rs | 828 +++++- chusql-storage/src/btree.rs | 617 ++--- chusql-storage/src/catalog.rs | 132 +- chusql-storage/src/config.rs | 226 +- chusql-storage/src/endpoint.rs | 99 + chusql-storage/src/heap.rs | 264 +- chusql-storage/src/initsql.rs | 203 ++ chusql-storage/src/lib.rs | 2 + chusql-storage/src/log.rs | 58 +- chusql-storage/src/protocol.rs | 122 +- chusql-storage/src/wal.rs | 61 +- chusql-storage/tests/btree_test.rs | 249 +- chusql-storage/tests/catalog_test.rs | 57 +- chusql-storage/tests/config_test.rs | 191 +- chusql-storage/tests/heap_test.rs | 118 +- chusql-storage/tests/page_test.rs | 19 - chusql-storage/tests/pipe_test.rs | 888 ++++++- chusql-storage/tests/wal_test.rs | 109 +- chusql-web/app/Main.hs | 129 +- chusql-web/chusql-web.cabal | 8 +- chusql-web/frontend/bin/chusql-cli.js | 4 - chusql-web/frontend/bin/chusql.js | 95 - chusql-web/frontend/bin/lib/args.js | 54 - chusql-web/frontend/bin/lib/args.test.js | 18 - chusql-web/frontend/bin/lib/config.js | 76 - chusql-web/frontend/bin/lib/config.test.js | 49 - chusql-web/frontend/bin/lib/open.js | 11 - chusql-web/frontend/index.html | 17 - chusql-web/frontend/package-lock.json | 2226 ----------------- chusql-web/frontend/package.json | 40 - chusql-web/frontend/src/App.module.css | 18 - chusql-web/frontend/src/App.test.tsx | 150 -- chusql-web/frontend/src/App.tsx | 126 - .../frontend/src/adapters/MockAdapter.test.ts | 48 - .../frontend/src/adapters/MockAdapter.ts | 178 -- .../frontend/src/adapters/RestAdapter.test.ts | 53 - .../frontend/src/adapters/RestAdapter.ts | 153 -- .../src/components/common/AuthGate.test.tsx | 25 - .../src/components/common/AuthGate.tsx | 56 - .../src/components/common/IconButton.tsx | 19 - .../frontend/src/components/common/Modal.tsx | 39 - .../src/components/common/auth.module.css | 11 - .../src/components/common/common.module.css | 41 - .../src/components/grid/DataGrid.test.tsx | 54 - .../frontend/src/components/grid/DataGrid.tsx | 111 - .../src/components/grid/RowDialog.test.tsx | 16 - .../src/components/grid/RowDialog.tsx | 26 - .../src/components/grid/TableEditor.tsx | 113 - .../src/components/grid/grid.module.css | 54 - .../src/components/layout/BottomPanel.tsx | 44 - .../src/components/layout/Explorer.test.tsx | 23 - .../src/components/layout/Explorer.tsx | 59 - .../components/layout/SchemaDialog.test.tsx | 73 - .../src/components/layout/SchemaDialog.tsx | 69 - .../src/components/layout/Workbench.test.tsx | 149 -- .../src/components/layout/Workbench.tsx | 121 - .../src/components/layout/bottom.module.css | 15 - .../src/components/layout/layout.module.css | 43 - .../components/settings/FontChainEditor.tsx | 24 - .../settings/SettingsPanel.test.tsx | 35 - .../src/components/settings/SettingsPanel.tsx | 46 - .../components/settings/settings.module.css | 29 - .../src/components/sql/SqlConsole.tsx | 119 - .../src/components/sql/editorLayout.test.ts | 25 - .../src/components/sql/editorLayout.ts | 36 - .../src/components/sql/sql.module.css | 15 - .../frontend/src/core/changeSql.test.ts | 55 - chusql-web/frontend/src/core/changeSql.ts | 61 - chusql-web/frontend/src/core/changes.test.ts | 53 - chusql-web/frontend/src/core/changes.ts | 72 - chusql-web/frontend/src/core/filters.test.ts | 23 - chusql-web/frontend/src/core/filters.ts | 39 - chusql-web/frontend/src/core/sql.test.ts | 29 - chusql-web/frontend/src/core/sql.ts | 76 - .../frontend/src/core/sqlHighlight.test.ts | 36 - chusql-web/frontend/src/core/sqlHighlight.ts | 76 - chusql-web/frontend/src/core/tabs.test.ts | 80 - chusql-web/frontend/src/core/tabs.ts | 35 - .../frontend/src/core/uiSettings.test.ts | 115 - chusql-web/frontend/src/core/uiSettings.ts | 104 - chusql-web/frontend/src/i18n/zh-CN.ts | 161 -- chusql-web/frontend/src/main.tsx | 16 - .../frontend/src/store/changeStore.test.ts | 65 - chusql-web/frontend/src/store/changeStore.ts | 108 - .../frontend/src/store/settingsStore.test.ts | 24 - .../frontend/src/store/settingsStore.ts | 78 - chusql-web/frontend/src/styles/global.css | 48 - chusql-web/frontend/src/test/setup.ts | 7 - chusql-web/frontend/src/types.ts | 92 - chusql-web/frontend/src/vite-env.d.ts | 8 - chusql-web/frontend/tsconfig.app.json | 21 - chusql-web/frontend/tsconfig.json | 7 - chusql-web/frontend/tsconfig.node.json | 13 - chusql-web/frontend/vite.config.ts | 34 - chusql-web/frontend/vitest.config.ts | 11 - chusql-web/src/ChuSQL/Web/Accounts.hs | 257 ++ chusql-web/src/ChuSQL/Web/Actions.hs | 108 +- chusql-web/src/ChuSQL/Web/Api.hs | 1142 ++++++--- chusql-web/src/ChuSQL/Web/Auth.hs | 26 +- chusql-web/src/ChuSQL/Web/Backend.hs | 343 ++- chusql-web/src/ChuSQL/Web/Config.hs | 215 +- chusql-web/src/ChuSQL/Web/Demo.hs | 2 - chusql-web/src/ChuSQL/Web/Privileges.hs | 396 +++ chusql-web/src/ChuSQL/Web/Settings.hs | 220 +- chusql-web/src/ChuSQL/Web/Static.hs | 1 - chusql-web/src/ChuSQL/Web/StorageProcess.hs | 76 +- chusql-web/src/ChuSQL/Web/TOML.hs | 163 ++ chusql-web/src/ChuSQL/Web/UiSettings.hs | 36 +- chusql-web/static/api.js | 257 ++ chusql-web/static/app.js | 1567 ++++++++++++ chusql-web/static/core.js | 497 ++++ chusql-web/static/index.html | 19 + chusql-web/static/style.css | 497 ++++ chusql-web/test/Spec.hs | 1666 +++++++++--- doc/architecture.md | 42 + doc/commands.md | 142 ++ doc/config.md | 112 + doc/install.md | 110 + script/chusql.ps1 | 483 ---- scripts/chusql.toml | 55 + scripts/csql-web.ps1 | 58 + scripts/csql-web.sh | 62 + scripts/init.sql | 16 + scripts/install.ps1 | 124 + scripts/install.sh | 406 +++ 166 files changed, 16317 insertions(+), 10763 deletions(-) create mode 100644 .github/workflows/linux.yml create mode 100644 .github/workflows/release.yml delete mode 100644 benchmark/chusql-engine/chusql-bench.cabal delete mode 100644 benchmark/chusql-engine/src/Main.hs delete mode 100644 benchmark/chusql-engine/stack.yaml rename benchmark/{fullchain => }/chusql-fullchain.cabal (100%) delete mode 100644 benchmark/chusql-storage/Cargo.lock delete mode 100644 benchmark/chusql-storage/Cargo.toml delete mode 100644 benchmark/chusql-storage/benches/storage.rs rename benchmark/{fullchain => }/src/Main.hs (89%) rename benchmark/{fullchain => }/stack.yaml (60%) rename benchmark/{chusql-engine => }/stack.yaml.lock (100%) create mode 100644 chusql-cli/app/Main.hs create mode 100644 chusql-cli/app/Repl.hs create mode 100644 chusql-cli/chusql-cli.cabal create mode 100644 chusql-cli/src/ChuSQL/CLI/Format.hs create mode 100644 chusql-cli/src/ChuSQL/CLI/Script.hs create mode 100644 chusql-cli/src/ChuSQL/CLI/Session.hs create mode 100644 chusql-cli/stack.yaml rename {benchmark/fullchain => chusql-cli}/stack.yaml.lock (100%) create mode 100644 chusql-cli/test/Spec.hs create mode 100644 chusql-storage/src/endpoint.rs create mode 100644 chusql-storage/src/initsql.rs delete mode 100644 chusql-web/frontend/bin/chusql-cli.js delete mode 100644 chusql-web/frontend/bin/chusql.js delete mode 100644 chusql-web/frontend/bin/lib/args.js delete mode 100644 chusql-web/frontend/bin/lib/args.test.js delete mode 100644 chusql-web/frontend/bin/lib/config.js delete mode 100644 chusql-web/frontend/bin/lib/config.test.js delete mode 100644 chusql-web/frontend/bin/lib/open.js delete mode 100644 chusql-web/frontend/index.html delete mode 100644 chusql-web/frontend/package-lock.json delete mode 100644 chusql-web/frontend/package.json delete mode 100644 chusql-web/frontend/src/App.module.css delete mode 100644 chusql-web/frontend/src/App.test.tsx delete mode 100644 chusql-web/frontend/src/App.tsx delete mode 100644 chusql-web/frontend/src/adapters/MockAdapter.test.ts delete mode 100644 chusql-web/frontend/src/adapters/MockAdapter.ts delete mode 100644 chusql-web/frontend/src/adapters/RestAdapter.test.ts delete mode 100644 chusql-web/frontend/src/adapters/RestAdapter.ts delete mode 100644 chusql-web/frontend/src/components/common/AuthGate.test.tsx delete mode 100644 chusql-web/frontend/src/components/common/AuthGate.tsx delete mode 100644 chusql-web/frontend/src/components/common/IconButton.tsx delete mode 100644 chusql-web/frontend/src/components/common/Modal.tsx delete mode 100644 chusql-web/frontend/src/components/common/auth.module.css delete mode 100644 chusql-web/frontend/src/components/common/common.module.css delete mode 100644 chusql-web/frontend/src/components/grid/DataGrid.test.tsx delete mode 100644 chusql-web/frontend/src/components/grid/DataGrid.tsx delete mode 100644 chusql-web/frontend/src/components/grid/RowDialog.test.tsx delete mode 100644 chusql-web/frontend/src/components/grid/RowDialog.tsx delete mode 100644 chusql-web/frontend/src/components/grid/TableEditor.tsx delete mode 100644 chusql-web/frontend/src/components/grid/grid.module.css delete mode 100644 chusql-web/frontend/src/components/layout/BottomPanel.tsx delete mode 100644 chusql-web/frontend/src/components/layout/Explorer.test.tsx delete mode 100644 chusql-web/frontend/src/components/layout/Explorer.tsx delete mode 100644 chusql-web/frontend/src/components/layout/SchemaDialog.test.tsx delete mode 100644 chusql-web/frontend/src/components/layout/SchemaDialog.tsx delete mode 100644 chusql-web/frontend/src/components/layout/Workbench.test.tsx delete mode 100644 chusql-web/frontend/src/components/layout/Workbench.tsx delete mode 100644 chusql-web/frontend/src/components/layout/bottom.module.css delete mode 100644 chusql-web/frontend/src/components/layout/layout.module.css delete mode 100644 chusql-web/frontend/src/components/settings/FontChainEditor.tsx delete mode 100644 chusql-web/frontend/src/components/settings/SettingsPanel.test.tsx delete mode 100644 chusql-web/frontend/src/components/settings/SettingsPanel.tsx delete mode 100644 chusql-web/frontend/src/components/settings/settings.module.css delete mode 100644 chusql-web/frontend/src/components/sql/SqlConsole.tsx delete mode 100644 chusql-web/frontend/src/components/sql/editorLayout.test.ts delete mode 100644 chusql-web/frontend/src/components/sql/editorLayout.ts delete mode 100644 chusql-web/frontend/src/components/sql/sql.module.css delete mode 100644 chusql-web/frontend/src/core/changeSql.test.ts delete mode 100644 chusql-web/frontend/src/core/changeSql.ts delete mode 100644 chusql-web/frontend/src/core/changes.test.ts delete mode 100644 chusql-web/frontend/src/core/changes.ts delete mode 100644 chusql-web/frontend/src/core/filters.test.ts delete mode 100644 chusql-web/frontend/src/core/filters.ts delete mode 100644 chusql-web/frontend/src/core/sql.test.ts delete mode 100644 chusql-web/frontend/src/core/sql.ts delete mode 100644 chusql-web/frontend/src/core/sqlHighlight.test.ts delete mode 100644 chusql-web/frontend/src/core/sqlHighlight.ts delete mode 100644 chusql-web/frontend/src/core/tabs.test.ts delete mode 100644 chusql-web/frontend/src/core/tabs.ts delete mode 100644 chusql-web/frontend/src/core/uiSettings.test.ts delete mode 100644 chusql-web/frontend/src/core/uiSettings.ts delete mode 100644 chusql-web/frontend/src/i18n/zh-CN.ts delete mode 100644 chusql-web/frontend/src/main.tsx delete mode 100644 chusql-web/frontend/src/store/changeStore.test.ts delete mode 100644 chusql-web/frontend/src/store/changeStore.ts delete mode 100644 chusql-web/frontend/src/store/settingsStore.test.ts delete mode 100644 chusql-web/frontend/src/store/settingsStore.ts delete mode 100644 chusql-web/frontend/src/styles/global.css delete mode 100644 chusql-web/frontend/src/test/setup.ts delete mode 100644 chusql-web/frontend/src/types.ts delete mode 100644 chusql-web/frontend/src/vite-env.d.ts delete mode 100644 chusql-web/frontend/tsconfig.app.json delete mode 100644 chusql-web/frontend/tsconfig.json delete mode 100644 chusql-web/frontend/tsconfig.node.json delete mode 100644 chusql-web/frontend/vite.config.ts delete mode 100644 chusql-web/frontend/vitest.config.ts create mode 100644 chusql-web/src/ChuSQL/Web/Accounts.hs create mode 100644 chusql-web/src/ChuSQL/Web/Privileges.hs create mode 100644 chusql-web/src/ChuSQL/Web/TOML.hs create mode 100644 chusql-web/static/api.js create mode 100644 chusql-web/static/app.js create mode 100644 chusql-web/static/core.js create mode 100644 chusql-web/static/index.html create mode 100644 chusql-web/static/style.css create mode 100644 doc/architecture.md create mode 100644 doc/commands.md create mode 100644 doc/config.md create mode 100644 doc/install.md delete mode 100644 script/chusql.ps1 create mode 100644 scripts/chusql.toml create mode 100644 scripts/csql-web.ps1 create mode 100644 scripts/csql-web.sh create mode 100644 scripts/init.sql create mode 100644 scripts/install.ps1 create mode 100644 scripts/install.sh diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml new file mode 100644 index 0000000..a716197 --- /dev/null +++ b/.github/workflows/linux.yml @@ -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 + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8083dca --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,221 @@ +# 发版流水线:打 tag(v*)时为四个平台各出 web / cli 两个发行包,附 sha256,挂到 Release。 +# +# 资源命名(install.sh 按 - 挑资源,见 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 diff --git a/.gitignore b/.gitignore index 200d02c..7a55486 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ # ChuSQL 单仓库 .gitignore # chusql-engine/ Haskell (Stack + Cabal) # chusql-storage/ Rust (Cargo) -# chusql-web/ 前端 (Node / TypeScript) +# chusql-web/ 前端 (手写 HTML / CSS / JS,无 Node 工具链) # ============================================================ # ------------------------------------------------------------ @@ -38,33 +38,19 @@ target/ # 运行时数据(Rust 服务默认数据目录 CHUSQL_DATA_DIR=data/),不入库 chusql-storage/data/ -# 一键启动脚本(script/chusql.ps1)的唯一默认数据目录与日志,不入库 +# 一键启动脚本的默认数据目录与日志,不入库 localdata/ -# 启动器保存的设置(script/chusql.ps1 config set ... 写这里),本机专用,不入库 -script/chusql.settings.json - -# 前端 IDE 设置(字体等,由 /api/ui-settings 读写),同样本机专用 -script/chusql.ui.settings.json +# 前端 IDE 设置(字体等,由 /api/ui-settings 读写),本机专用,不入库 +scripts/chusql.ui.settings.json # Cargo.lock 要入库(本项目是要产出可执行程序的 crate,不是纯库) # ------------------------------------------------------------ -# 前端 / Node —— chusql-web +# 前端 —— chusql-web/static 是手写的源文件,必须入库 # ------------------------------------------------------------ -node_modules/ -.pnpm-store/ -build/ -.next/ -.nuxt/ -.vite/ -.npm-cache/ -.angular/ -coverage/ -*.tsbuildinfo - -# 前端构建产物:vite outDir 指向 ../static,由 npm run build 生成,不入库 -chusql-web/static/ +# 这里曾经放着 node_modules / .vite / *.tsbuildinfo 等 Node 工具链规则; +# 前端已改为纯 HTML + CSS + JS 静态资源,不再需要它们。 # 环境变量:只提交 .env.example,本地实际配置不入库 .env @@ -87,10 +73,16 @@ chusql-web/static/ *~ \#*\# .\#* +agent_credientials # 注意:.zed/tasks.json 是本仓库共享的构建任务(stack build / run / test / repl), # 需要入库,所以这里不忽略 .zed/ +# ------------------------------------------------------------ +# 项目计划 / 提示词 / 设计决策 / 已知问题(本机文档,不入库) +# ------------------------------------------------------------ +plandocs/ + # ------------------------------------------------------------ # 操作系统 # ------------------------------------------------------------ diff --git a/AGENTS.md b/AGENTS.md index 2f903d2..f17f296 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,124 +1,24 @@ -# AGENTS.md — ChuSQL 开发契约 +# AGENTS.md — ChuSQL 开发契约(简版) -给 AI agent 的项目规则。**约束优先于便利**:任何"暂时放宽"都必须先在此文件里改数字,并说明理由,否则视为违约。 -进度与待办的唯一事实来源是 `projectplan.txt`;本文件只管"什么算做完"。 +参考: +- 进度与待办:`project-plan.txt` +- 已知问题:`known-issues.txt` +- 设计依据:`design-decisions.txt` ---- +## 核心约束 +- project-plan.txt 是唯一进度来源。 +- 测试必须通过,禁止绕过检查。 +- 不吞错误,使用明确错误返回。 +- 表存在性以 catalog 为准。 +- 语义检查和优化只使用 schema。 +- 索引由存储层管理,优化器只判断可点查条件。 +- 优化不能改变结果语义。 -## 1. 硬约束(CONSTRAINTS) +## 协作规则 +- engine / web / storage 使用规定测试入口。 +- 注释、文案保持简短。 +- 改动说明只在交流中记录。 -违反任一条 = 改动不算完成,不许提交、不许进 `projectplan.txt` 的 ✅。 - -| # | 约束 | 当前值 | 校验方式 | -|---|---|---|---| -| C1 | Haskell 测试全绿 | **381 条**(engine 200 + web 181) | `cd chusql-engine && stack test`、`cd chusql-web && stack test` | -| C2 | Rust 测试全绿 | **78 条** | `cd chusql-storage && cargo test` | -| C3 | Haskell 编译零警告 | `-Wall` 下 **0 warning** | `stack build` 输出无 `warning:`(engine 与 web 两个包都要看) | -| C4 | 不引入抑制手段 | 禁止 `{-# OPTIONS_GHC -Wno-... #-}`、`#[allow(...)]`、`@ts-ignore`、`--no-verify`、跳过/删除测试、注释掉断言 | 看 diff | -| C5 | 不吞错误 | Rust 拿不到 `unwrap()/expect()` 当控制流;Haskell 不用 `error`/`head` 兜底。能返回错误的走 `Result` / `Either` | 看 diff + clippy 提示 | -| C6 | 索引语义不得回退 | "索引列必须唯一"是**决策 14**,不是实现细节;放宽它必须同时改 `projectplan.txt` 的已知问题 6/9 | 看 diff | -| C7 | 性能结论必须同轮交叉测 | 机器漂移 15~25%(已知问题 4);**小于 2 倍的差异不当结论**,改前改后必须同轮前后跑 | 基准记录 | -| C8 | 已实测的功能不许只留"应该能跑" | 新能力要么有测试断言,要么在 `projectplan.txt` 标 ⬜ 未做 | 看 `projectplan.txt` | -| C9 | 测试数下降 = 阻塞 | 新增测试可以增数;数字变小必须解释哪条被移除及原因 | 前后对比 | - -**已知的历史违约模式**(别重复): -- P1 遗留:`Spec.hs` 的 "INSERT ... via IPC" 用例只断言了结果,**没断言"没走 scan"** —— 需求被"测试绿了"掩盖。补法是检查服务端 debug 日志里不出现 `scan`。 -- 若哪天要"为了跑通先注释掉断言" —— 停手,改成把这条写进 `projectplan.txt` 的 ⬜。 - ---- - -## 2. 改动规则 - -1. **改 `chusql-engine` 源码后**,跑全链路基准前必须先 `stack build --force-dirty`,否则基准可能链到旧引擎(plan 第 146 行)。 -2. **表存在性判据**以 `catalog` 为准(不是文件长度),空表也是合法表;改这条要先搜它**所有**调用点(决策 7、8)。 -3. **索引元数据只放一处**(数据字典)。优化器只把"单列等值条件"标成"可点查",有没有索引由存储层回答(`no_index` → 上层退回全表扫描)。不许在优化器里缓存一份索引表(决策 13)。 -4. **等价改造不许改结果**。典型:等值连接换哈希表,输出顺序必须和嵌套循环一致(左表序为主、右表序为辅,同键下右行保序);认不出等值键或某侧空表时退回嵌套循环(决策 9)。 -5. **存储接口分 `schema` 与 `snapshot`**:语义检查与优化只走 `schema`,不许再拉全库行(决策 10)。 -6. **测试的位置约定**:Haskell 引擎测试全部在 `chusql-engine/test/Spec.hs`;Web 在 `chusql-web/test/Spec.hs`(web 是独立包,不能反向依赖 engine 的测试);Rust 在 `chusql-storage/tests/*_test.rs`,另有 `src/log.rs` 里 3 条单元测试。新增测试放同一处,不另起目录。 -7. **只留根目录一个 README**:性能基准的跑法/结果/踩坑都写进 `README.md`;设计长文写 `docs/ENGINE_INTRO.md`、`docs/STORAGE_INTRO.md`,CLI 与接口说明写 `docs/COMMANDS.md`。 -8. 改任何**决策**或**已知问题**,同一轮内更新 `projectplan.txt`。 - ---- - -## 3. 已知正确性缺口(最高优先级) - -- **重放不幂等**(已知问题 1):断电卡在"数据已落盘"与"日志清空"之间时,重启会把同一操作再作用一遍,`INSERT` 多一行。**这是当前唯一的正确性缺口**,不是功能缺失。 - 做 P7 完整版 WAL 时,验收标准 = 给每次操作记 LSN、重放前比对是否已作用过,并有一条"中途断电重放两次、行数不变"的测试。 - ---- - -## 4. Skill 路由表 - -已安装 7 个相关 skill(源:`addyosmani/agent-skills`,全局位于 `~/.agents/skills`)。按阶段加载,**不要全部加载**。 - -| 阶段 | 先加载 | 用途 | -|---|---|---| -| 任何阶段开工 | `constraint-driven-development` | 对照本文件 C1–C9,检查本轮有没有偷偷降标准 | -| 写代码 / 修 bug(全程) | `test-driven-development` | 先红后绿;C1/C2 的 200/181/78 是底线 | -| 编译失败、测试失败、行为不符预期、IPC/FFI 异常 | `debugging-and-error-recovery` | 根因定位,禁止"试一下看看"式修改 | -| P3 Web(✅ 已完成,改动 Web 时仍照此走) | `spec-driven-development` → `api-and-interface-design` → `security-and-hardening` | 先出 spec 再定接口,最后过输入校验与 Session 安全 | -| P3 启动前(无 spec 时) | `spec-driven-development` | Web 涉及 5 块能力,必须先拆能力图 | -| P4 CLI | `api-and-interface-design` | 与 Web 共用 Haskell 后端,接口一次定稳 | -| P5 FFI(C ABI + 跨语言所有权) | `api-and-interface-design` → `debugging-and-error-recovery` | ABI 契约 + 内存所有权问题定位 | -| P2 遗留 A/B(字节键、重复键、范围扫描) | `test-driven-development` → `performance-optimization` | 重写比较器与分裂节点,必须有前后同轮数字 | -| P6 SQL 功能补齐 | `test-driven-development` | 每条语法/语义能力都要有用例 | -| P7 代价优化 / 统计信息被"用"上 | `performance-optimization` | 遵 C7;已知问题 3(池淘汰 O(池容量))绝对值 2.8 µs/次,先证明值得改 | -| P7 完整版 WAL(含重放幂等) | `debugging-and-error-recovery` + 对抗式复核 | 见上面第 3 节,正确性问题 | - -**明确不用**(本项目场景不匹配,别加载): -`browser-testing-with-devtools`(需 chrome-devtools MCP,本机没配;Web 的端到端用 -`stack test` 里那条真 HTTP 用例顶着)、 -`deprecation-and-migration`、`observability-and-instrumentation`、`shipping-and-launch`(无生产部署)、 -`planning-and-task-breakdown`(路线图已在 `projectplan.txt`)、 -`git-workflow-and-versioning` / `code-review-and-quality` / `refactor` / `code-simplification`(与既有习惯重叠,只增流程噪音)。 - ---- - -## 5. 每轮收尾清单 - -- [ ] `cd chusql-engine && stack test` → 200 条全绿 -- [ ] `cd chusql-web && stack test` → 181 条全绿(含一条真起 Rust 存储进程的端到端;`static/` 未构建时 14 条构建产物契约测试记 pending) -- [ ] `cd chusql-storage && cargo test` → 78 条全绿 -- [ ] `stack build` 输出 0 warning(`-Wall`,engine 与 web 两个包) -- [ ] diff 里没有 C4 列的抑制手段 -- [ ] 动过性能路径 → 同轮交叉测,数字进 `README.md` 表格 -- [ ] 动过决策/已知问题 → `projectplan.txt` 同一轮更新 -- [ ] 新能力的测试数只增不减(C9) - ---- - -## 6. 常用命令 - -```bash -# Haskell 引擎测试 -cd chusql-engine && stack test - -# Web 测试(端到端那条会自己 cargo build --bin chusql-storage 并起真进程) -cd chusql-web && stack test - -# Rust 测试 -cd chusql-storage && cargo test - -# 一键把三个模块都起来(Rust 存储 + Haskell Web + 浏览器) -# .\script\chusql.ps1 web -Port 9000 临时端口(关掉即失效) -# .\script\chusql.ps1 config set port 9000 存成设置(script\chusql.settings.json) -# .\script\chusql.ps1 config / cli / help 看设置 / CLI 占位(会起 web)/ 帮助 -# (每个开关都能换成 CHUSQL_* 环境变量;清单见 README 的配置表) -# 手动分开起(一条命令连 Rust 存储进程一起拉) -cd chusql-storage && cargo build --release -cd chusql-web && stack run chusql-web -- --storage ../chusql-storage/target/release/chusql-storage.exe -# 浏览器打开 http://127.0.0.1:7777/ ; -# 端口被占或被系统保留时换一个:--port 7778 -# (Windows 查保留段:netsh interface ipv4 show excludedportrange protocol=tcp) - -# 全链路基准(Haskell 引擎 + Rust 存储进程一起起) -cd chusql-storage && cargo build --release -cd benchmark/fullchain && stack run chusql-fullchain -- 1000 1000 -# ⚠️ 改过 chusql-engine 源码 → 先 stack build --force-dirty - -# 分层基准 -cd benchmark/chusql-engine && stack run # 查询层(内存数据,隔离优化器) -cd benchmark/chusql-storage && cargo bench --offline # 存储层微基准 -``` - -> 上一轮评估:2026-09-25(P3 ⑰ 全部完成:按 DataGrip 截图把界面整个重做——主工具栏 + "Database Explorer" 工具窗(标题栏窗口动作/小工具栏/连接→库→schema→Database Objects→表→列的树)+ 标签页 + 数据编辑器工具栏 + 带行号槽与类型字形的网格 + 状态栏;图标换成 IDEA 风格线稿,配色换 IDEA 深浅两套;顺手做实了标签页、行号槽、单元格游标与方向键、工具栏加减行、DDL 只读查看、树过滤、折叠全部、工具窗三个窗口动作与下拉菜单)。下一项:P2 遗留 A/B(复合键 + 字节键 → 非唯一索引)。 +## 当前重点 +- 完善 WAL 重放幂等。 +- 完整事务路线见 project-plan.txt 阶段 12。 diff --git a/README.md b/README.md index 3572083..1423c2c 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,45 @@ # ChuSQL -本项目是一个简单的数据库项目,服务端采用Haskell和Rust联合编写,采取B/S模式,管理客户端运行在浏览器网页上。 +本项目是一个简单的数据库项目,服务端采用 Haskell 和 Rust 联合编写,采取 B/S 模式,管理客户端运行在浏览器网页上。 -## 仓库布局 +优点: -| 目录 | 语言 | 组件 | -| ----------------- | -------------------------- | ----------------------------------------------------------------- | -| `chusql-engine/` | Haskell | 执行引擎:词法分析与AST、表达式求值、关系代数、内存表、服务端接口 | -| `chusql-storage/` | Rust | 存储引擎:磁盘IO、B+树、缓冲池、WAL、崩溃恢复、并发控制 | -| `chusql-web/` | Haskell + React/TypeScript | Web 管理端:Scotty REST API + VS Code Dark+ 风格数据库 Web IDE | -| `script/` | PowerShell | `chusql.ps1` / `chusql.cmd`:一键启动 / 子命令 / 设置管理 | +- 轻量:两个可执行文件加一份静态资源,解包即用,没有 Node / Python 之类的运行时依赖 +- 部署容易:Linux 与 macOS 一条命令装完并启动,Windows 解压后跑一次安装脚本 +- 配置统一:一个 `chusql.toml` 同时管住存储、Web 与命令行 +- 账号统一:Web 管理端与命令行共用同一个管理员和同一张账号表 -# 架构分层 +## 安装 -## Haskell部分 +Linux / macOS(自动识别当前平台,取对应发行包): -作为函数式编程语言,从语言层面契合SQL语句解析和代数数学运算,同时有强大的类型安全系统。 +```sh +curl -fsSL https://github.com/TheBadRoger/ChuSQL/releases/latest/download/install.sh | sh -s -- --component web +``` -- SQL词法分析、AST -- 语义分析:表/列存在性与类型检查(执行前的关卡) -- 事务处理 -- 查询优化、内存表管理 -- 关系代数运算 -- 服务端接口与请求相应 -- Web 管理端:Scotty REST API、登录会话、静态页面交付 +Windows:下载 `chusql-web-windows-x86_64.zip`,解压后在 PowerShell 里执行 -## Rust部分 +```powershell +.\install.ps1 -Component web +``` -高性能、内存安全且具有强大并行异步编程能力的现代编程语言。 +只装命令行客户端就把 `web` 换成 `cli`,入口变成 `csql`。安装位置、目录结构与卸载方式见 +[doc/install.md](doc/install.md)。 -- 磁盘IO -- B+树、页面管理 -- 缓冲池 -- WAL -- 事务日志和崩溃恢复 -- 并发控制 +## 启动 -# 性能基准 +```sh +csql-web +``` -| 指标 | 200 × 200 | 500 × 500 | 1000 × 1000 | -| ------------------------------------------------ | --------: | --------: | ------------------: | -| 单条 `INSERT`(整条语句) | 2.66 ms | 2.86 ms | 2.88 ms | -| 同一条 `INSERT`(原始 IPC,不经引擎) | 2.50 ms | 2.85 ms | 2.77 ms | -| **多行 `INSERT`(100 行/条)** | — | — | **0.059 ms/行** | -| `SELECT * FROM users` | 0.4 ms | 1.0 ms | 1.9 ms | -| `SELECT name FROM users WHERE age > 90` | 0.3 ms | 0.8 ms | 2.7 ms | -| `SELECT ... JOIN ... WHERE` | 0.9 ms | 2.4 ms | 5.3 ms | -| `SELECT name FROM users WHERE id = k` | 0.2 ms | 0.2 ms | 0.2 ms | -| `DELETE FROM users WHERE id = k`(删 1 行) | — | — | 4.8 ms | -| `DELETE FROM users`(删全部 1000 行) | — | — | 20.2 ms | -| `SELECT ... WHERE code = k`(无索引 → 建索引后) | — | — | 1.8 ms → **0.2 ms** | +浏览器打开 http://127.0.0.1:7777 ,默认账号 `root`,口令是安装时给的那个。服务启动后只有系统库 `system`(里面是账号表),**没有默认工作库**:自己建一个库再用 `USE <库名>` 选中它。 +换口令、换端口、改数据目录都在安装时写好的 `chusql.toml` 里。 -> 最近更新:2026-09-26 +## 文档 + +| 文档 | 内容 | +| --------------------------------- | ------------------------------------------------------------------------ | +| [安装与卸载](doc/install.md) | 三种安装方式、全部安装选项、装完的目录结构、从源码安装、卸载 | +| [配置选项](doc/config.md) | 配置位置与优先级、全部配置键与默认值、端点规则、口令与安全 | +| [命令与选项](doc/commands.md) | `csql` 与 `chusql-web` 的选项、元命令、账号与角色权限、REST 接口 | +| [架构与组件](doc/architecture.md) | 三个组件怎么分工、一次请求经过什么、数据落在哪 | diff --git a/benchmark/chusql-engine/chusql-bench.cabal b/benchmark/chusql-engine/chusql-bench.cabal deleted file mode 100644 index d8c6c9b..0000000 --- a/benchmark/chusql-engine/chusql-bench.cabal +++ /dev/null @@ -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 diff --git a/benchmark/chusql-engine/src/Main.hs b/benchmark/chusql-engine/src/Main.hs deleted file mode 100644 index 69ce86c..0000000 --- a/benchmark/chusql-engine/src/Main.hs +++ /dev/null @@ -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 diff --git a/benchmark/chusql-engine/stack.yaml b/benchmark/chusql-engine/stack.yaml deleted file mode 100644 index eacce59..0000000 --- a/benchmark/chusql-engine/stack.yaml +++ /dev/null @@ -1,5 +0,0 @@ -resolver: lts-24.59 - -packages: - - . - - ../../chusql-engine diff --git a/benchmark/fullchain/chusql-fullchain.cabal b/benchmark/chusql-fullchain.cabal similarity index 100% rename from benchmark/fullchain/chusql-fullchain.cabal rename to benchmark/chusql-fullchain.cabal diff --git a/benchmark/chusql-storage/Cargo.lock b/benchmark/chusql-storage/Cargo.lock deleted file mode 100644 index ceae9ba..0000000 --- a/benchmark/chusql-storage/Cargo.lock +++ /dev/null @@ -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" diff --git a/benchmark/chusql-storage/Cargo.toml b/benchmark/chusql-storage/Cargo.toml deleted file mode 100644 index 51fef18..0000000 --- a/benchmark/chusql-storage/Cargo.toml +++ /dev/null @@ -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 diff --git a/benchmark/chusql-storage/benches/storage.rs b/benchmark/chusql-storage/benches/storage.rs deleted file mode 100644 index 2fa81b7..0000000 --- a/benchmark/chusql-storage/benches/storage.rs +++ /dev/null @@ -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(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(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 = (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)); -} diff --git a/benchmark/fullchain/src/Main.hs b/benchmark/src/Main.hs similarity index 89% rename from benchmark/fullchain/src/Main.hs rename to benchmark/src/Main.hs index 4f2f3fd..2eb2ee9 100644 --- a/benchmark/fullchain/src/Main.hs +++ b/benchmark/src/Main.hs @@ -13,12 +13,13 @@ import ChuSQL.Storage.IPC ( doListCatalog, doLookupByColumn, sendRequest, + setPipeName, ) import ChuSQL.Syntax.Parser (parseStatement) import Control.Concurrent (threadDelay) import Control.Exception (SomeException, bracket, evaluate, try) import Control.Monad (filterM, unless, when) -import Data.List (intercalate, isInfixOf, isPrefixOf) +import Data.List (intercalate, isInfixOf) import GHC.Clock (getMonotonicTime) import System.Directory ( createDirectoryIfMissing, @@ -26,9 +27,10 @@ import System.Directory ( getTemporaryDirectory, removeDirectoryRecursive, ) -import System.Environment (getArgs, getEnvironment, setEnv) +import System.Environment (getArgs) import System.Exit (exitFailure) import System.FilePath (()) +import System.Info (os) import System.Process ( CreateProcess (..), ProcessHandle, @@ -40,21 +42,19 @@ import System.Process ( ) import Text.Printf (printf) --- 全链路基准:Haskell 引擎与 Rust 存储进程一起跑, --- 语句走完整链路(解析、优化、命名管道、WAL、落盘), --- 用墙上时钟计时,结果打印到标准输出。 +-- 全链路基准:Haskell 引擎与 Rust 存储进程一起跑,语句走完整链路并打印墙上耗时。 -- | 找 Rust 存储进程:优先 release findServer :: FilePath -> IO FilePath findServer "" = do let dirs = [ "chusql-storage/target" - , "../../chusql-storage/target" , "../chusql-storage/target" - , "target" ] modes = ["release", "debug"] - exes = ["chusql-storage.exe", "chusql-storage"] + -- 名字跟平台走:同一个 target 目录里可能躺着一份别处交叉编译出来的 .exe, + -- 在 Linux 上误选它只会拿到 config file not found + exes = [if os == "mingw32" then "chusql-storage.exe" else "chusql-storage"] candidates = [d m e | d <- dirs, m <- modes, e <- exes] found <- filterM doesFileExist candidates case found of @@ -74,27 +74,12 @@ findServer p = do putStrLn ("storage binary not found: " ++ p) exitFailure --- | 子进程环境:继承并覆盖 CHUSQL_* -childEnv :: [(String, String)] -> IO [(String, String)] -childEnv extra = do - base <- getEnvironment - let stripped = filter (\(k, _) -> not ("CHUSQL_" `isPrefixOf` k)) base - pure (stripped ++ extra) - --- | 起存储进程并等管道可连 -startServer :: FilePath -> String -> FilePath -> IO ProcessHandle -startServer serverPath pipe dataDir = do - envs <- - childEnv - [ ("CHUSQL_PIPE", pipe) - , ("CHUSQL_DATA_DIR", dataDir) - , ("CHUSQL_PAGE_SIZE", "4096") - , ("CHUSQL_BTREE_ORDER", "4") - , ("CHUSQL_LOG", "error") - ] +-- | 起存储进程并等管道可连:调参走 --config 指的那份配置文件,环境原样继承 +startServer :: FilePath -> FilePath -> IO ProcessHandle +startServer serverPath configPath = do (_, _, _, ph) <- createProcess - (proc serverPath []) {env = Just envs, std_out = NoStream, std_err = Inherit} + (proc serverPath ["--config", configPath]){std_out = NoStream, std_err = Inherit} ready <- waitReady (200 :: Int) unless ready $ do putStrLn "storage process was not ready within 5 s" @@ -194,12 +179,12 @@ batchInsert label n mkSql = do -- | 一次点查(原始 IPC,不经过引擎) benchLookup :: String -> String -> Int -> Int -> IO () benchLookup table column key times = do - _ <- doLookupByColumn table column key + _ <- doLookupByColumn table column (VInt key) t0 <- getMonotonicTime - results <- mapM (const (doLookupByColumn table column key)) [1 .. times] + results <- mapM (const (doLookupByColumn table column (VInt key))) [1 .. times] t1 <- getMonotonicTime let total = t1 - t0 - hit = length [() | Right (IndexRow (Just _)) <- results] + hit = length [() | Right (IndexRows (_ : _)) <- results] printf "%-38s %8.1f ms %7.3f ms/op (hits %d/%d)\n" "lookup_by_index (raw IPC, by id)" @@ -255,7 +240,6 @@ runBatch sqls = do chunked :: Int -> Int -> [[Int]] chunked size n = go [1 .. n] where - -- | 每次切下 size 个 go [] = [] go xs = let (a, b) = splitAt size xs in a : go b @@ -391,11 +375,26 @@ main = do stamp <- getMonotonicTime let pipe = "chusql-fullchain-" ++ show (round (stamp * 1e6) :: Int) dataDir = tmp pipe + configPath = tmp (pipe ++ ".toml") + -- TOML 的普通字符串会吃反斜杠,路径统一用正斜杠 + slashed = map (\c -> if c == '\\' then '/' else c) dataDir createDirectoryIfMissing True dataDir - setEnv "CHUSQL_PIPE" pipe + writeFile + configPath + ( unlines + [ "[storage]" + , "data_dir = \"" ++ slashed ++ "\"" + , "[server]" + , "pipe_name = \"" ++ pipe ++ "\"" + , "[log]" + , "level = \"error\"" + ] + ) + setPipeName pipe printf "full-chain benchmark: Haskell engine <--named pipe--> Rust storage\n" printf "storage : %s\n" serverPath + printf "config : %s\n" configPath printf "pipe : %s\n" pipe printf "data dir : %s\n" dataDir printf "dataset : users = %d rows, orders = %d rows\n" u o @@ -404,7 +403,7 @@ main = do bracket (do t1 <- getMonotonicTime - ph <- startServer serverPath pipe dataDir + ph <- startServer serverPath configPath t2 <- getMonotonicTime printf "\n[startup] storage ready in %.2f s\n" (t2 - t1) pure ph diff --git a/benchmark/fullchain/stack.yaml b/benchmark/stack.yaml similarity index 60% rename from benchmark/fullchain/stack.yaml rename to benchmark/stack.yaml index eacce59..4048110 100644 --- a/benchmark/fullchain/stack.yaml +++ b/benchmark/stack.yaml @@ -2,4 +2,4 @@ resolver: lts-24.59 packages: - . - - ../../chusql-engine + - ../chusql-engine diff --git a/benchmark/chusql-engine/stack.yaml.lock b/benchmark/stack.yaml.lock similarity index 100% rename from benchmark/chusql-engine/stack.yaml.lock rename to benchmark/stack.yaml.lock diff --git a/chusql-cli/app/Main.hs b/chusql-cli/app/Main.hs new file mode 100644 index 0000000..586a667 --- /dev/null +++ b/chusql-cli/app/Main.hs @@ -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 " + ) + ) + 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")) diff --git a/chusql-cli/app/Repl.hs b/chusql-cli/app/Repl.hs new file mode 100644 index 0000000..4cf666b --- /dev/null +++ b/chusql-cli/app/Repl.hs @@ -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 :列结构 +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 diff --git a/chusql-cli/chusql-cli.cabal b/chusql-cli/chusql-cli.cabal new file mode 100644 index 0000000..50affcb --- /dev/null +++ b/chusql-cli/chusql-cli.cabal @@ -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 diff --git a/chusql-cli/src/ChuSQL/CLI/Format.hs b/chusql-cli/src/ChuSQL/CLI/Format.hs new file mode 100644 index 0000000..86b0aaa --- /dev/null +++ b/chusql-cli/src/ChuSQL/CLI/Format.hs @@ -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" diff --git a/chusql-cli/src/ChuSQL/CLI/Script.hs b/chusql-cli/src/ChuSQL/CLI/Script.hs new file mode 100644 index 0000000..0201364 --- /dev/null +++ b/chusql-cli/src/ChuSQL/CLI/Script.hs @@ -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
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
describe a table" + , " \\dr, \\roles list roles with their grants and members" + , " \\c switch database" + , " \\f, \\format output format: " <> T.intercalate ", " (map formatName [FormatTable, FormatJson, FormatCsv]) + , "" + , "At the chusql> prompt the arrow keys walk the SQL history." + ] diff --git a/chusql-cli/src/ChuSQL/CLI/Session.hs b/chusql-cli/src/ChuSQL/CLI/Session.hs new file mode 100644 index 0000000..b9fde7b --- /dev/null +++ b/chusql-cli/src/ChuSQL/CLI/Session.hs @@ -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 diff --git a/chusql-cli/stack.yaml b/chusql-cli/stack.yaml new file mode 100644 index 0000000..e232734 --- /dev/null +++ b/chusql-cli/stack.yaml @@ -0,0 +1,6 @@ +resolver: lts-24.59 + +packages: + - . + - ../chusql-engine + - ../chusql-web diff --git a/benchmark/fullchain/stack.yaml.lock b/chusql-cli/stack.yaml.lock similarity index 100% rename from benchmark/fullchain/stack.yaml.lock rename to chusql-cli/stack.yaml.lock diff --git a/chusql-cli/test/Spec.hs b/chusql-cli/test/Spec.hs new file mode 100644 index 0000000..7eeb53b --- /dev/null +++ b/chusql-cli/test/Spec.hs @@ -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
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") diff --git a/chusql-engine/chusql-engine.cabal b/chusql-engine/chusql-engine.cabal index 9d4a8a9..83a1a13 100644 --- a/chusql-engine/chusql-engine.cabal +++ b/chusql-engine/chusql-engine.cabal @@ -28,6 +28,7 @@ library , bytestring , hashable , megaparsec + , network , parser-combinators , scientific , text diff --git a/chusql-engine/src/ChuSQL/Algebra/Eval.hs b/chusql-engine/src/ChuSQL/Algebra/Eval.hs index 35d9599..7f73df3 100644 --- a/chusql-engine/src/ChuSQL/Algebra/Eval.hs +++ b/chusql-engine/src/ChuSQL/Algebra/Eval.hs @@ -1,147 +1,480 @@ -module ChuSQL.Algebra.Eval (evalRelOp, evalRelOpM) where +module ChuSQL.Algebra.Eval (evalRelOp, evalRelOpM, evalExprM, evalCondForRowM) where -import ChuSQL.Algebra.Expr (evalCondForRow) +import ChuSQL.Algebra.Expr (evalCondForRow, evalExpr) +import ChuSQL.Algebra.Optimize (optimize, relOpCols) import ChuSQL.Algebra.Op -import ChuSQL.Algebra.Sort (sortRows) +import ChuSQL.Algebra.Planner (translate) +import ChuSQL.Algebra.Sort (compareValue, sortRows) import ChuSQL.Model import ChuSQL.Storage (IndexResult (..), MonadStorage (..)) -import ChuSQL.Syntax.AST (Expr (..)) +import ChuSQL.Storage.Memory (runMemoryStorage) +import ChuSQL.Syntax.AST (Expr (..), JoinKind (..), Subquery (..)) import Control.Monad (filterM, foldM) import qualified Data.HashMap.Strict as HM -- 执行:按算子树算出结果行(Scan 阶段会加别名前缀)。 +-- 外层行 outer 给相关子查询用:子查询里的列先在本层行里找,找不到再落到外层行。 --- | 只保留清单里的列 +-- | 只保留清单里的列,顺序按清单(SELECT 的列序就是结果的列序) project :: [String] -> Row -> Row project ["*"] row = row -project cs row = [(c, v) | (c, v) <- row, c `elem` cs] +project cs row = [(c, v) | c <- cs, Just v <- [lookup c row]] -- | 给列名加别名前缀 addPrefix :: Maybe String -> Row -> Row addPrefix mAlias = map (\(k, v) -> (qualify mAlias k, v)) --- | 扫描一张表 -evalScan :: Database -> Maybe String -> String -> Either String [Row] -evalScan db mAlias tbl = do - table <- lookupTable db tbl - Right (map (addPrefix mAlias) (tableRows table)) +prefixRows :: Maybe String -> [Row] -> [Row] +prefixRows mAlias = map (addPrefix mAlias) + +-- | 值 → 字面量 +valueLiteral :: Value -> Expr +valueLiteral (VInt n) = LitInt n +valueLiteral (VFloat d) = LitFloat d +valueLiteral (VStr s) = LitStr s +valueLiteral (VBool b) = LitBool b +valueLiteral VNull = LitNull -- | 点查退化成扫描时用的条件 -lookupCondition :: Maybe String -> String -> Int -> Expr -lookupCondition mAlias col k = Eq (Col (qualify mAlias col)) (LitInt k) +lookupCondition :: Maybe String -> String -> Value -> Expr +lookupCondition mAlias col k = Eq (Col (qualify mAlias col)) (valueLiteral k) + +-- | 范围扫描退化成扫描时用的条件(闭端写成 > 或 = 的并) +rangeCondition :: Maybe String -> String -> Maybe (Value, Bool) -> Maybe (Value, Bool) -> Expr +rangeCondition mAlias col lo hi = case (lo, hi) of + (Just l, Just h) -> And (bound Gt l) (bound Lt h) + (Just l, Nothing) -> bound Gt l + (Nothing, Just h) -> bound Lt h + (Nothing, Nothing) -> LitBool True + where + c = Col (qualify mAlias col) + bound rel (v, inclusive) + | inclusive = Or (rel c (valueLiteral v)) (Eq c (valueLiteral v)) + | otherwise = rel c (valueLiteral v) + +-- | 一批行的列名(空批给空清单) +rowsCols :: [Row] -> [String] +rowsCols (r : _) = map fst r +rowsCols [] = [] + +-- | 参与等值匹配的键:NULL 不参与匹配,整数值的浮点归一到整数 +-- (嵌套循环走 compareValues,哈希要对齐它) +joinKey :: Value -> Maybe Value +joinKey VNull = Nothing +joinKey (VFloat d) + | not (isNaN d) + , not (isInfinite d) + , fromIntegral (rounded :: Integer) == d = + Just (VInt (fromIntegral rounded)) + where + rounded = round d :: Integer +joinKey v = Just v + +joinKeyOf :: String -> Row -> Maybe Value +joinKeyOf c row = lookup c row >>= joinKey data EquiKeys = EquiKeys { ekLeft :: String , ekRight :: String } --- | 一批行的列名(空批给空清单,此时一律退回嵌套循环) -rowsCols :: [Row] -> [String] -rowsCols (r : _) = map fst r -rowsCols [] = [] - -- | 认出 a = b 这种两列等值条件,并且能分清谁在左谁在右 -equiKeys :: [Row] -> [Row] -> Expr -> Maybe EquiKeys -equiKeys lrows rrows (Eq (Col a) (Col b)) +equiKeys :: [String] -> [String] -> Expr -> Maybe EquiKeys +equiKeys lcols rcols (Eq (Col a) (Col b)) | a /= b, a `elem` lcols, b `elem` rcols = Just (EquiKeys a b) | a /= b, b `elem` lcols, a `elem` rcols = Just (EquiKeys b a) - where - lcols = rowsCols lrows - rcols = rowsCols rrows equiKeys _ _ _ = Nothing --- | 哈希连接,输出顺序同嵌套循环 -hashJoin :: EquiKeys -> [Row] -> [Row] -> [Row] -hashJoin keys lrows rrows = concatMap probe lrows +-- | 连接:能认出等值键就走哈希,否则退回嵌套循环;左连接没配上的左行补 NULL +joinRowsIn :: + (MonadStorage m) => + Database -> + Row -> + JoinKind -> + [String] -> + Expr -> + [Row] -> + [Row] -> + m (Either String [Row]) +joinRowsIn db outer kind rcols cond lrows rrows = + case equiKeys (rowsCols lrows) rcols cond of + Just ks -> pure (Right (hashJoin kind ks ncols lrows rrows)) + Nothing -> filterPairsM db outer kind ncols cond lrows rrows where - buckets = - HM.fromListWith - (flip (++)) - [(v, [r]) | r <- rrows, Just v <- [lookup (ekRight keys) r]] - probe l = case lookup (ekLeft keys) l of - Nothing -> [] - Just v -> [l ++ r | r <- HM.lookupDefault [] v buckets] + -- 右表为空时行上没有列名,用算子推导出来的列名补 NULL + ncols = if null rrows then rcols else rowsCols rrows + +-- | 左连接里配不上的左行补右表 NULL;内连接里没有匹配就是没行 +unmatched :: JoinKind -> Row -> [String] -> [Row] +unmatched InnerJoin _ _ = [] +unmatched LeftJoin l cols = [l ++ [(c, VNull) | c <- cols]] + +-- | 哈希连接,输出顺序同嵌套循环 +hashJoin :: JoinKind -> EquiKeys -> [String] -> [Row] -> [Row] -> [Row] +hashJoin kind ks ncols lrows rrows = concatMap probe lrows + where + buckets = HM.fromListWith (flip (++)) [(v, [r]) | r <- rrows, Just v <- [joinKeyOf (ekRight ks) r]] + probe l = + let miss = case kind of + InnerJoin -> [] + LeftJoin -> [l ++ nullRow] + in case joinKeyOf (ekLeft ks) l of + Nothing -> miss + Just v -> case HM.lookupDefault [] v buckets of + [] -> miss + rs -> [l ++ r | r <- rs] + nullRow = [(c, VNull) | c <- ncols] -- | 把两边的行按条件配对(边配边筛,不先建整张积表) -filterPairs :: (Row -> Either String Bool) -> [Row] -> [Row] -> Either String [Row] -filterPairs cond lrows rrows = go lrows [] +filterPairsM :: + (MonadStorage m) => + Database -> + Row -> + JoinKind -> + [String] -> + Expr -> + [Row] -> + [Row] -> + m (Either String [Row]) +filterPairsM db outer kind ncols cond lrows rrows = go lrows [] where - go [] acc = Right (reverse acc) + go [] acc = pure (Right (reverse acc)) go (l : ls) acc = do - acc' <- foldM (keep l) acc rrows - go ls acc' - keep l acc r = do - let row = l ++ r - ok <- cond row - pure (if ok then row : acc else acc) + hits <- keep l rrows [] + case hits of + Left e -> pure (Left e) + Right hs -> do + let hs' = case (kind, hs) of + (LeftJoin, []) -> [l ++ nullRow] + _ -> hs + go ls (reverse hs' ++ acc) + keep _ [] acc = pure (Right (reverse acc)) + keep l (r : rs) acc = do + ok <- evalCondForRowM db cond (l ++ r ++ outer) + case ok of + Left e -> pure (Left e) + Right True -> keep l rs ((l ++ r) : acc) + Right False -> keep l rs acc + nullRow = [(c, VNull) | c <- ncols] --- | 一次连接:能认出等值键就走哈希,否则退回嵌套循环 -joinRows :: Expr -> [Row] -> [Row] -> Either String [Row] -joinRows cond lrows rrows = case equiKeys lrows rrows cond of - Just keys -> Right (hashJoin keys lrows rrows) - Nothing -> filterPairs (evalCondForRow cond) lrows rrows +-- | 索引连接:右表是裸 Scan 时逐左行点查,返回 Nothing 表示该走通用路径 +probeIndexJoin :: + (MonadStorage m) => + Database -> + Row -> + JoinKind -> + Maybe String -> + String -> + EquiKeys -> + [String] -> + Expr -> + Row -> + m (Either String (Maybe [Row])) +probeIndexJoin db outer kind mAlias t ks rcols cond l = case lookup (ekLeft ks) l of + Nothing -> pure (Right (Just (unmatched kind l rcols))) + Just k + | not (indexSafe db t (unqualify mAlias (ekRight ks)) k) -> pure (Right Nothing) + | otherwise -> do + res <- lookupByColumn t (unqualify mAlias (ekRight ks)) k + case res of + Left e -> pure (Left e) + Right NoIndex -> pure (Right Nothing) + Right (IndexRows rows) -> do + pairs <- joinRowsIn db outer kind rcols cond [l] (prefixRows mAlias rows) + pure (fmap Just pairs) --- | 纯求值(不需要存储) +-- | 索引按值的相等匹配,而数值比较会把 2 和 2.0 看成相等, +-- 所以只有非浮点列 + 非浮点键才敢用索引,保证优化不改变结果语义 +indexSafe :: Database -> String -> String -> Value -> Bool +indexSafe db t col k = case lookupTable db t of + Left _ -> False + Right tbl -> case lookup col (tableCols tbl) of + Nothing -> False + Just c -> not (floatType (columnType c)) && not (floatValue k) + +floatType :: ColumnType -> Bool +floatType CFloat = True +floatType CDouble = True +floatType (CDecimal _ _) = True +floatType _ = False + +floatValue :: Value -> Bool +floatValue (VFloat _) = True +floatValue _ = False + +-- | 分组键的值 +groupKey :: [String] -> Row -> Either String [Value] +groupKey keys row = mapM get keys + where + get c = maybe (Left ("unknown column: " ++ c)) Right (lookup c row) + +-- | 按分组键把行分组,保留分组首次出现的顺序 +groupRows :: [String] -> [Row] -> Either String [([Value], [Row])] +groupRows keys rows = go rows (HM.empty, []) + where + go [] (acc, order) = Right [(k, reverse (HM.lookupDefault [] k acc)) | k <- reverse order] + go (r : rs) (acc, order) = do + k <- groupKey keys r + let order' = if HM.member k acc then order else k : order + go rs (HM.insertWith (++) k [r] acc, order') + +-- | 一组行上求同一个表达式(外层列从 outer 取) +valuesOf :: Row -> Expr -> [Row] -> Either String [Value] +valuesOf outer e rows = mapM (evalExpr e . (++ outer)) rows + +-- | 去掉 NULL +nonNull :: [Value] -> [Value] +nonNull = filter (/= VNull) + +-- | 数值相加:整数配整数还是整数 +addValue :: Value -> Value -> Either String Value +addValue (VInt a) (VInt b) = Right (VInt (a + b)) +addValue (VInt a) (VFloat b) = Right (VFloat (fromIntegral a + b)) +addValue (VFloat a) (VInt b) = Right (VFloat (a + fromIntegral b)) +addValue (VFloat a) (VFloat b) = Right (VFloat (a + b)) +addValue _ _ = Left "type error: expected two numbers" + +-- | 能当数用的值 +numberOf :: Value -> Either String Double +numberOf (VInt n) = Right (fromIntegral n) +numberOf (VFloat d) = Right d +numberOf _ = Left "type error: expected a number" + +-- | 一组值里的极值(NULL 已经先剔掉) +extremeOf :: (Value -> Value -> Value) -> [Value] -> Either String Value +extremeOf _ [] = Right VNull +extremeOf pick (x : xs) = Right (foldl pick x xs) + +-- | 一个聚合算子在一组行上的结果 +evalAgg :: Row -> Expr -> [Row] -> Either String Value +evalAgg outer agg rows = case agg of + CountAll -> Right (VInt (length rows)) + CountOf e -> do + vs <- valuesOf outer e rows + Right (VInt (length (nonNull vs))) + SumOf e -> do + vs <- valuesOf outer e rows + case nonNull vs of + [] -> Right VNull + xs -> foldM addValue (VInt 0) xs + AvgOf e -> do + vs <- valuesOf outer e rows + case nonNull vs of + [] -> Right VNull + xs -> do + total <- foldM addValue (VInt 0) xs + sumOf <- numberOf total + Right (VFloat (sumOf / fromIntegral (length xs))) + MinOf e -> valuesOf outer e rows >>= extremeOf lower . nonNull + MaxOf e -> valuesOf outer e rows >>= extremeOf higher . nonNull + _ -> Left "only COUNT, SUM, AVG, MIN and MAX can be aggregated" + where + lower a b = if compareValue b a == LT then b else a + higher a b = if compareValue b a == GT then b else a + +-- | 一组的输出行:分组列 + 各聚合值 +oneGroup :: Row -> [String] -> [(String, Expr)] -> ([Value], [Row]) -> Either String Row +oneGroup outer keys aggs (keyValues, rows) = do + values <- mapM (\(_, e) -> evalAgg outer e rows) aggs + Right (zip keys keyValues ++ zip (map fst aggs) values) + +-- | 分组求值:没有分组列时,即使没有行也要出一行 +evalAggregate :: Row -> [String] -> [(String, Expr)] -> [Row] -> Either String [Row] +evalAggregate outer keys aggs rows = do + groups <- groupRows keys rows + let top = if null keys && null groups then [([], [])] else groups + mapM (oneGroup outer keys aggs) top + +-- | 单子过滤:条件本身是单子求值的 +filterRowsM :: (Monad m) => (Row -> m (Either String Bool)) -> [Row] -> m (Either String [Row]) +filterRowsM p = go [] + where + go acc [] = pure (Right (reverse acc)) + go acc (r : rs) = do + keep <- p r + case keep of + Left e -> pure (Left e) + Right True -> go (r : acc) rs + Right False -> go acc rs + +-- | 按投影顺序逐项求值(可以带子查询) +computeM :: (MonadStorage m) => Database -> Row -> [(String, Expr)] -> Row -> m (Either String Row) +computeM db outer items row = go items [] + where + go [] acc = pure (Right (reverse acc)) + go ((label, e) : rest) acc = do + v <- evalExprM db e (row ++ outer) + case v of + Left err -> pure (Left err) + Right val -> go rest ((label, val) : acc) + +-- | 表达式求值(先把子查询求成字面量) +evalExprM :: (MonadStorage m) => Database -> Expr -> Row -> m (Either String Value) +evalExprM db e env = do + e' <- substSubqueries db e env + pure (e' >>= \x -> evalExpr x env) + +-- | 条件求值(先把子查询求成字面量) +evalCondForRowM :: (MonadStorage m) => Database -> Expr -> Row -> m (Either String Bool) +evalCondForRowM db e env = do + e' <- substSubqueries db e env + pure (e' >>= \x -> evalCondForRow x env) + +-- | 子查询结果里唯一那一列的值 +rowValue :: Row -> Either String Value +rowValue [(_, v)] = Right v +rowValue _ = Left "subquery must return exactly one column" + +-- | 标量子查询:0 行给 NULL,多行报错 +scalarResult :: Either String [Row] -> Either String Expr +scalarResult rows = do + rs <- rows + case rs of + [] -> Right LitNull + [r] -> valueLiteral <$> rowValue r + _ -> Left "scalar subquery returned more than one row" + +-- | 把子查询求值成字面量;子查询的错直接往上传 +substSubqueries :: (MonadStorage m) => Database -> Expr -> Row -> m (Either String Expr) +substSubqueries db e env = case e of + ScalarSub sq -> scalarResult <$> runSubqueryRows db sq env + InSub a sq negated -> do + lhs <- substSubqueries db a env + rows <- runSubqueryRows db sq env + pure (do + a' <- lhs + rs <- rows + vs <- mapM rowValue rs + Right (InList a' (map valueLiteral vs) negated)) + ExistsSub sq negated -> do + rows <- runSubqueryRows db sq env + pure (fmap (\rs -> LitBool (if negated then null rs else not (null rs))) rows) + InList a es negated -> do + lhs <- substSubqueries db a env + es' <- mapM (\x -> substSubqueries db x env) es + pure (do + a' <- lhs + items <- sequence es' + Right (InList a' items negated)) + Add a b -> bin Add a b + Sub a b -> bin Sub a b + Mul a b -> bin Mul a b + Div a b -> bin Div a b + Neg a -> un Neg a + Gt a b -> bin Gt a b + Lt a b -> bin Lt a b + Eq a b -> bin Eq a b + And a b -> bin And a b + Or a b -> bin Or a b + IsNull a -> un IsNull a + IsNotNull a -> un IsNotNull a + CountOf a -> un CountOf a + SumOf a -> un SumOf a + AvgOf a -> un AvgOf a + MinOf a -> un MinOf a + MaxOf a -> un MaxOf a + _ -> pure (Right e) + where + bin ctor a b = do + ra <- substSubqueries db a env + case ra of + Left err -> pure (Left err) + Right a' -> do + rb <- substSubqueries db b env + pure ((ctor a') <$> rb) + un ctor a = do + ra <- substSubqueries db a env + pure (ctor <$> ra) + +-- | 跑子查询:翻译成算子树再求值,外层行做相关子查询的绑定 +runSubqueryRows :: (MonadStorage m) => Database -> Subquery -> Row -> m (Either String [Row]) +runSubqueryRows db sq outer = case translate (subqueryStatement sq) of + Left e -> pure (Left e) + Right op -> evalRelOpIn db outer (optimize db op) + +-- | 单子求值:Scan/点查/范围查都问存储 +evalRelOpM :: (MonadStorage m) => Database -> RelOp -> m (Either String [Row]) +evalRelOpM db = evalRelOpIn db [] + +-- | 纯求值(内存存储,子查询走同一条路径) evalRelOp :: Database -> RelOp -> Either String [Row] -evalRelOp db (Scan mAlias tbl) = evalScan db mAlias tbl -evalRelOp db (Lookup mAlias tbl col k) = do - rows <- evalScan db mAlias tbl - filterM (evalCondForRow (lookupCondition mAlias col k)) rows -evalRelOp db (Filter e op) = do - rows <- evalRelOp db op - filterM (evalCondForRow e) rows -evalRelOp db (Project cols op) = do - rows <- evalRelOp db op - Right (map (project cols) rows) -evalRelOp db (Sort spec op) = do - rows <- evalRelOp db op - Right (sortRows spec rows) -evalRelOp db (Limit n op) = do - rows <- evalRelOp db op - Right (take n rows) -evalRelOp db (Join left right cond) = do - lrows <- evalRelOp db left - rrows <- evalRelOp db right - joinRows cond lrows rrows +evalRelOp db op = do + (result, _) <- runMemoryStorage (evalRelOpIn db [] op) db + result --- | 单子求值:点查先问存储有无索引 -evalRelOpM :: (MonadStorage m) => RelOp -> m (Either String [Row]) -evalRelOpM (Scan mAlias t) = do - result <- scan t - pure $ do - rows <- result - case mAlias of - Nothing -> Right rows - Just _ -> Right (map (addPrefix mAlias) rows) -evalRelOpM (Lookup mAlias t col k) = do - result <- lookupByColumn t col k - case result of +-- | 带外层行的执行器 +evalRelOpIn :: (MonadStorage m) => Database -> Row -> RelOp -> m (Either String [Row]) +evalRelOpIn _ _ Unit = pure (Right [[]]) +evalRelOpIn db outer (Compute items op) = do + rows <- evalRelOpIn db outer op + case rows of Left e -> pure (Left e) - Right NoIndex -> do - rows <- evalRelOpM (Scan mAlias t) - pure $ do - rs <- rows - filterM (evalCondForRow (lookupCondition mAlias col k)) rs - Right (IndexRow Nothing) -> pure (Right []) - Right (IndexRow (Just row)) -> pure (Right [addPrefix mAlias row]) -evalRelOpM (Filter p x) = do - result <- evalRelOpM x + Right rs -> do + computed <- mapM (computeM db outer items) rs + pure (sequence computed) +evalRelOpIn db outer (Aggregate keys aggs op) = do + rows <- evalRelOpIn db outer op + pure (rows >>= evalAggregate outer keys aggs) +evalRelOpIn _ _ (Scan mAlias tbl cols) = do + res <- maybe (scan tbl) (scanColumns tbl) cols + pure (fmap (prefixRows mAlias) res) +evalRelOpIn db outer (Lookup mAlias tbl col k) = do + res <- lookupByColumn tbl col k + case res of + Left e -> pure (Left e) + Right NoIndex -> scanFiltered db outer mAlias tbl (lookupCondition mAlias col k) + Right (IndexRows rows) -> pure (Right (prefixRows mAlias rows)) +evalRelOpIn db outer (Range mAlias tbl col lo hi) = do + res <- scanRange tbl col lo hi + case res of + Left e -> pure (Left e) + Right Nothing -> scanFiltered db outer mAlias tbl (rangeCondition mAlias col lo hi) + Right (Just rows) -> pure (Right (prefixRows mAlias rows)) +evalRelOpIn db outer (Filter e op) = do + rows <- evalRelOpIn db outer op + case rows of + Left err -> pure (Left err) + Right rs -> filterRowsM (\r -> evalCondForRowM db e (r ++ outer)) rs +evalRelOpIn db outer (Project cols op) = do + rows <- evalRelOpIn db outer op + pure (fmap (map (project cols)) rows) +evalRelOpIn db outer (Sort spec op) = do + rows <- evalRelOpIn db outer op + pure (fmap (sortRows spec) rows) +evalRelOpIn db outer (Limit n op) = do + rows <- evalRelOpIn db outer op + pure (fmap (take n) rows) +evalRelOpIn db outer (Join kind l r c) = do + lres <- evalRelOpIn db outer l + case lres of + Left e -> pure (Left e) + Right ls -> do + let nested = do + rres <- evalRelOpIn db outer r + case rres of + Left e -> pure (Left e) + Right rs -> joinRowsIn db outer kind (relOpCols db r) c ls rs + case (r, equiKeys (relOpCols db l) (relOpCols db r) c) of + (Scan mAlias t _, Just ks) -> do + probed <- mapM (probeIndexJoin db outer kind mAlias t ks (relOpCols db r) c) ls + case sequence probed of + Left e -> pure (Left e) + Right ms -> case sequence ms of + Just rows -> pure (Right (concat rows)) + Nothing -> nested + _ -> nested + +-- | 扫描后按条件过滤(点查/范围查退化成扫描时用) +scanFiltered :: + (MonadStorage m) => + Database -> + Row -> + Maybe String -> + String -> + Expr -> + m (Either String [Row]) +scanFiltered _ outer mAlias tbl cond = do + res <- scan tbl pure $ do - rows <- result - filterM (evalCondForRow p) rows -evalRelOpM (Project cols x) = do - result <- evalRelOpM x - pure (fmap (map (project cols)) result) -evalRelOpM (Sort spec x) = do - result <- evalRelOpM x - pure (fmap (sortRows spec) result) -evalRelOpM (Limit n x) = do - result <- evalRelOpM x - pure (fmap (take n) result) -evalRelOpM (Join l r cond) = do - ls <- evalRelOpM l - rs <- evalRelOpM r - pure $ do - left <- ls - right <- rs - joinRows cond left right + rows <- res + filterM (evalCondForRow cond . (++ outer)) (prefixRows mAlias rows) diff --git a/chusql-engine/src/ChuSQL/Algebra/Expr.hs b/chusql-engine/src/ChuSQL/Algebra/Expr.hs index 1f8295e..5ab1de0 100644 --- a/chusql-engine/src/ChuSQL/Algebra/Expr.hs +++ b/chusql-engine/src/ChuSQL/Algebra/Expr.hs @@ -1,40 +1,188 @@ -module ChuSQL.Algebra.Expr (evalExpr, evalCondForRow, colsInExpr) where +module ChuSQL.Algebra.Expr (evalExpr, evalCondForRow, colsInExpr, aggregatesIn, hasAggregate, bareColumns, hasSubquery, inValues, threeValuedNot) where import ChuSQL.Model import ChuSQL.Syntax.AST import Control.Monad (join) --- 表达式求值:拿一行数据算出一个值。 +-- 表达式求值:三值逻辑,NULL 参与运算结果仍是 NULL。 -- | 在一行上求出表达式的值 evalExpr :: Expr -> Row -> Either String Value evalExpr (Col name) row = maybe (Left ("unknown column: " ++ name)) Right (lookup name row) +evalExpr LitNull _ = Right VNull evalExpr (LitInt n) _ = Right (VInt n) +evalExpr (LitFloat d) _ = Right (VFloat d) evalExpr (LitStr s) _ = Right (VStr s) evalExpr (LitBool b) _ = Right (VBool b) -evalExpr (Gt a b) row = liftBinOp (intOp (>)) a b row -evalExpr (Lt a b) row = liftBinOp (intOp (<)) a b row -evalExpr (Eq a b) row = liftBinOp eqOp a b row -evalExpr (And a b) row = liftBinOp (boolOp (&&)) a b row -evalExpr (Or a b) row = liftBinOp (boolOp (||)) a b row +evalExpr (LitDate s) _ = Right (VStr s) +evalExpr (LitTimestamp s) _ = Right (VStr s) +evalExpr (LitBlob s) _ = Right (VStr s) +evalExpr (Add a b) row = liftArith addArith a b row +evalExpr (Sub a b) row = liftArith subArith a b row +evalExpr (Mul a b) row = liftArith mulArith a b row +evalExpr (Div a b) row = liftArith divArith a b row +evalExpr (Neg a) row = do + v <- evalExpr a row + case v of + VNull -> Right VNull + VInt n -> Right (VInt (negate n)) + VFloat d -> Right (VFloat (negate d)) + _ -> Left "type error: expected a number" +evalExpr (Gt a b) row = liftCompare (> EQ) a b row +evalExpr (Lt a b) row = liftCompare (< EQ) a b row +evalExpr (Eq a b) row = liftCompare (== EQ) a b row +evalExpr (And a b) row = liftBool andValues a b row +evalExpr (Or a b) row = liftBool orValues a b row +evalExpr (IsNull a) row = do + v <- evalExpr a row + Right (VBool (v == VNull)) +evalExpr (IsNotNull a) row = do + v <- evalExpr a row + Right (VBool (v /= VNull)) +evalExpr (InList a es negated) row = do + v <- evalExpr a row + vs <- mapM (\e -> evalExpr e row) es + r <- inValues v vs + Right (if negated then threeValuedNot r else r) +evalExpr (ScalarSub _) _ = Left subqueryNeedsDatabase +evalExpr (InSub _ _ _) _ = Left subqueryNeedsDatabase +evalExpr (ExistsSub _ _) _ = Left subqueryNeedsDatabase +evalExpr agg _ = Left ("aggregate functions are not allowed here: " ++ show agg) + +-- | 子查询要连上存储才能跑 +subqueryNeedsDatabase :: String +subqueryNeedsDatabase = "subquery needs a database to run" + +-- | IN 的三值逻辑:命中为真,没命中但碰到 NULL 就给 NULL,否则为假 +inValues :: Value -> [Value] -> Either String Value +inValues VNull _ = Right VNull +inValues v vs = do + results <- mapM (compareValues (== EQ) v) vs + Right $ + if VBool True `elem` results + then VBool True + else if VNull `elem` results then VNull else VBool False + +-- | NOT 的三值取反 +threeValuedNot :: Value -> Value +threeValuedNot (VBool b) = VBool (not b) +threeValuedNot _ = VNull -- | 表达式用到了哪些列 colsInExpr :: Expr -> [String] colsInExpr (Col c) = [c] +colsInExpr (Add a b) = colsInExpr a ++ colsInExpr b +colsInExpr (Sub a b) = colsInExpr a ++ colsInExpr b +colsInExpr (Mul a b) = colsInExpr a ++ colsInExpr b +colsInExpr (Div a b) = colsInExpr a ++ colsInExpr b +colsInExpr (Neg a) = colsInExpr a colsInExpr (Gt a b) = colsInExpr a ++ colsInExpr b colsInExpr (Lt a b) = colsInExpr a ++ colsInExpr b colsInExpr (Eq a b) = colsInExpr a ++ colsInExpr b colsInExpr (And a b) = colsInExpr a ++ colsInExpr b colsInExpr (Or a b) = colsInExpr a ++ colsInExpr b +colsInExpr (IsNull a) = colsInExpr a +colsInExpr (IsNotNull a) = colsInExpr a +colsInExpr (CountOf a) = colsInExpr a +colsInExpr (SumOf a) = colsInExpr a +colsInExpr (AvgOf a) = colsInExpr a +colsInExpr (MinOf a) = colsInExpr a +colsInExpr (MaxOf a) = colsInExpr a +colsInExpr (ScalarSub sq) = subqueryRefs sq +colsInExpr (InSub a sq _) = colsInExpr a ++ subqueryRefs sq +colsInExpr (InList a es _) = colsInExpr a ++ concatMap colsInExpr es +colsInExpr (ExistsSub sq _) = subqueryRefs sq colsInExpr _ = [] --- | 判断条件真假 +-- | 表达式里有没有子查询 +hasSubquery :: Expr -> Bool +hasSubquery e = case e of + ScalarSub _ -> True + InSub _ _ _ -> True + InList a es _ -> any hasSubquery (a : es) + ExistsSub _ _ -> True + Add a b -> both a b + Sub a b -> both a b + Mul a b -> both a b + Div a b -> both a b + Neg a -> hasSubquery a + Gt a b -> both a b + Lt a b -> both a b + Eq a b -> both a b + And a b -> both a b + Or a b -> both a b + IsNull a -> hasSubquery a + IsNotNull a -> hasSubquery a + CountOf a -> hasSubquery a + SumOf a -> hasSubquery a + AvgOf a -> hasSubquery a + MinOf a -> hasSubquery a + MaxOf a -> hasSubquery a + _ -> False + where + both a b = hasSubquery a || hasSubquery b + +-- | 表达式里出现的聚合调用(按出现顺序) +aggregatesIn :: Expr -> [Expr] +aggregatesIn e = case e of + a@CountAll -> [a] + a@(CountOf _) -> [a] + a@(SumOf _) -> [a] + a@(AvgOf _) -> [a] + a@(MinOf _) -> [a] + a@(MaxOf _) -> [a] + Add a b -> both a b + Sub a b -> both a b + Mul a b -> both a b + Div a b -> both a b + Neg a -> aggregatesIn a + Gt a b -> both a b + Lt a b -> both a b + Eq a b -> both a b + And a b -> both a b + Or a b -> both a b + IsNull a -> aggregatesIn a + IsNotNull a -> aggregatesIn a + _ -> [] + where + both a b = aggregatesIn a ++ aggregatesIn b + +-- | 表达式里有没有聚合调用 +hasAggregate :: Expr -> Bool +hasAggregate = not . null . aggregatesIn + +-- | 聚合之外的列引用(聚合参数里的列不算) +bareColumns :: Expr -> [String] +bareColumns e = case e of + Col c -> [c] + Add a b -> both a b + Sub a b -> both a b + Mul a b -> both a b + Div a b -> both a b + Neg a -> bareColumns a + Gt a b -> both a b + Lt a b -> both a b + Eq a b -> both a b + And a b -> both a b + Or a b -> both a b + IsNull a -> bareColumns a + IsNotNull a -> bareColumns a + ScalarSub sq -> subqueryRefs sq + InSub a sq _ -> bareColumns a ++ subqueryRefs sq + InList a es _ -> bareColumns a ++ concatMap bareColumns es + ExistsSub sq _ -> subqueryRefs sq + _ -> [] + where + both a b = bareColumns a ++ bareColumns b + +-- | 判断条件真假:只有 TRUE 通过,NULL 当作不通过 evalCondForRow :: Expr -> Row -> Either String Bool evalCondForRow e row = do v <- evalExpr e row case v of VBool b -> Right b + VNull -> Right False _ -> Left "type error: WHERE condition must be a boolean" -- | 把二元运算提升到 Either @@ -46,16 +194,89 @@ liftBinOp :: Either String Value liftBinOp f a b row = join (f <$> evalExpr a row <*> evalExpr b row) --- | 整数二元运算 -intOp :: (Int -> Int -> Bool) -> Value -> Value -> Either String Value -intOp f (VInt x) (VInt y) = Right (VBool (f x y)) -intOp _ _ _ = Left "type error: expected two integers" +-- | 整数算术与浮点算术成对出现 +data Arith = Arith + { arithInt :: Int -> Int -> Either String Int + , arithFloat :: Double -> Double -> Either String Double + } + +-- | 一边是 NULL 就出 NULL,两边都是整数就出整数 +liftArith :: Arith -> Expr -> Expr -> Row -> Either String Value +liftArith ops a b row = liftBinOp go a b row + where + go VNull _ = Right VNull + go _ VNull = Right VNull + go (VInt x) (VInt y) = VInt <$> arithInt ops x y + go x y = case (asDouble x, asDouble y) of + (Just p, Just q) -> VFloat <$> arithFloat ops p q + _ -> Left "type error: expected two numbers" + +-- | 能当数用的值 +asDouble :: Value -> Maybe Double +asDouble (VInt n) = Just (fromIntegral n) +asDouble (VFloat d) = Just d +asDouble _ = Nothing + +addArith :: Arith +addArith = Arith (\x y -> Right (x + y)) (\x y -> Right (x + y)) + +subArith :: Arith +subArith = Arith (\x y -> Right (x - y)) (\x y -> Right (x - y)) + +mulArith :: Arith +mulArith = Arith (\x y -> Right (x * y)) (\x y -> Right (x * y)) + +-- | 整数除法向零截断,溢出返回错误;浮点除法不能除以零 +divArith :: Arith +divArith = Arith intDiv floatDiv + where + intDiv _ 0 = Left "division by zero" + intDiv x y + | x == minBound && y == -1 = Left "integer division overflow" + | otherwise = Right (x `quot` y) + floatDiv _ 0 = Left "division by zero" + floatDiv x y = Right (x / y) + +-- | 比较:任一侧是 NULL 就给 NULL +liftCompare :: + (Ordering -> Bool) -> + Expr -> + Expr -> + Row -> + Either String Value +liftCompare rel a b row = liftBinOp (compareValues rel) a b row + +compareValues :: (Ordering -> Bool) -> Value -> Value -> Either String Value +compareValues rel x y + | x == VNull || y == VNull = Right VNull + | Just p <- asDouble x, Just q <- asDouble y = Right (VBool (rel (compare p q))) + | (VStr p, VStr q) <- (x, y) = Right (VBool (rel (compare p q))) + | (VBool p, VBool q) <- (x, y) = Right (VBool (rel (compare p q))) + | otherwise = Left "type error: cannot compare these values" -- | 布尔二元运算 -boolOp :: (Bool -> Bool -> Bool) -> Value -> Value -> Either String Value -boolOp f (VBool x) (VBool y) = Right (VBool (f x y)) -boolOp _ _ _ = Left "type error: expected two booleans" +liftBool :: + (Value -> Value -> Either String Value) -> + Expr -> + Expr -> + Row -> + Either String Value +liftBool f = liftBinOp f --- | 相等比较 -eqOp :: Value -> Value -> Either String Value -eqOp x y = Right (VBool (x == y)) +andValues :: Value -> Value -> Either String Value +andValues (VBool False) _ = Right (VBool False) +andValues _ (VBool False) = Right (VBool False) +andValues (VBool True) (VBool True) = Right (VBool True) +andValues VNull (VBool True) = Right VNull +andValues (VBool True) VNull = Right VNull +andValues VNull VNull = Right VNull +andValues _ _ = Left "type error: expected two booleans" + +orValues :: Value -> Value -> Either String Value +orValues (VBool True) _ = Right (VBool True) +orValues _ (VBool True) = Right (VBool True) +orValues (VBool False) (VBool False) = Right (VBool False) +orValues VNull (VBool False) = Right VNull +orValues (VBool False) VNull = Right VNull +orValues VNull VNull = Right VNull +orValues _ _ = Left "type error: expected two booleans" diff --git a/chusql-engine/src/ChuSQL/Algebra/Op.hs b/chusql-engine/src/ChuSQL/Algebra/Op.hs index 25e1dd8..2d95689 100644 --- a/chusql-engine/src/ChuSQL/Algebra/Op.hs +++ b/chusql-engine/src/ChuSQL/Algebra/Op.hs @@ -1,30 +1,51 @@ module ChuSQL.Algebra.Op (RelOp (..), renderPlan) where -import ChuSQL.Syntax.AST (Expr, SortDir) +import ChuSQL.Model (Value) +import ChuSQL.Syntax.AST (Expr, JoinKind (..), SortDir) -- 关系代数算子,以及把算子树渲染成文本。 data RelOp - = Scan (Maybe String) String - | Lookup (Maybe String) String String Int + = Scan (Maybe String) String (Maybe [String]) + | Lookup (Maybe String) String String Value + | Range (Maybe String) String String (Maybe (Value, Bool)) (Maybe (Value, Bool)) | Filter Expr RelOp | Project [String] RelOp + | Compute [(String, Expr)] RelOp + | Aggregate [String] [(String, Expr)] RelOp + | Unit | Sort [(String, SortDir)] RelOp | Limit Int RelOp - | Join RelOp RelOp Expr + | Join JoinKind RelOp RelOp Expr deriving (Show, Eq) +-- | 连接的显示名 +kindLabel :: JoinKind -> String +kindLabel InnerJoin = "Join" +kindLabel LeftJoin = "LeftJoin" + -- | 渲染成缩进文本 renderPlan :: RelOp -> String renderPlan = go 0 where go ind op = case op of - Scan a t -> pad ++ "Scan " ++ show a ++ " " ++ show t + Scan a t cols -> pad ++ "Scan " ++ show a ++ " " ++ show t ++ maybe "" ((" columns=" ++) . show) cols Lookup _ t c k -> pad ++ "Lookup " ++ show t ++ "." ++ show c ++ " = " ++ show k + Range _ t c lo hi -> pad ++ "Range " ++ show t ++ "." ++ show c ++ " " ++ show lo ++ " .. " ++ show hi Filter e x -> pad ++ "Filter " ++ show e ++ "\n" ++ go (ind + 1) x Project c x -> pad ++ "Project " ++ show c ++ "\n" ++ go (ind + 1) x + Compute c x -> pad ++ "Compute " ++ show c ++ "\n" ++ go (ind + 1) x + Aggregate keys aggs x -> + pad + ++ "Aggregate keys=" + ++ show keys + ++ " aggs=" + ++ show [label ++ " <- " ++ show e | (label, e) <- aggs] + ++ "\n" + ++ go (ind + 1) x + Unit -> pad ++ "Unit" Sort s x -> pad ++ "Sort " ++ show s ++ "\n" ++ go (ind + 1) x Limit n x -> pad ++ "Limit " ++ show n ++ "\n" ++ go (ind + 1) x - Join l r c -> pad ++ "Join on " ++ show c ++ "\n" ++ go (ind + 1) l ++ "\n" ++ go (ind + 1) r + Join k l r c -> pad ++ kindLabel k ++ " on " ++ show c ++ "\n" ++ go (ind + 1) l ++ "\n" ++ go (ind + 1) r where pad = replicate (ind * 2) ' ' diff --git a/chusql-engine/src/ChuSQL/Algebra/Optimize.hs b/chusql-engine/src/ChuSQL/Algebra/Optimize.hs index c6b822c..3593640 100644 --- a/chusql-engine/src/ChuSQL/Algebra/Optimize.hs +++ b/chusql-engine/src/ChuSQL/Algebra/Optimize.hs @@ -1,27 +1,33 @@ -module ChuSQL.Algebra.Optimize (optimize, pushProject) where +module ChuSQL.Algebra.Optimize (optimize, pushProject, relOpCols) where import ChuSQL.Algebra.Expr (colsInExpr, evalExpr) import ChuSQL.Algebra.Op (RelOp (..)) import ChuSQL.Model -import ChuSQL.Syntax.AST (Expr (..)) +import ChuSQL.Syntax.AST (Expr (..), JoinKind (..)) +import Data.List (nub) -- 查询优化:谓词下推 + 投影下推 + 常量折叠,跑到不动点。 -- | 算子会产出哪些列 relOpCols :: Database -> RelOp -> [String] -relOpCols db (Scan mAlias tbl) = +relOpCols _ Unit = [] +relOpCols _ (Compute items _) = map fst items +relOpCols _ (Aggregate keys aggs _) = keys ++ map fst aggs +relOpCols _ (Scan mAlias _ (Just cols)) = map (qualify mAlias) cols +relOpCols db (Scan mAlias tbl Nothing) = case lookup tbl db of Nothing -> [] Just t -> map (prefix ++) (colNames t) where prefix = maybe "" (++ ".") mAlias -relOpCols db (Lookup mAlias tbl _ _) = relOpCols db (Scan mAlias tbl) +relOpCols db (Lookup mAlias tbl _ _) = relOpCols db (Scan mAlias tbl Nothing) +relOpCols db (Range mAlias tbl _ _ _) = relOpCols db (Scan mAlias tbl Nothing) relOpCols db (Filter _ x) = relOpCols db x relOpCols _ (Project ["*"] _) = ["*"] relOpCols _ (Project cols _) = cols relOpCols db (Sort _ x) = relOpCols db x relOpCols db (Limit _ x) = relOpCols db x -relOpCols db (Join l r _) = relOpCols db l ++ relOpCols db r +relOpCols db (Join _ l r _) = relOpCols db l ++ relOpCols db r -- | need 是否都在 have 里 isSubsetOf :: [String] -> [String] -> Bool @@ -51,7 +57,7 @@ applyFilters :: [Expr] -> RelOp -> RelOp applyFilters [] op = op applyFilters (e : es) op = applyFilters es (Filter e op) --- | Filter 压在 Join 上时下推 +-- | Filter 压在内连接上时下推 pushJoin :: Database -> Expr -> RelOp -> RelOp -> Expr -> RelOp pushJoin db p l r c = let preds = splitConj p @@ -61,17 +67,56 @@ pushJoin db p l r c = (rPs, bothPs) = partitionPred rCols rest1 l' = applyFilters lPs l r' = applyFilters rPs r - joined = Join l' r' c + joined = Join InnerJoin l' r' c in case combineConj bothPs of Nothing -> joined Just p' -> Filter p' joined +-- | Filter 压在左连接上时,只把纯左表的谓词下推到左表; +-- 右表没配上会补 NULL,右表谓词不能提前算。 +pushLeftJoin :: Database -> Expr -> RelOp -> RelOp -> Expr -> RelOp +pushLeftJoin db p l r c = + let (lPs, rest) = partitionPred (relOpCols db l) (splitConj p) + joined = Join LeftJoin (applyFilters lPs l) r c + in case combineConj rest of + Nothing -> joined + Just p' -> Filter p' joined + -- | 合并两层 Filter 或挪到 Sort 下 pushOne :: Database -> RelOp -> RelOp -pushOne _ (Filter p (Filter q x)) = Filter (And p q) x -pushOne _ (Filter p (Sort spec x)) = Sort spec (Filter p x) +pushOne _ (Filter p (Filter q x)) + | not (hasDivision p || hasDivision q) = Filter (And p q) x +pushOne _ (Filter p (Sort spec x)) + | not (hasDivision p) = Sort spec (Filter p x) pushOne _ op = op +-- | 除法可能报错,不能提前或延后求值 +hasDivision :: Expr -> Bool +hasDivision e = case e of + Div _ _ -> True + Neg a -> hasDivision a + Add a b -> both a b + Sub a b -> both a b + Mul a b -> both a b + Gt a b -> both a b + Lt a b -> both a b + Eq a b -> both a b + And a b -> both a b + Or a b -> both a b + _ -> False + where + both a b = hasDivision a || hasDivision b + +-- | 检查子计划中可能报错的除法 +planHasDivision :: RelOp -> Bool +planHasDivision (Filter p x) = hasDivision p || planHasDivision x +planHasDivision (Join _ l r c) = hasDivision c || planHasDivision l || planHasDivision r +planHasDivision (Compute items x) = any (hasDivision . snd) items || planHasDivision x +planHasDivision (Project _ x) = planHasDivision x +planHasDivision (Sort _ x) = planHasDivision x +planHasDivision (Limit _ x) = planHasDivision x +planHasDivision _ = False + -- | 是否要全部列 needsAll :: [String] -> Bool needsAll = elem allColumns @@ -85,29 +130,39 @@ pushProjectLeaf db need leaf -- | 把需要哪些列往下传 pushProject :: Database -> [String] -> RelOp -> RelOp +pushProject _ _ Unit = Unit +pushProject db _ (Compute items x) = Compute items (pushProject db (concatMap (colsInExpr . snd) items) x) +pushProject db _ (Aggregate keys aggs x) = + Aggregate keys aggs (pushProject db (keys ++ concatMap (colsInExpr . snd) aggs) x) pushProject db _ (Project cols x) = case pushProject db cols x of Project cols' y | cols' == cols -> Project cols y other -> Project cols other pushProject db need (Filter p x) = Filter p (pushProject db (need ++ colsInExpr p) x) pushProject db need (Sort spec x) = Sort spec (pushProject db (need ++ map fst spec) x) pushProject db need (Limit n x) = Limit n (pushProject db need x) -pushProject db need (Join l r c) - | needsAll need = Join (pushProject db need l) (pushProject db need r) c +pushProject db need (Join kind l r c) + | needsAll need = Join kind (pushProject db need l) (pushProject db need r) c | otherwise = let tot = need ++ colsInExpr c lCols = relOpCols db l rCols = relOpCols db r lNeed = if needsAll lCols then ["*"] else [x | x <- tot, x `elem` lCols] rNeed = if needsAll rCols then ["*"] else [x | x <- tot, x `elem` rCols] - in Join (pushProject db lNeed l) (pushProject db rNeed r) c -pushProject db need leaf@(Scan _ _) = pushProjectLeaf db need leaf + in Join kind (pushProject db lNeed l) (pushProject db rNeed r) c +pushProject db need leaf@(Scan a t _) + | needsAll need = leaf + | all (`elem` need) (relOpCols db leaf) = leaf + | otherwise = Scan a t (Just (nub (map (unqualify a) need))) pushProject db need leaf@(Lookup _ _ _ _) = pushProjectLeaf db need leaf +pushProject db need leaf@(Range _ _ _ _ _) = pushProjectLeaf db need leaf -- | 折一个表达式节点 foldNode :: Expr -> Expr foldNode e | null (colsInExpr e) = case evalExpr e [] of + Right VNull -> LitNull Right (VInt n) -> LitInt n + Right (VFloat d) -> LitFloat d Right (VStr s) -> LitStr s Right (VBool b) -> LitBool b Left _ -> e @@ -115,6 +170,11 @@ foldNode e -- | 自底向上折整棵表达式 foldConstants :: Expr -> Expr +foldConstants (Add a b) = foldNode (Add (foldConstants a) (foldConstants b)) +foldConstants (Sub a b) = foldNode (Sub (foldConstants a) (foldConstants b)) +foldConstants (Mul a b) = foldNode (Mul (foldConstants a) (foldConstants b)) +foldConstants (Div a b) = foldNode (Div (foldConstants a) (foldConstants b)) +foldConstants (Neg a) = foldNode (Neg (foldConstants a)) foldConstants (Gt a b) = foldNode (Gt (foldConstants a) (foldConstants b)) foldConstants (Lt a b) = foldNode (Lt (foldConstants a) (foldConstants b)) foldConstants (Eq a b) = foldNode (Eq (foldConstants a) (foldConstants b)) @@ -124,13 +184,32 @@ foldConstants e = e -- | 单节点重写 rewriteNode :: Database -> RelOp -> RelOp -rewriteNode _ (Filter (Eq (Col k) (LitInt v)) (Scan mAlias t)) = - Lookup mAlias t (unqualify mAlias k) v -rewriteNode db (Filter p (Join l r c)) = pushJoin db p l r c +rewriteNode _ (Filter (Eq (Col k) (LitInt v)) (Scan mAlias t Nothing)) = + Lookup mAlias t (unqualify mAlias k) (VInt v) +rewriteNode _ (Filter (Eq (Col k) (LitStr v)) (Scan mAlias t Nothing)) = + Lookup mAlias t (unqualify mAlias k) (VStr v) +rewriteNode _ (Filter (And (Gt (Col k1) (LitInt lo)) (Lt (Col k2) (LitInt hi))) (Scan mAlias t Nothing)) + | k1 == k2 = Range mAlias t (unqualify mAlias k1) (Just (VInt lo, False)) (Just (VInt hi, False)) +rewriteNode _ (Filter (And (Gt (Col k1) (LitStr lo)) (Lt (Col k2) (LitStr hi))) (Scan mAlias t Nothing)) + | k1 == k2 = Range mAlias t (unqualify mAlias k1) (Just (VStr lo, False)) (Just (VStr hi, False)) +rewriteNode _ (Filter (Gt (Col k) (LitInt v)) (Scan mAlias t Nothing)) = + Range mAlias t (unqualify mAlias k) (Just (VInt v, False)) Nothing +rewriteNode _ (Filter (Gt (Col k) (LitStr v)) (Scan mAlias t Nothing)) = + Range mAlias t (unqualify mAlias k) (Just (VStr v, False)) Nothing +rewriteNode _ (Filter (Lt (Col k) (LitInt v)) (Scan mAlias t Nothing)) = + Range mAlias t (unqualify mAlias k) Nothing (Just (VInt v, False)) +rewriteNode _ (Filter (Lt (Col k) (LitStr v)) (Scan mAlias t Nothing)) = + Range mAlias t (unqualify mAlias k) Nothing (Just (VStr v, False)) +rewriteNode db (Filter p (Join kind l r c)) + | not (hasDivision p || planHasDivision (Join kind l r c)) = + case kind of + InnerJoin -> pushJoin db p l r c + LeftJoin -> pushLeftJoin db p l r c rewriteNode db (Filter p x) = case foldConstants p of LitBool True -> x p' -> pushOne db (Filter p' x) rewriteNode _ (Project ["*"] x) = x +rewriteNode _ (Compute items x) = Compute [(label, foldConstants e) | (label, e) <- items] x rewriteNode db op = pushOne db op -- | 自底向上遍历一次 @@ -139,9 +218,11 @@ optimizeRelOp db = rewriteNode db . descend where descend (Filter p x) = Filter p (optimizeRelOp db x) descend (Project cols x) = Project cols (optimizeRelOp db x) + descend (Compute items x) = Compute items (optimizeRelOp db x) + descend (Aggregate keys aggs x) = Aggregate keys aggs (optimizeRelOp db x) descend (Sort spec x) = Sort spec (optimizeRelOp db x) descend (Limit n x) = Limit n (optimizeRelOp db x) - descend (Join l r c) = Join (optimizeRelOp db l) (optimizeRelOp db r) c + descend (Join kind l r c) = Join kind (optimizeRelOp db l) (optimizeRelOp db r) c descend x = x -- | 反复跑到不动点 diff --git a/chusql-engine/src/ChuSQL/Algebra/Planner.hs b/chusql-engine/src/ChuSQL/Algebra/Planner.hs index 404bab0..a5947ad 100644 --- a/chusql-engine/src/ChuSQL/Algebra/Planner.hs +++ b/chusql-engine/src/ChuSQL/Algebra/Planner.hs @@ -1,5 +1,6 @@ module ChuSQL.Algebra.Planner (translate) where +import ChuSQL.Algebra.Expr (aggregatesIn) import ChuSQL.Algebra.Op import ChuSQL.Syntax.AST @@ -7,29 +8,80 @@ import ChuSQL.Syntax.AST -- | 把 FROM 子句翻成 Scan / Join fromToRelOp :: FromClause -> RelOp -fromToRelOp (FromTable mAlias tbl) = Scan mAlias tbl -fromToRelOp (FromJoin left mAlias tbl cond) = - Join (fromToRelOp left) (Scan mAlias tbl) cond +fromToRelOp FromUnit = Unit +fromToRelOp (FromTable mAlias tbl) = Scan mAlias tbl Nothing +fromToRelOp (FromJoin kind left mAlias tbl cond) = + Join kind (fromToRelOp left) (Scan mAlias tbl Nothing) cond --- | 翻成:过滤 -> 排序 -> 截断 -> 取列 +-- | 带上 WHERE 的来源 +source :: FromClause -> Maybe Expr -> RelOp +source fromC mWhere = case mWhere of + Nothing -> fromToRelOp fromC + Just w -> Filter w (fromToRelOp fromC) + +-- | 给用到的聚合起内部列名,同一个聚合只算一次 +nameAggs :: [(String, Expr)] -> [(String, Expr)] +nameAggs items = go (1 :: Int) [] (concatMap (aggregatesIn . snd) items) + where + go :: Int -> [(String, Expr)] -> [Expr] -> [(String, Expr)] + go _ acc [] = reverse acc + go n acc (e : es) + | any ((== e) . snd) acc = go n acc es + | otherwise = go (n + 1) (("agg" ++ show n, e) : acc) es + +-- | 把聚合调用换成对聚合结果的引用 +rewriteAggs :: [(String, Expr)] -> Expr -> Expr +rewriteAggs aggs e = case [label | (label, agg) <- aggs, agg == e] of + (label : _) -> Col label + [] -> case e of + Add a b -> Add (go a) (go b) + Sub a b -> Sub (go a) (go b) + Mul a b -> Mul (go a) (go b) + Div a b -> Div (go a) (go b) + Neg a -> Neg (go a) + Gt a b -> Gt (go a) (go b) + Lt a b -> Lt (go a) (go b) + Eq a b -> Eq (go a) (go b) + And a b -> And (go a) (go b) + Or a b -> Or (go a) (go b) + IsNull a -> IsNull (go a) + IsNotNull a -> IsNotNull (go a) + other -> other + where + go = rewriteAggs aggs + +-- | 过滤 + 分组:没有分组也没有聚合时原样返回 +grouped :: [String] -> [(String, Expr)] -> RelOp -> (RelOp, [(String, Expr)]) +grouped keys items base + | null keys && null aggs = (base, items) + | otherwise = (Aggregate keys aggs base, [(label, rewriteAggs aggs e) | (label, e) <- items]) + where + aggs = nameAggs items + +-- | 排序 + 截断 +limited :: [(String, SortDir)] -> Maybe Int -> RelOp -> RelOp +limited orderBy mLimit op = case mLimit of + Nothing -> sorted + Just n -> Limit n sorted + where + sorted = case orderBy of + [] -> op + spec -> Sort spec op + +-- | 翻成:过滤 -> 分组 -> 排序 -> 截断 -> 取列 translate :: Statement -> Either String RelOp translate q = case q of Select { selectCols = cols , selectFrom = fromC , selectWhere = mWhere + , selectGroupBy = groupBy , selectOrderBy = orderBy , selectLimit = mLimit } -> do - let base = fromToRelOp fromC - filtered = case mWhere of - Nothing -> base - Just w -> Filter w base - sorted = case orderBy of - [] -> filtered - spec -> Sort spec filtered - limited = case mLimit of - Nothing -> sorted - Just n -> Limit n sorted - Right (Project cols limited) + let (plan, _) = grouped groupBy [] (source fromC mWhere) + Right (Project cols (limited orderBy mLimit plan)) + SelectExpr items fromC mWhere groupBy orderBy mLimit -> do + let (plan, items') = grouped groupBy items (source fromC mWhere) + Right (Compute items' (limited orderBy mLimit plan)) _ -> Left "only SELECT is supported by translate" diff --git a/chusql-engine/src/ChuSQL/Algebra/Sort.hs b/chusql-engine/src/ChuSQL/Algebra/Sort.hs index 5e64cd7..af57849 100644 --- a/chusql-engine/src/ChuSQL/Algebra/Sort.hs +++ b/chusql-engine/src/ChuSQL/Algebra/Sort.hs @@ -1,4 +1,4 @@ -module ChuSQL.Algebra.Sort (sortRows) where +module ChuSQL.Algebra.Sort (sortRows, compareValue) where import ChuSQL.Model import ChuSQL.Syntax.AST @@ -12,9 +12,15 @@ flipOrdering LT = GT flipOrdering GT = LT flipOrdering EQ = EQ --- | 比较两个值 +-- | 比较两个值:NULL 最小,数值跨整数与小数比较 compareValue :: Value -> Value -> Ordering +compareValue VNull VNull = EQ +compareValue VNull _ = LT +compareValue _ VNull = GT compareValue (VInt a) (VInt b) = compare a b +compareValue (VFloat a) (VFloat b) = compare a b +compareValue (VInt a) (VFloat b) = compare (fromIntegral a) b +compareValue (VFloat a) (VInt b) = compare a (fromIntegral b) compareValue (VStr a) (VStr b) = compare a b compareValue (VBool a) (VBool b) = compare a b compareValue _ _ = EQ diff --git a/chusql-engine/src/ChuSQL/Engine.hs b/chusql-engine/src/ChuSQL/Engine.hs index a57b594..c37af82 100644 --- a/chusql-engine/src/ChuSQL/Engine.hs +++ b/chusql-engine/src/ChuSQL/Engine.hs @@ -1,14 +1,16 @@ module ChuSQL.Engine (runStatement, runStatementM, rowsOf) where -import ChuSQL.Algebra.Eval (evalRelOpM) +import ChuSQL.Algebra.Eval (evalCondForRowM, evalExprM, evalRelOpM) import ChuSQL.Algebra.Expr (evalCondForRow, evalExpr) import ChuSQL.Algebra.Optimize (optimize) import ChuSQL.Algebra.Planner (translate) import ChuSQL.Model -import ChuSQL.Semantic (check) +import ChuSQL.Semantic (prepare) import ChuSQL.Storage (MonadStorage (..)) import ChuSQL.Storage.Memory (MemoryStorage (runMemoryStorage)) import ChuSQL.Syntax.AST +import ChuSQL.Syntax.Parser (parseExpression) +import Data.Maybe (mapMaybe) -- 引擎入口:语义检查 + 分发执行,以及内存实现与泛型版本。 @@ -25,71 +27,71 @@ rowsOf = fmap snd -- | 泛型入口:先检查再执行 runStatementM :: (MonadStorage m) => Statement -> m (Either String [Row]) +runStatementM (CreateDatabase name) = fmap (fmap (const [])) (createDatabase name) +runStatementM (DropDatabase name) = fmap (fmap (const [])) (dropDatabase name) +runStatementM (UseDatabase name) = fmap (fmap (const [])) (useDatabase name) +runStatementM ShowDatabases = fmap (fmap (map (\name -> [("database", VStr name)]))) listDatabases runStatementM q = do db <- schema - case check db q of + case prepare db q of Left err -> pure (Left err) - Right _ -> runStatementUncheckedM db q + Right resolved -> runStatementUncheckedM db resolved -- | 按语句类型分发(已检查过) runStatementUncheckedM :: (MonadStorage m) => Database -> Statement -> m (Either String [Row]) runStatementUncheckedM db q@Select{} = case translate q of Left e -> pure (Left e) - Right relOp -> evalRelOpM (optimize db relOp) -runStatementUncheckedM _ (Insert tbl cols rows) = - case mapM toRow rows of - Left err -> pure (Left err) - Right rs -> do - result <- insertMany tbl rs - pure (result >> Right []) - where - -- \| 一行字面量求值成一行数据 - toRow :: [Expr] -> Either String Row - toRow vals = do - values <- mapM (\e -> evalExpr e []) vals - if length cols /= length values - then Left "column count does not match value count" - else Right (zip cols values) -runStatementUncheckedM _ (Delete tbl mWhere) = do - rowsResult <- scan tbl - case rowsResult of - Left err -> pure (Left err) - Right rows -> case splitByCondition mWhere rows of - Left err -> pure (Left err) - Right (doomed, kept) -> - case mapM rowId doomed of - Right ids -> do - result <- deleteKeys tbl ids - pure (result >> Right []) - Left _ -> do - result <- replaceAll tbl kept - pure (result >> Right []) - where - -- \| 这一行的 id(要拿来当删除键) - rowId :: Row -> Either String Int - rowId r = case lookup "id" r of - Just (VInt k) -> Right k - _ -> Left "row has no integer id" -runStatementUncheckedM _ (Update tbl assigns mWhere) = do + Right relOp -> evalRelOpM db (optimize db relOp) +runStatementUncheckedM db q@SelectExpr{} = + case translate q of + Left e -> pure (Left e) + Right relOp -> evalRelOpM db (optimize db relOp) +runStatementUncheckedM db (Insert tbl cols rows) = + case lookupTable db tbl of + Left e -> pure (Left e) + Right table -> do + current <- existingRows tbl table + case current >>= \current' -> buildRows table current' cols rows of + Left err -> pure (Left err) + Right rs -> do + result <- insertMany tbl rs + pure (result >> Right []) +runStatementUncheckedM db (Delete tbl mWhere) = do rowsResult <- scan tbl case rowsResult of Left err -> pure (Left err) Right rows -> do - let newRows = mapM (updateRow mWhere assigns) rows - case newRows of + split <- splitByConditionM db mWhere rows + case split of Left err -> pure (Left err) - Right rs -> do - result <- replaceAll tbl rs - pure (result >> Right []) + Right (doomed, kept) -> + case mapM rowId doomed of + Right ids -> do + result <- deleteKeys tbl ids + pure (result >> Right []) + Left _ -> do + result <- replaceAll tbl kept + pure (result >> Right []) where - -- \| 命中条件就套用赋值 - updateRow :: Maybe Expr -> [(String, Expr)] -> Row -> Either String Row - updateRow cond asgns row = do - keep <- case cond of - Nothing -> Right True - Just e -> evalCondForRow e row - if keep then applyUpdates asgns row else Right row + rowId :: Row -> Either String Int + rowId r = case lookup "id" r of + Just (VInt k) -> Right k + _ -> Left "row has no integer id" +runStatementUncheckedM db (Update tbl assigns mWhere) = + case lookupTable db tbl of + Left e -> pure (Left e) + Right table -> do + rowsResult <- scan tbl + case rowsResult of + Left err -> pure (Left err) + Right rows -> do + newRows <- mapM (updateRowM db table mWhere assigns) rows + case sequence newRows of + Left err -> pure (Left err) + Right rs -> do + result <- replaceAll tbl rs + pure (result >> Right []) runStatementUncheckedM _ (CreateTable name cols) = do result <- createTable name cols pure (result >> Right []) @@ -105,23 +107,251 @@ runStatementUncheckedM _ (DropIndex tbl col) = do runStatementUncheckedM _ (DropColumn tbl col) = do result <- dropColumn tbl col pure (result >> Right []) +runStatementUncheckedM db (AddColumn tbl def) = + alterTable db tbl (\table -> addColumnTo def (tableCols table) (tableRows table)) +runStatementUncheckedM db (RenameColumn tbl old new) = + alterTable db tbl (\table -> renameColumnIn old new (tableCols table) (tableRows table)) +runStatementUncheckedM db (AlterColumnType tbl col ty) = + alterTable db tbl (\table -> retypeColumn col ty (tableCols table) (tableRows table)) +runStatementUncheckedM db (AlterColumnDefault tbl col def) = + alterTable db tbl (\table -> setColumnDefault col def (tableCols table) (tableRows table)) +runStatementUncheckedM db (AlterColumnNull tbl col nullable) = + alterTable db tbl (\table -> setColumnNullable col nullable (tableCols table) (tableRows table)) +runStatementUncheckedM _ CreateUser{} = pure (Left "CREATE USER is executed by the account service") +runStatementUncheckedM _ AlterUser{} = pure (Left "ALTER USER is executed by the account service") +runStatementUncheckedM _ DropUser{} = pure (Left "DROP USER is executed by the account service") +runStatementUncheckedM _ CreateRole{} = pure (Left "CREATE ROLE is executed by the privilege service") +runStatementUncheckedM _ DropRole{} = pure (Left "DROP ROLE is executed by the privilege service") +runStatementUncheckedM _ GrantPrivileges{} = pure (Left "GRANT is executed by the privilege service") +runStatementUncheckedM _ RevokePrivileges{} = pure (Left "REVOKE is executed by the privilege service") +runStatementUncheckedM _ GrantRole{} = pure (Left "GRANT is executed by the privilege service") +runStatementUncheckedM _ RevokeRole{} = pure (Left "REVOKE is executed by the privilege service") +runStatementUncheckedM _ q@CreateDatabase{} = runStatementM q +runStatementUncheckedM _ q@DropDatabase{} = runStatementM q +runStatementUncheckedM _ q@UseDatabase{} = runStatementM q +runStatementUncheckedM _ ShowDatabases = runStatementM ShowDatabases --- | 按条件把行分成待删和保留两拨 -splitByCondition :: Maybe Expr -> [Row] -> Either String ([Row], [Row]) -splitByCondition cond = go [] [] +-- | 只有需要读全表才能判断约束时才扫表 +existingRows :: (MonadStorage m) => String -> Table -> m (Either String [Row]) +existingRows tbl table + | needsRows table = scan tbl + | otherwise = pure (Right []) where - -- \| 逐行过,保持原顺序 - go doomed kept [] = Right (reverse doomed, reverse kept) + needsRows t = any (constraintColumn . snd) (tableCols t) + constraintColumn c = columnAutoIncrement c || columnUnique c || columnPrimaryKey c + +-- | 插一行:补齐缺列、给自增值、收类型、查约束 +buildRows :: Table -> [Row] -> [String] -> [[Expr]] -> Either String [Row] +buildRows table current cols rows = do + values <- mapM (mapM (\e -> evalExpr e [])) rows + if any (\vals -> length vals /= length cols) values + then Left "column count does not match value count" + else do + let auto = autoColumn table + go auto (autoStart auto current) values [] + where + go _ _ [] acc = do + let built = reverse acc + mapM_ (runChecks table) built + enforceNotNull (tableCols table) built + enforceUnique (tableCols table) (current ++ built) + Right built + go auto counter (vals : rest) acc = do + (row, counter') <- buildRow table auto cols counter vals + go auto counter' rest (row : acc) + +-- | 按列定义补出一行 +buildRow :: Table -> Maybe (String, Column) -> [String] -> Int -> [Value] -> Either String (Row, Int) +buildRow table auto cols counter vals = go (tableCols table) counter [] + where + provided = zip cols vals + go [] c acc = Right (reverse acc, c) + go ((name, col) : rest) c acc = do + (v, c') <- pick name col c + v' <- coerceValue (columnType col) v + go rest c' ((name, v') : acc) + pick name col c + | Just (autoName, _) <- auto + , name == autoName + , maybe True (== VNull) (lookup name provided) = Right (VInt c, c + 1) + | Just v <- lookup name provided = Right (v, bump name col c v) + | Just d <- columnDefault col = Right (d, c) + | columnNullable col = Right (VNull, c) + | otherwise = Left ("INSERT: column " ++ name ++ " is NOT NULL and has no default") + bump name _ c v + | Just (autoName, _) <- auto + , name == autoName = max (c + 1) (valueInt v + 1) + | otherwise = c + +valueInt :: Value -> Int +valueInt (VInt n) = n +valueInt _ = 0 + +-- | 自增列(至多一个) +autoColumn :: Table -> Maybe (String, Column) +autoColumn table = case [(n, c) | (n, c) <- tableCols table, columnAutoIncrement c] of + (entry : _) -> Just entry + [] -> Nothing + +-- | 下一个自增值 +autoStart :: Maybe (String, Column) -> [Row] -> Int +autoStart Nothing _ = 1 +autoStart (Just (name, _)) rows = case [n | r <- rows, Just (VInt n) <- [lookup name r]] of + [] -> 1 + ns -> maximum ns + 1 + +-- | 执行 CHECK 约束 +runChecks :: Table -> Row -> Either String () +runChecks table row = mapM_ check (tableCols table) + where + check (name, col) = case columnCheck col of + Nothing -> Right () + Just text -> case parseExpression text of + Left err -> Left ("CHECK on " ++ name ++ ": " ++ firstLine err) + Right e -> case evalCondForRow e row of + Left err -> Left ("CHECK on " ++ name ++ ": " ++ err) + Right True -> Right () + Right False -> Left ("CHECK on " ++ name ++ " failed") + +firstLine :: String -> String +firstLine = takeWhile (/= '\n') + +-- | NOT NULL 检查 +enforceNotNull :: [(String, Column)] -> [Row] -> Either String () +enforceNotNull cols rows = mapM_ check cols + where + check (name, col) + | columnNullable col = Right () + | any (\r -> lookup name r == Just VNull) rows = Left ("NOT NULL: column " ++ name ++ " cannot be null") + | otherwise = Right () + +-- | 唯一性检查(NULL 之间不算冲突) +enforceUnique :: [(String, Column)] -> [Row] -> Either String () +enforceUnique cols rows = mapM_ check targets + where + targets = [(n, c) | (n, c) <- cols, columnUnique c || columnPrimaryKey c] + check (name, _) = go name [] (mapMaybe (lookup name) rows) + go _ _ [] = Right () + go name seen (VNull : rest) = go name seen rest + go name seen (v : rest) + | v `elem` seen = Left ("UNIQUE: duplicate value in column " ++ name) + | otherwise = go name (v : seen) rest + +-- | UPDATE 的一行(条件与赋值都可能带子查询) +updateRowM :: (MonadStorage m) => Database -> Table -> Maybe Expr -> [(String, Expr)] -> Row -> m (Either String Row) +updateRowM db table cond asgns row = do + keep <- case cond of + Nothing -> pure (Right True) + Just e -> evalCondForRowM db e row + case keep of + Left err -> pure (Left err) + Right False -> pure (Right row) + Right True -> do + row' <- applyUpdatesM db table asgns row + pure (row' >>= \r -> runChecks table r >> Right r) + +-- | 依次求值、收类型并覆盖列(表达式可以带子查询) +applyUpdatesM :: (MonadStorage m) => Database -> Table -> [(String, Expr)] -> Row -> m (Either String Row) +applyUpdatesM _ _ [] row = pure (Right row) +applyUpdatesM db table ((col, e) : rest) row = do + v <- evalExprM db e row + case v of + Left err -> pure (Left err) + Right val -> case colType table col of + Just def -> case coerceValue (columnType def) val of + Left err -> pure (Left err) + Right v' -> applyUpdatesM db table rest (setColumn col v' row) + Nothing -> applyUpdatesM db table rest (setColumn col val row) + +-- | 覆盖一列 +setColumn :: String -> Value -> Row -> Row +setColumn col v = map (\(k, val) -> if k == col then (k, v) else (k, val)) + +-- | 按条件把行分成待删和保留两拨(条件可以带子查询) +splitByConditionM :: (MonadStorage m) => Database -> Maybe Expr -> [Row] -> m (Either String ([Row], [Row])) +splitByConditionM db cond = go [] [] + where + go doomed kept [] = pure (Right (reverse doomed, reverse kept)) go doomed kept (r : rs) = do hit <- case cond of - Nothing -> Right True - Just e -> evalCondForRow e r - go (if hit then r : doomed else doomed) (if hit then kept else r : kept) rs + Nothing -> pure (Right True) + Just e -> evalCondForRowM db e r + case hit of + Left err -> pure (Left err) + Right h -> go (if h then r : doomed else doomed) (if h then kept else r : kept) rs --- | 依次求值并覆盖列 -applyUpdates :: [(String, Expr)] -> Row -> Either String Row -applyUpdates [] row = Right row -applyUpdates ((col, e) : rest) row = do - v <- evalExpr e row - let row' = map (\(k, val) -> if k == col then (k, v) else (k, val)) row - applyUpdates rest row' +-- | 表结构变更的统一入口:扫全表,改完整体回写 +alterTable :: + (MonadStorage m) => + Database -> + String -> + (Table -> Either String ([(String, Column)], [Row])) -> + m (Either String [Row]) +alterTable db tbl change = case lookupTable db tbl of + Left e -> pure (Left e) + Right table -> do + rowsResult <- scan tbl + case rowsResult of + Left e -> pure (Left e) + Right rows -> case change table{tableRows = rows} >>= checkAltered of + Left e -> pure (Left e) + Right (cols, newRows) -> do + result <- replaceSchema tbl cols newRows + pure (result >> Right []) + where + checkAltered (cols, rows) = do + let table = Table tbl cols rows + mapM_ (runChecks table) rows + enforceNotNull cols rows + enforceUnique cols rows + Right (cols, rows) + +-- | ADD COLUMN:老行补默认值或 NULL,自增列从 1 开始 +addColumnTo :: (String, Column) -> [(String, Column)] -> [Row] -> Either String ([(String, Column)], [Row]) +addColumnTo (name, col) cols rows + | name `elem` map fst cols = Left ("ADD COLUMN: column already exists: " ++ name) + | otherwise = do + values <- fill + Right (cols ++ [(name, col)], [r ++ [(name, v)] | (r, v) <- zip rows values]) + where + fill + | columnAutoIncrement col = mapM (coerceValue (columnType col) . VInt) [1 .. length rows] + | Just d <- columnDefault col = mapM (\_ -> coerceValue (columnType col) d) rows + | columnNullable col = mapM (\_ -> coerceValue (columnType col) VNull) rows + | null rows = Right [] + | otherwise = Left ("ADD COLUMN: column " ++ name ++ " is NOT NULL and has no default") + +-- | RENAME COLUMN:列定义与每一行的键一起改 +renameColumnIn :: String -> String -> [(String, Column)] -> [Row] -> Either String ([(String, Column)], [Row]) +renameColumnIn old new cols rows + | old `notElem` map fst cols = Left ("RENAME COLUMN: unknown column: " ++ old) + | new `elem` map fst cols = Left ("RENAME COLUMN: column already exists: " ++ new) + | otherwise = Right (renameKeys cols, map renameKeys rows) + where + renameKeys = map (\(k, v) -> if k == old then (new, v) else (k, v)) + +-- | ALTER COLUMN TYPE:老值收进新类型 +retypeColumn :: String -> ColumnType -> [(String, Column)] -> [Row] -> Either String ([(String, Column)], [Row]) +retypeColumn col ty cols rows + | col `notElem` map fst cols = Left ("ALTER COLUMN: unknown column: " ++ col) + | otherwise = do + rows' <- mapM convert rows + Right (map (\entry@(n, c) -> if n == col then (n, c{columnType = ty}) else entry) cols, rows') + where + convert row = case lookup col row of + Nothing -> Right row + Just v -> do + v' <- coerceValue ty v + Right (map (\(k, x) -> if k == col then (k, v') else (k, x)) row) + +-- | ALTER COLUMN SET/DROP DEFAULT +setColumnDefault :: String -> Maybe Value -> [(String, Column)] -> [Row] -> Either String ([(String, Column)], [Row]) +setColumnDefault col def cols rows + | col `notElem` map fst cols = Left ("ALTER COLUMN: unknown column: " ++ col) + | otherwise = Right (map (\entry@(n, c) -> if n == col then (n, c{columnDefault = def}) else entry) cols, rows) + +-- | ALTER COLUMN SET/DROP NOT NULL +setColumnNullable :: String -> Bool -> [(String, Column)] -> [Row] -> Either String ([(String, Column)], [Row]) +setColumnNullable col nullable cols rows + | col `notElem` map fst cols = Left ("ALTER COLUMN: unknown column: " ++ col) + | otherwise = Right (map (\entry@(n, c) -> if n == col then (n, c{columnNullable = nullable}) else entry) cols, rows) diff --git a/chusql-engine/src/ChuSQL/Model.hs b/chusql-engine/src/ChuSQL/Model.hs index 65bf61d..0e77940 100644 --- a/chusql-engine/src/ChuSQL/Model.hs +++ b/chusql-engine/src/ChuSQL/Model.hs @@ -1,27 +1,354 @@ -module ChuSQL.Model (Column (..), Value (..), Row, Table (..), Database, colNames, colType, lookupTable, allColumns, qualify, unqualify) where +{-# LANGUAGE PatternSynonyms #-} +module ChuSQL.Model + ( ColumnType (..) + , Column (..) + , pattern TInt + , pattern TStr + , pattern TBool + , TypeClass (..) + , Value (..) + , Row + , Table (..) + , Database + , colNames + , colType + , lookupTable + , allColumns + , qualify + , unqualify + , resolveColumn + , plainColumn + , typeLabel + , typeName + , parseColumnType + , typeClassOf + , assignable + , integerType + , numericType + , valueFits + , coerceValue + ) where + +import Data.Char (isDigit, isSpace, toLower) import Data.Hashable (Hashable (..)) -import Data.List (stripPrefix) +import Data.List (isSuffixOf, stripPrefix) import Data.Maybe (fromMaybe) --- 数据模型:列类型、值、行、表、数据库,外加几个查表小工具。 +-- 数据模型:列类型、列约束、值、行、表、数据库,外加几个查表小工具。 -data Column - = TInt - | TStr - | TBool +-- | 列的基础类型 +data ColumnType + = CInt + | CBigInt + | CSmallInt + | CStr + | CVarchar Int + | CChar Int + | CBool + | CFloat + | CDouble + | CDecimal Int Int + | CDate + | CTimestamp + | CBlob deriving (Show, Eq) +-- | 列定义:类型 + 约束;约束全部取默认值就是普通可空列。 +data Column = Column + { columnType :: ColumnType + , columnNullable :: Bool + , columnDefault :: Maybe Value + , columnAutoIncrement :: Bool + , columnUnique :: Bool + , columnPrimaryKey :: Bool + , columnCheck :: Maybe String + } + deriving (Eq) + +-- | 普通可空列 +plainColumn :: ColumnType -> Column +plainColumn t = Column t True Nothing False False False Nothing + +pattern TInt :: Column +pattern TInt <- Column CInt _ _ _ _ _ _ + where + TInt = plainColumn CInt + +pattern TStr :: Column +pattern TStr <- Column CStr _ _ _ _ _ _ + where + TStr = plainColumn CStr + +pattern TBool :: Column +pattern TBool <- Column CBool _ _ _ _ _ _ + where + TBool = plainColumn CBool + +-- | 错误消息里用的类型名 +typeLabel :: ColumnType -> String +typeLabel CInt = "TInt" +typeLabel CBigInt = "TBigInt" +typeLabel CSmallInt = "TSmallInt" +typeLabel CStr = "TStr" +typeLabel (CVarchar n) = "TVarchar " ++ show n +typeLabel (CChar n) = "TChar " ++ show n +typeLabel CBool = "TBool" +typeLabel CFloat = "TFloat" +typeLabel CDouble = "TDouble" +typeLabel (CDecimal p s) = "TDecimal " ++ show p ++ " " ++ show s +typeLabel CDate = "TDate" +typeLabel CTimestamp = "TTimestamp" +typeLabel CBlob = "TBlob" + +-- | 线上/磁盘上的类型名 +typeName :: ColumnType -> String +typeName CInt = "int" +typeName CBigInt = "bigint" +typeName CSmallInt = "smallint" +typeName CStr = "str" +typeName (CVarchar n) = "varchar(" ++ show n ++ ")" +typeName (CChar n) = "char(" ++ show n ++ ")" +typeName CBool = "bool" +typeName CFloat = "float" +typeName CDouble = "double" +typeName (CDecimal p s) = "decimal(" ++ show p ++ "," ++ show s ++ ")" +typeName CDate = "date" +typeName CTimestamp = "timestamp" +typeName CBlob = "blob" + +-- | 解析类型名,可带长度/精度参数;不认识的给 Nothing +parseColumnType :: String -> Maybe ColumnType +parseColumnType raw = case (base, args) of + ("int", []) -> Just CInt + ("integer", []) -> Just CInt + ("bigint", []) -> Just CBigInt + ("smallint", []) -> Just CSmallInt + ("str", []) -> Just CStr + ("text", []) -> Just CStr + ("varchar", []) -> Just CStr + ("varchar", [n]) -> CVarchar <$> positive n + ("char", []) -> Just (CChar 1) + ("char", [n]) -> CChar <$> positive n + ("bool", []) -> Just CBool + ("boolean", []) -> Just CBool + ("float", []) -> Just CFloat + ("real", []) -> Just CFloat + ("double", []) -> Just CDouble + ("decimal", []) -> Just (CDecimal 10 0) + ("numeric", []) -> Just (CDecimal 10 0) + ("decimal", [p]) -> flip CDecimal 0 <$> positive p + ("numeric", [p]) -> flip CDecimal 0 <$> positive p + ("decimal", [p, s]) -> CDecimal <$> positive p <*> plain s + ("numeric", [p, s]) -> CDecimal <$> positive p <*> plain s + ("date", []) -> Just CDate + ("timestamp", []) -> Just CTimestamp + ("blob", []) -> Just CBlob + _ -> Nothing + where + cleaned = map toLower (filter (not . isSpace) raw) + (base, rest) = span (/= '(') cleaned + args = case stripSuffixMaybe ")" (drop 1 rest) of + Nothing -> [] + Just inner -> map (filter (not . isSpace)) (splitOn ',' inner) + +-- | 去掉结尾 +stripSuffixMaybe :: String -> String -> Maybe String +stripSuffixMaybe suffix s + | suffix `isSuffixOf` s = Just (take (length s - length suffix) s) + | otherwise = Nothing + +-- | 按逗号切开 +splitOn :: Char -> String -> [String] +splitOn sep s = case break (== sep) s of + (part, []) -> [part] + (part, _ : rest) -> part : splitOn sep rest + +-- | 正数参数 +positive :: String -> Maybe Int +positive s + | not (null s) && all isDigit s = Just (read s) + | otherwise = Nothing + +-- | 非负整数参数 +plain :: String -> Maybe Int +plain = positive + +-- | 写出普通列就只说类型名,带约束才补在后面 +instance Show Column where + show c = typeLabel (columnType c) ++ notNull ++ def ++ auto ++ uni ++ key ++ check + where + notNull = if columnNullable c then "" else " NOT NULL" + def = maybe "" (\v -> " DEFAULT " ++ show v) (columnDefault c) + auto = if columnAutoIncrement c then " AUTO_INCREMENT" else "" + uni = if columnUnique c then " UNIQUE" else "" + key = if columnPrimaryKey c then " PRIMARY KEY" else "" + check = maybe "" (\s -> " CHECK (" ++ s ++ ")") (columnCheck c) + +-- | 类型大类 +data TypeClass + = NumericClass + | TextClass + | TemporalClass + | BooleanClass + deriving (Show, Eq) + +typeClassOf :: ColumnType -> TypeClass +typeClassOf CInt = NumericClass +typeClassOf CBigInt = NumericClass +typeClassOf CSmallInt = NumericClass +typeClassOf CFloat = NumericClass +typeClassOf CDouble = NumericClass +typeClassOf CDecimal{} = NumericClass +typeClassOf CStr = TextClass +typeClassOf CVarchar{} = TextClass +typeClassOf CChar{} = TextClass +typeClassOf CBlob = TextClass +typeClassOf CDate = TemporalClass +typeClassOf CTimestamp = TemporalClass +typeClassOf CBool = BooleanClass + +-- | 同大类可赋值;日期/时间列接受字符串字面量 +assignable :: ColumnType -> ColumnType -> Bool +assignable target source + | target == source = True + | typeClassOf target == typeClassOf source = True + | typeClassOf target == TemporalClass && typeClassOf source == TextClass = True + | otherwise = False + +integerType :: ColumnType -> Bool +integerType CInt = True +integerType CBigInt = True +integerType CSmallInt = True +integerType _ = False + +numericType :: ColumnType -> Bool +numericType t = typeClassOf t == NumericClass + +-- | 值能不能放进这一列(先不看可空) +valueFits :: ColumnType -> Value -> Bool +valueFits _ VNull = True +valueFits CInt (VInt _) = True +valueFits CBigInt (VInt _) = True +valueFits CSmallInt (VInt _) = True +valueFits CFloat (VInt _) = True +valueFits CFloat (VFloat _) = True +valueFits CDouble (VInt _) = True +valueFits CDouble (VFloat _) = True +valueFits CDecimal{} (VInt _) = True +valueFits CDecimal{} (VFloat _) = True +valueFits CStr (VStr _) = True +valueFits CVarchar{} (VStr _) = True +valueFits CChar{} (VStr _) = True +valueFits CBool (VBool _) = True +valueFits CDate (VStr _) = True +valueFits CTimestamp (VStr _) = True +valueFits CBlob (VStr _) = True +valueFits _ _ = False + +-- | 把值收进列类型:NULL 原样通过,类型不符报错 +coerceValue :: ColumnType -> Value -> Either String Value +coerceValue _ VNull = Right VNull +coerceValue t v = case t of + CInt -> toInt t v + CBigInt -> toInt t v + CSmallInt -> toInt t v + CFloat -> toFloat t v + CDouble -> toFloat t v + CDecimal p s -> toDecimal p s v + CStr -> toText t v + CVarchar n -> toSized n v + CChar n -> toSized n v + CBool -> toBool t v + CDate -> toDate v + CTimestamp -> toTimestamp v + CBlob -> toText t v + +toInt :: ColumnType -> Value -> Either String Value +toInt _ (VInt n) = Right (VInt n) +toInt t (VFloat d) = case properFraction d :: (Int, Double) of + (n, frac) + | frac == 0 -> Right (VInt n) + | otherwise -> Left ("cannot put a fractional number into " ++ typeLabel t) +toInt t _ = Left ("cannot put this value into " ++ typeLabel t) + +toFloat :: ColumnType -> Value -> Either String Value +toFloat _ (VInt n) = Right (VFloat (fromIntegral n)) +toFloat _ (VFloat d) = Right (VFloat d) +toFloat t _ = Left ("cannot put this value into " ++ typeLabel t) + +-- | DECIMAL 按小数位四舍五入 +toDecimal :: Int -> Int -> Value -> Either String Value +toDecimal p s v = case v of + VInt n -> rounded (fromIntegral n) + VFloat d -> rounded d + _ -> Left ("cannot put this value into " ++ typeLabel (CDecimal p s)) + where + scale = (10 :: Double) ^ max 0 s + rounded d = Right (VFloat (fromIntegral (round (d * scale) :: Integer) / scale)) + +toText :: ColumnType -> Value -> Either String Value +toText _ (VStr s) = Right (VStr s) +toText t _ = Left ("cannot put this value into " ++ typeLabel t) + +toSized :: Int -> Value -> Either String Value +toSized n (VStr s) + | length s <= n = Right (VStr s) + | otherwise = Left ("value is longer than " ++ show n ++ " characters") +toSized _ _ = Left "cannot put this value into a character column" + +toBool :: ColumnType -> Value -> Either String Value +toBool _ (VBool b) = Right (VBool b) +toBool t _ = Left ("cannot put this value into " ++ typeLabel t) + +toDate :: Value -> Either String Value +toDate (VStr s) + | validDateText s = Right (VStr s) + | otherwise = Left "date must look like YYYY-MM-DD" +toDate _ = Left "date must be a string" + +toTimestamp :: Value -> Either String Value +toTimestamp (VStr s) + | validTimestampText s = Right (VStr s) + | otherwise = Left "timestamp must look like YYYY-MM-DD HH:MM:SS" +toTimestamp _ = Left "timestamp must be a string" + +-- | YYYY-MM-DD +validDateText :: String -> Bool +validDateText s = case splitOn '-' s of + [y, m, d] -> allOf 4 y && allOf 2 m && allOf 2 d + _ -> False + +-- | YYYY-MM-DD HH:MM:SS,日期与时间之间允许空格或 T +validTimestampText :: String -> Bool +validTimestampText s = case break (\c -> c == ' ' || c == 'T') s of + (d, _ : tm) -> validDateText d && validTimeText tm + _ -> False + +-- | HH:MM:SS +validTimeText :: String -> Bool +validTimeText t = case splitOn ':' t of + [h, m, sec] -> allOf 2 h && allOf 2 m && allOf 2 sec + _ -> False + +allOf :: Int -> String -> Bool +allOf n s = length s == n && all isDigit s + + data Value - = VInt Int + = VNull + | VInt Int + | VFloat Double | VStr String | VBool Bool deriving (Show, Eq) instance Hashable Value where - hashWithSalt s (VInt n) = hashWithSalt (hashWithSalt s (0 :: Int)) n - hashWithSalt s (VStr t) = hashWithSalt (hashWithSalt s (1 :: Int)) t - hashWithSalt s (VBool b) = hashWithSalt (hashWithSalt s (2 :: Int)) b + hashWithSalt s VNull = hashWithSalt (hashWithSalt s (0 :: Int)) () + hashWithSalt s (VInt n) = hashWithSalt (hashWithSalt s (1 :: Int)) n + hashWithSalt s (VFloat d) = hashWithSalt (hashWithSalt s (2 :: Int)) d + hashWithSalt s (VStr t) = hashWithSalt (hashWithSalt s (3 :: Int)) t + hashWithSalt s (VBool b) = hashWithSalt (hashWithSalt s (4 :: Int)) b type Row = [(String, Value)] @@ -38,7 +365,7 @@ type Database = [(String, Table)] colNames :: Table -> [String] colNames = map fst . tableCols --- | 查某列的类型 +-- | 查某列的定义 colType :: Table -> String -> Maybe Column colType t c = lookup c (tableCols t) @@ -58,3 +385,12 @@ qualify (Just a) c = a ++ "." ++ c unqualify :: Maybe String -> String -> String unqualify Nothing c = c unqualify (Just a) c = fromMaybe c (stripPrefix (a ++ ".") c) + +-- | 裸列仅允许唯一匹配,限定名必须精确匹配 +resolveColumn :: String -> [(String, a)] -> Either String (String, a) +resolveColumn name env = case [(k, v) | (k, v) <- env, matches k] of + [entry] -> Right entry + [] -> Left ("unknown column: " ++ name) + _ -> Left ("ambiguous column: " ++ name) + where + matches k = k == name || ('.' : name) `isSuffixOf` k diff --git a/chusql-engine/src/ChuSQL/Semantic.hs b/chusql-engine/src/ChuSQL/Semantic.hs index 782aefe..e228259 100644 --- a/chusql-engine/src/ChuSQL/Semantic.hs +++ b/chusql-engine/src/ChuSQL/Semantic.hs @@ -1,30 +1,65 @@ -module ChuSQL.Semantic (check) where +module ChuSQL.Semantic (check, prepare) where -import ChuSQL.Algebra.Expr (colsInExpr) +import ChuSQL.Algebra.Expr (bareColumns, colsInExpr, hasAggregate) import ChuSQL.Model import ChuSQL.Syntax.AST -import Data.List (intercalate, nub) +import ChuSQL.Syntax.Parser (parseExpression) +import Data.Char (isAlphaNum, isAscii, toLower) +import Data.List (intercalate, isInfixOf, isPrefixOf, nub) +import Data.Maybe (fromMaybe) -- 语义检查:表和列在不在、类型对不对,全部在执行前查。 +-- | 推导出来的类型:NULL 没有类型,可以放进任何列 +data InferredType + = InferNull + | InferType ColumnType + deriving (Show, Eq) + -- | 给列名加上别名前缀 prefixColumns :: Maybe String -> [(String, Column)] -> [(String, Column)] prefixColumns mAlias cols = [(prefix ++ c, ty) | (c, ty) <- cols] where prefix = maybe "" (++ ".") mAlias --- | 收集 FROM 能提供的列 -checkFrom :: Database -> FromClause -> Either String [(String, Column)] -checkFrom db (FromTable mAlias tbl) = do +-- | 检查期的作用域:本层优先,外层兜底。 +-- 不能把两层平铺成一个列表:同名裸列会出现两次,被误报成 ambiguous。 +data Scope = Scope + { scopeInner :: [(String, Column)] + , scopeOuter :: [(String, Column)] + } + +-- | 本层先查,查不到再落外层;本层同名引起歧义时不往外层让 +scopeAt :: String -> Scope -> String -> Either String (String, Column) +scopeAt place scope c + | null (scopeOuter scope) = columnAt place (scopeInner scope) c + | otherwise = case columnAt place (scopeInner scope) c of + Right entry -> Right entry + Left err + | "ambiguous column:" `isInfixOf` err -> Left err + | otherwise -> columnAt place (scopeOuter scope) c + +-- | 收集 FROM 能提供的列;outer 是外层作用域,给 ON 里的相关子查询用 +checkFrom :: Database -> [(String, Column)] -> FromClause -> Either String [(String, Column)] +checkFrom _ _ FromUnit = Right [] +checkFrom db _ (FromTable mAlias tbl) = do t <- lookupTable db tbl - Right (prefixColumns mAlias (tableCols t)) -checkFrom db (FromJoin left mAlias tbl cond) = do - lcols <- checkFrom db left + Right (prefixColumns (Just (fromMaybe tbl mAlias)) (tableCols t)) +checkFrom db outer (FromJoin _ left mAlias tbl cond) = do + lcols <- checkFrom db outer left t <- lookupTable db tbl - let env = lcols ++ prefixColumns mAlias (tableCols t) - checkBool "ON" env cond + let env = lcols ++ prefixColumns (Just (fromMaybe tbl mAlias)) (tableCols t) + checkBool db "ON" (Scope env outer) cond Right env +-- | 本层作用域优先:外层已有的同名键不再重复堆进来(否则同一个键被数两次,误报 ambiguous) +scopedEnv :: [(String, Column)] -> [(String, Column)] -> [(String, Column)] +scopedEnv env outer = env ++ [o | o <- outer, o `notElem` env] + +-- | 作用域摊平成一层(只用于继续往外传,查找仍走 scopeAt 的本层优先) +scopeFlat :: Scope -> [(String, Column)] +scopeFlat scope = scopedEnv (scopeInner scope) (scopeOuter scope) + -- | 拼“列不存在”的报错 missingColumn :: String -> String -> [(String, Column)] -> String missingColumn place c env = @@ -32,94 +67,287 @@ missingColumn place c env = where hint | null env = "" - | otherwise = " (available: " ++ intercalate ", " (map fst env) ++ ")" + | otherwise = " (available: " ++ intercalate ", " (map (reverse . takeWhile (/= '.') . reverse . fst) env) ++ "; qualified: " ++ intercalate ", " (map fst env) ++ ")" -- | 检查列是否都在环境里 -checkColumns :: String -> [(String, Column)] -> [String] -> Either String () -checkColumns place env requested = - case [c | c <- requested, c /= allColumns, c `notElem` map fst env] of - [] -> Right () - (c : _) -> Left (missingColumn place c env) +checkColumns :: String -> Scope -> [String] -> Either String () +checkColumns place env = mapM_ (\c -> if c == allColumns then Right () else () <$ scopeAt place env c) --- | 推导表达式的类型 -inferExpr :: String -> [(String, Column)] -> Expr -> Either String Column -inferExpr place env (Col c) = maybe (Left (missingColumn place c env)) Right (lookup c env) -inferExpr _ _ (LitInt _) = Right TInt -inferExpr _ _ (LitStr _) = Right TStr -inferExpr _ _ (LitBool _) = Right TBool -inferExpr place env (Gt a b) = operands place env TInt a b -inferExpr place env (Lt a b) = operands place env TInt a b -inferExpr place env (And a b) = operands place env TBool a b -inferExpr place env (Or a b) = operands place env TBool a b -inferExpr place env (Eq a b) = do - ta <- inferExpr place env a - tb <- inferExpr place env b - if ta == tb - then Right TBool - else Left (place ++ ": both sides of = must have the same type, got " ++ show ta ++ " and " ++ show tb) +-- | 保留子句名与可用列提示 +columnAt :: String -> [(String, Column)] -> String -> Either String (String, Column) +columnAt place env c = case resolveColumn c env of + Right entry -> Right entry + Left err | "ambiguous column:" `isPrefixOf` err -> Left (place ++ ": " ++ err) + Left _ -> Left (missingColumn place c env) --- | 检查二元运算两边的类型 -operands :: String -> [(String, Column)] -> Column -> Expr -> Expr -> Either String Column -operands place env want a b = do - ta <- inferExpr place env a - tb <- inferExpr place env b - if ta == want && tb == want - then Right TBool - else Left (place ++ ": operator needs " ++ show want ++ " on both sides, got " ++ show ta ++ " and " ++ show tb) +-- | 聚合不能出现在 WHERE 里 +checkNoAggregate :: String -> Expr -> Either String () +checkNoAggregate place e + | hasAggregate e = Left (place ++ ": aggregate functions are not allowed here") + | otherwise = Right () + +-- | 分组查询里,没被聚合的列必须出现在 GROUP BY 里 +checkGrouping :: Scope -> [String] -> [(String, Expr)] -> Either String () +checkGrouping env keys items + | null keys && not (any (hasAggregate . snd) items) = Right () + | otherwise = do + checkColumns "GROUP BY" env keys + mapM_ okCol (nub (concatMap (bareColumns . snd) items)) + where + okCol c + | c == allColumns = Left "SELECT: * cannot be selected together with GROUP BY" + | c `elem` keys = Right () + | otherwise = Left ("SELECT: column " ++ c ++ " must appear in GROUP BY or be aggregated") + +-- | 渲染一个推导类型 +renderType :: InferredType -> String +renderType InferNull = "NULL" +renderType (InferType t) = typeLabel t + +-- | 推导表达式的类型(子查询也在这里整条查一遍) +inferExpr :: Database -> String -> Scope -> Expr -> Either String InferredType +inferExpr _ place env (Col c) = InferType . columnType . snd <$> scopeAt place env c +inferExpr _ _ _ LitNull = Right InferNull +inferExpr _ _ _ (LitInt _) = Right (InferType CInt) +inferExpr _ _ _ (LitFloat _) = Right (InferType CFloat) +inferExpr _ _ _ (LitStr _) = Right (InferType CStr) +inferExpr _ _ _ (LitBool _) = Right (InferType CBool) +inferExpr _ _ _ (LitDate _) = Right (InferType CDate) +inferExpr _ _ _ (LitTimestamp _) = Right (InferType CTimestamp) +inferExpr _ _ _ (LitBlob _) = Right (InferType CBlob) +inferExpr db place env (Add a b) = arithmetic db place env a b +inferExpr db place env (Sub a b) = arithmetic db place env a b +inferExpr db place env (Mul a b) = arithmetic db place env a b +inferExpr db place env (Div a b) = arithmetic db place env a b +inferExpr db place env (Neg a) = do + t <- inferExpr db place env a + case t of + InferNull -> Right InferNull + InferType x + | numericType x -> Right t + | otherwise -> Left (place ++ ": operator needs TInt on both sides, got TInt and " ++ typeLabel x) +inferExpr db place env (Gt a b) = comparison db place env a b +inferExpr db place env (Lt a b) = comparison db place env a b +inferExpr db place env (Eq a b) = comparison db place env a b +inferExpr db place env (And a b) = boolean db place env a b +inferExpr db place env (Or a b) = boolean db place env a b +inferExpr db place env (IsNull a) = inferExpr db place env a >> Right (InferType CBool) +inferExpr db place env (IsNotNull a) = inferExpr db place env a >> Right (InferType CBool) +inferExpr _ _ _ CountAll = Right (InferType CInt) +inferExpr db place env (CountOf a) = do + _ <- aggregateArg db place env a + Right (InferType CInt) +inferExpr db place env (SumOf a) = aggregateArg db place env a >>= numericResult "SUM" place +inferExpr db place env (AvgOf a) = do + t <- aggregateArg db place env a + case t of + InferNull -> Right InferNull + InferType x + | numericType x -> Right (InferType CFloat) + | otherwise -> Left (place ++ ": AVG needs a number, got " ++ typeLabel x) +inferExpr db place env (MinOf a) = aggregateArg db place env a +inferExpr db place env (MaxOf a) = aggregateArg db place env a +inferExpr db place env (ScalarSub sq) = do + cols <- inferSubqueryOutput db env sq + case cols of + [(_, t)] -> Right t + _ -> Left (place ++ ": scalar subquery must return exactly one column") +inferExpr db place env (InSub a sq _) = do + ta <- inferExpr db place env a + cols <- inferSubqueryOutput db env sq + case cols of + [(_, t)] + | compatible ta t -> Right (InferType CBool) + | otherwise -> Left (place ++ ": IN subquery column has type " ++ renderType t ++ ", expected " ++ renderType ta) + _ -> Left (place ++ ": IN subquery must return exactly one column") +inferExpr db place env (InList a es _) = do + ta <- inferExpr db place env a + ts <- mapM (inferExpr db place env) es + mapM_ (itemType place ta) ts + Right (InferType CBool) +inferExpr _ _ _ (ExistsSub _ _) = Right (InferType CBool) + +-- | IN 列表里的每一项都要跟左边的类型对得上 +itemType :: String -> InferredType -> InferredType -> Either String () +itemType place want got + | compatible want got = Right () + | otherwise = Left (place ++ ": IN list item has type " ++ renderType got ++ ", expected " ++ renderType want) + +-- | 子查询的输出列与类型(外层作用域 outerScope 可做相关引用,内层整条查一遍) +inferSubqueryOutput :: Database -> Scope -> Subquery -> Either String [(String, InferredType)] +inferSubqueryOutput db outerScope sq = case subqueryStatement sq of + q@Select { selectCols = cols } -> do + env <- checkFrom db enclosing (selectFrom q) + checkResolvedWith db enclosing q + mapM (columnTypeOf env) cols + q@SelectExpr { selectItems = items } -> do + env <- checkFrom db enclosing (selectFrom q) + checkResolvedWith db enclosing q + mapM (\(l, e) -> (\t -> (l, t)) <$> inferExpr db "SELECT" (Scope env enclosing) e) items + _ -> Left "subquery must be a SELECT" + where + enclosing = scopeFlat outerScope + columnTypeOf env c = do + (_, col) <- scopeAt "SELECT" (Scope env enclosing) c + Right (c, InferType (columnType col)) + +-- | 聚合的参数:列要在,且不能嵌套聚合 +aggregateArg :: Database -> String -> Scope -> Expr -> Either String InferredType +aggregateArg db place env a + | hasAggregate a = Left (place ++ ": nested aggregates are not allowed") + | otherwise = inferExpr db place env a + +-- | 数值聚合的结果类型:整数进整数出,其它往浮点靠 +numericResult :: String -> String -> InferredType -> Either String InferredType +numericResult fn place t = case t of + InferNull -> Right InferNull + InferType x + | numericType x -> Right (if integerType x then InferType x else InferType CFloat) + | otherwise -> Left (place ++ ": " ++ fn ++ " needs a number, got " ++ typeLabel x) + +-- | 算术结果:有小数就出小数,否则是整数 +arithResult :: InferredType -> InferredType -> InferredType +arithResult InferNull _ = InferNull +arithResult _ InferNull = InferNull +arithResult (InferType a) (InferType b) + | integerType a && integerType b = InferType CInt + | otherwise = InferType CFloat + +-- | 算术两边的类型检查 +arithmetic :: Database -> String -> Scope -> Expr -> Expr -> Either String InferredType +arithmetic db place env a b = do + ta <- inferExpr db place env a + tb <- inferExpr db place env b + let want = if any isFloatSide [ta, tb] then CFloat else CInt + mapM_ (numericOperand place (InferType want) (InferType want)) [ta, tb] + Right (arithResult ta tb) + where + isFloatSide (InferType x) = numericType x && not (integerType x) + isFloatSide _ = False + +-- | 一侧必须是数 +numericOperand :: String -> InferredType -> InferredType -> InferredType -> Either String () +numericOperand place want other t = case t of + InferNull -> Right () + InferType x | numericType x -> Right () + _ -> Left (place ++ ": operator needs " ++ renderType want ++ " on both sides, got " ++ renderType other ++ " and " ++ renderType t) + +-- | 比较两边的类型检查 +comparison :: Database -> String -> Scope -> Expr -> Expr -> Either String InferredType +comparison db place env a b = do + ta <- inferExpr db place env a + tb <- inferExpr db place env b + if compatible ta tb + then Right (InferType CBool) + else Left (place ++ ": both sides of = must have the same type, got " ++ renderType ta ++ " and " ++ renderType tb) + +-- | 两类能不能比 +compatible :: InferredType -> InferredType -> Bool +compatible InferNull _ = True +compatible _ InferNull = True +compatible (InferType a) (InferType b) + | numericType a && numericType b = True + | typeClassOf a == typeClassOf b = True + | typeClassOf a == TemporalClass && typeClassOf b == TextClass = True + | typeClassOf b == TemporalClass && typeClassOf a == TextClass = True + | otherwise = False + +-- | 逻辑运算两边的类型检查 +boolean :: Database -> String -> Scope -> Expr -> Expr -> Either String InferredType +boolean db place env a b = do + ta <- inferExpr db place env a + tb <- inferExpr db place env b + mapM_ (boolOperand place ta) [ta, tb] + Right (InferType CBool) + where + boolOperand _ _ InferNull = Right () + boolOperand _ _ (InferType CBool) = Right () + boolOperand p other t = Left (p ++ ": operator needs TBool on both sides, got " ++ renderType other ++ " and " ++ renderType t) -- | 要求条件能算出布尔 -checkBool :: String -> [(String, Column)] -> Expr -> Either String () -checkBool place env e = do +checkBool :: Database -> String -> Scope -> Expr -> Either String () +checkBool db place env e = do checkColumns place env (colsInExpr e) - t <- inferExpr place env e - if t == TBool - then Right () - else Left (place ++ ": condition must be a boolean, got " ++ show t) + t <- inferExpr db place env e + case t of + InferNull -> Right () + InferType CBool -> Right () + InferType x -> Left (place ++ ": condition must be a boolean, got " ++ typeLabel x) -- | 检查赋值列和类型 -checkTyped :: String -> [(String, Column)] -> Table -> (String, Expr) -> Either String () -checkTyped place env t (c, e) = do +checkTyped :: Database -> String -> Scope -> Table -> (String, Expr) -> Either String () +checkTyped db place env t (c, e) = do want <- maybe (Left (missingColumn place c (tableCols t))) Right (colType t c) - got <- inferExpr place env e - if want == got - then Right () - else Left (place ++ ": column " ++ c ++ " needs " ++ show want ++ ", got " ++ show got) + got <- inferExpr db place env e + case got of + InferNull -> Right () + InferType x + | assignable (columnType want) x -> Right () + | otherwise -> Left (place ++ ": column " ++ c ++ " needs " ++ show want ++ ", got " ++ typeLabel x) -- | 检查插入值的类型 -checkValue :: Table -> (String, Expr) -> Either String () -checkValue = checkTyped "INSERT" [] +checkValue :: Database -> Table -> (String, Expr) -> Either String () +checkValue db = checkTyped db "INSERT" (Scope [] []) -- | 检查 UPDATE 的赋值 -checkAssign :: Table -> (String, Expr) -> Either String () -checkAssign t = checkTyped "UPDATE" (tableCols t) t +checkAssign :: Database -> Table -> (String, Expr) -> Either String () +checkAssign db t = checkTyped db "UPDATE" (Scope (tableCols t) []) t -- | 按语句类型分派检查 check :: Database -> Statement -> Either String () -check db q = case q of +check db q = () <$ prepare db q + +-- | 执行前解析列名并检查类型 +prepare :: Database -> Statement -> Either String Statement +prepare db q = do + resolved <- resolveStatement db q + checkResolved db resolved + pure resolved + +-- | 检查已解析的语句 +checkResolved :: Database -> Statement -> Either String () +checkResolved db = checkResolvedWith db [] + +-- | 检查已解析的语句;outer 是相关子查询能看见的外层列 +checkResolvedWith :: Database -> [(String, Column)] -> Statement -> Either String () +checkResolvedWith db outer q = case q of Select { selectCols = cols , selectFrom = fromC , selectWhere = mWhere + , selectGroupBy = groupBy , selectOrderBy = orderBy } -> do - env <- checkFrom db fromC - checkColumns "SELECT" env cols - checkColumns "ORDER BY" env (map fst orderBy) - mapM_ (checkBool "WHERE" env) mWhere + env <- checkFrom db outer fromC + let full = Scope env outer + checkColumns "SELECT" full cols + checkGrouping full groupBy (map (\c -> (c, Col c)) cols) + checkColumns "ORDER BY" full (map fst orderBy) + mapM_ (checkNoAggregate "WHERE") mWhere + mapM_ (checkBool db "WHERE" full) mWhere + SelectExpr items fromC mWhere groupBy orderBy _ -> do + env <- checkFrom db outer fromC + let full = Scope env outer + mapM_ (\item -> () <$ inferExpr db "SELECT" full (snd item)) items + checkGrouping full groupBy items + checkColumns "ORDER BY" full (map fst orderBy) + mapM_ (checkNoAggregate "WHERE") mWhere + mapM_ (checkBool db "WHERE" full) mWhere Insert tbl cols rows -> do t <- lookupTable db tbl - checkColumns "INSERT" (tableCols t) cols + checkColumns "INSERT" (Scope (tableCols t) []) cols if null rows then Left "INSERT: no values" - else mapM_ (mapM_ (checkValue t) . zip cols) rows + else mapM_ (mapM_ (checkValue db t) . zip cols) rows + checkInsertTargets t cols Delete tbl mWhere -> do t <- lookupTable db tbl - mapM_ (checkBool "WHERE" (tableCols t)) mWhere + mapM_ (checkBool db "WHERE" (Scope (tableCols t) [])) mWhere Update tbl assigns mWhere -> do t <- lookupTable db tbl - checkColumns "UPDATE" (tableCols t) (map fst assigns) - mapM_ (checkAssign t) assigns - mapM_ (checkBool "WHERE" (tableCols t)) mWhere + checkColumns "UPDATE" (Scope (tableCols t) []) (map fst assigns) + mapM_ (checkAssign db t) assigns + mapM_ (checkBool db "WHERE" (Scope (tableCols t) [])) mWhere CreateTable name cols -> do if null name then Left "CREATE TABLE: empty table name" @@ -128,6 +356,7 @@ check db q = case q of if length names /= length (nub names) then Left ("CREATE TABLE: duplicate column names in " ++ name) else Right () + checkColumnDefs (map fst cols) cols DropTable name -> if null name then Left "DROP TABLE: empty table name" @@ -150,9 +379,334 @@ check db q = case q of if col == idColumn then Left "DROP COLUMN: the built-in id column cannot be dropped" else Right () + AddColumn tbl def -> do + t <- lookupTable db tbl + if fst def `elem` tableCols' t + then Left ("ADD COLUMN: column already exists: " ++ fst def) + else Right () + checkColumnDefs (tableCols' t ++ [fst def]) [def] + RenameColumn tbl old new -> do + t <- lookupTable db tbl + if old `notElem` tableCols' t + then Left ("RENAME COLUMN: unknown column: " ++ old) + else Right () + if old == idColumn + then Left "RENAME COLUMN: the built-in id column cannot be renamed" + else + if new `elem` tableCols' t + then Left ("RENAME COLUMN: column already exists: " ++ new) + else Right () + AlterColumnType tbl col ty -> do + t <- lookupTable db tbl + c <- requireColumn "ALTER COLUMN" t col + if columnAutoIncrement c && not (integerType ty) + then Left "ALTER COLUMN: AUTO_INCREMENT column must stay an integer type" + else Right () + AlterColumnDefault tbl col def -> do + t <- lookupTable db tbl + c <- requireColumn "ALTER COLUMN" t col + mapM_ (\v -> checkDefault c v) def + AlterColumnNull tbl col nullable -> do + t <- lookupTable db tbl + c <- requireColumn "ALTER COLUMN" t col + if not nullable && columnAutoIncrement c + then Left "ALTER COLUMN: AUTO_INCREMENT column cannot be nullable" + else Right () + CreateUser name password -> checkAccount name password + AlterUser name password -> checkAccount name password + DropUser name -> checkUserName name + CreateRole name -> checkRoleName name + DropRole name -> checkRoleName name + GrantPrivileges privs obj role -> do + mapM_ checkPrivilege privs + checkGrantObject obj + checkRoleName role + RevokePrivileges privs obj role -> do + mapM_ checkPrivilege privs + checkGrantObject obj + checkRoleName role + GrantRole role members -> do + checkRoleName role + mapM_ checkUserName members + RevokeRole role members -> do + checkRoleName role + mapM_ checkUserName members + CreateDatabase name -> checkDatabaseName name + DropDatabase name -> checkDatabaseName name + UseDatabase name -> checkDatabaseName name + ShowDatabases -> Right () where - -- \| 表的列名清单 tableCols' = map fst . tableCols +-- | 查一列,没有就报错 +requireColumn :: String -> Table -> String -> Either String Column +requireColumn place t c = maybe (Left (place ++ ": unknown column: " ++ c)) Right (colType t c) + +-- | 插入时没提到的列能不能自己补上 +checkInsertTargets :: Table -> [String] -> Either String () +checkInsertTargets t cols = mapM_ checkTarget (tableCols t) + where + checkTarget (name, c) + | name `elem` cols = Right () + | columnAutoIncrement c = Right () + | columnNullable c = Right () + | Just _ <- columnDefault c = Right () + | otherwise = Left ("INSERT: column " ++ name ++ " is NOT NULL and has no default") + +-- | 一列定义是否合法;names 是校验 CHECK 列引用时可用的全部列名 +checkColumnDefs :: [String] -> [(String, Column)] -> Either String () +checkColumnDefs names cols = do + mapM_ (\(_, c) -> mapM_ (checkDefault c) (columnDefault c)) cols + mapM_ (\(_, c) -> checkAutoIncrement c) cols + if length [() | (_, c) <- cols, columnAutoIncrement c] > 1 + then Left "CREATE TABLE: at most one AUTO_INCREMENT column is allowed" + else Right () + checkChecks names cols + +-- | 自增列必须是整数列 +checkAutoIncrement :: Column -> Either String () +checkAutoIncrement c + | columnAutoIncrement c && not (integerType (columnType c)) = Left "AUTO_INCREMENT: column must be an integer type" + | otherwise = Right () + +-- | 默认值必须能放进这一列 +checkDefault :: Column -> Value -> Either String () +checkDefault c v + | VNull <- v = Right () + | valueFits (columnType c) v = Right () + | otherwise = Left ("DEFAULT: value does not fit column type " ++ show c) + +-- | CHECK 里的列必须存在 +checkChecks :: [String] -> [(String, Column)] -> Either String () +checkChecks names = mapM_ checkOne + where + checkOne (_, c) = case columnCheck c of + Nothing -> Right () + Just text -> case parseExpression text of + Left err -> Left ("CHECK: " ++ firstLine err) + Right e -> mapM_ (known e) (colsInExpr e) + known _ col + | col `elem` names = Right () + | otherwise = Left ("CHECK: unknown column: " ++ col) + firstLine = takeWhile (/= '\n') + +-- | 账号名上限 +accountNameLimit :: Int +accountNameLimit = 64 + +checkDatabaseName :: String -> Either String () +checkDatabaseName name + | null name || length name > 64 || not (all (\c -> isAscii c && (isAlphaNum c || c == '_')) name) = Left "invalid database name" + | otherwise = Right () + +-- | 口令上限 +passwordLimit :: Int +passwordLimit = 256 + +-- | 账号名:ASCII 字母数字加点、短横线、下划线 +checkUserName :: String -> Either String () +checkUserName name + | null name = Left "USER: empty account name" + | length name > accountNameLimit = Left "USER: account name is too long" + | not (all allowed name) = Left "USER: account name may only contain ASCII letters, digits, dot, dash or underscore" + | otherwise = Right () + where + allowed c = isAscii c && (isAlphaNum c || c `elem` ("_.-" :: String)) + +-- | 建号 / 改口令:名字合法且口令不空 +checkAccount :: String -> String -> Either String () +checkAccount name password = do + checkUserName name + if null password + then Left "USER: password must not be empty" + else + if length password > passwordLimit + then Left "USER: password is too long" + else Right () + +-- | 角色名:字符规则同账号名,另外不许拿权限关键字当名字(否则 GRANT 读不出来) +checkRoleName :: String -> Either String () +checkRoleName name + | null name = Left "ROLE: empty role name" + | length name > accountNameLimit = Left "ROLE: role name is too long" + | not (all allowed name) = Left "ROLE: role name may only contain ASCII letters, digits, dot, dash or underscore" + | map toLower name `elem` privilegeNames = Left "ROLE: role name must not be a privilege keyword" + | otherwise = Right () + where + allowed c = isAscii c && (isAlphaNum c || c `elem` ("_.-" :: String)) + +-- | 权限关键字(小写,只用于比较) +privilegeNames :: [String] +privilegeNames = ["select", "insert", "update", "delete", "all"] + +-- | 权限名只能是这几个 +checkPrivilege :: String -> Either String () +checkPrivilege name + | map toLower name `elem` privilegeNames = Right () + | otherwise = Left ("GRANT: unknown privilege: " ++ name) + +-- | 授权对象:* 表示全部表,否则是一张表(可带一个库前缀) +checkGrantObject :: String -> Either String () +checkGrantObject "*" = Right () +checkGrantObject obj + | null obj = Left "GRANT: object name must not be empty" + | length obj > 128 = Left "GRANT: object name is too long" + | not (all allowed obj) = Left "GRANT: object name may only contain ASCII letters, digits, dot, dash or underscore" + | otherwise = Right () + where + allowed c = isAscii c && (isAlphaNum c || c `elem` ("_.-" :: String)) + idColumn :: String idColumn = "id" +-- | 把列引用统一为执行期名字(子查询一起解析) +resolveExpr :: + Database -> + [(String, Column)] -> + [(String, Column)] -> + (String -> Either String String) -> + Expr -> + Either String Expr +resolveExpr db env outer f e = case e of + Col c -> Col <$> f c + Add a b -> binary Add a b + Sub a b -> binary Sub a b + Mul a b -> binary Mul a b + Div a b -> binary Div a b + Neg a -> Neg <$> resolveExpr db env outer f a + Gt a b -> binary Gt a b + Lt a b -> binary Lt a b + Eq a b -> binary Eq a b + And a b -> binary And a b + Or a b -> binary Or a b + IsNull a -> IsNull <$> resolveExpr db env outer f a + IsNotNull a -> IsNotNull <$> resolveExpr db env outer f a + CountAll -> Right CountAll + CountOf a -> CountOf <$> resolveExpr db env outer f a + SumOf a -> SumOf <$> resolveExpr db env outer f a + AvgOf a -> AvgOf <$> resolveExpr db env outer f a + MinOf a -> MinOf <$> resolveExpr db env outer f a + MaxOf a -> MaxOf <$> resolveExpr db env outer f a + ScalarSub sq -> ScalarSub <$> resolveSubquery db (env ++ outer) sq + InSub a sq negated -> InSub <$> resolveExpr db env outer f a <*> resolveSubquery db (env ++ outer) sq <*> pure negated + InList a es negated -> InList <$> resolveExpr db env outer f a <*> mapM (resolveExpr db env outer f) es <*> pure negated + ExistsSub sq negated -> ExistsSub <$> resolveSubquery db (env ++ outer) sq <*> pure negated + _ -> Right e + where + binary ctor a b = ctor <$> resolveExpr db env outer f a <*> resolveExpr db env outer f b + +-- | 子查询:内层整条解析一遍,外层列参与解析(相关引用) +-- 解析完把“引用了哪些外层列”记进 subqueryRefs:投影下推要靠它保住这些列 +resolveSubquery :: Database -> [(String, Column)] -> Subquery -> Either String Subquery +resolveSubquery db outer sq = do + stmt <- resolveSubqueryStatement db outer (subqueryStatement sq) + pure (Subquery stmt (outerRefsOf db stmt)) + +-- | 子查询里指向外层作用域的列(内层查不到的列就是外层引用) +outerRefsOf :: Database -> Statement -> [String] +outerRefsOf db stmt = nub [c | c <- statementColumns stmt, not (inInner c)] + where + inner = case checkFrom db [] (statementFrom stmt) of + Right env -> env + Left _ -> [] + inInner c = case resolveColumn c inner of + Right _ -> True + Left _ -> False + +-- | 一条语句里出现的所有列引用(含 JOIN 的 ON 和嵌套子查询的外层引用) +statementColumns :: Statement -> [String] +statementColumns stmt = case stmt of + Select cols fromC mWhere groupBy orderBy _ -> + cols ++ fromColumns fromC ++ concatMap colsInExpr mWhere ++ groupBy ++ map fst orderBy + SelectExpr items fromC mWhere groupBy orderBy _ -> + concatMap (colsInExpr . snd) items ++ fromColumns fromC ++ concatMap colsInExpr mWhere ++ groupBy ++ map fst orderBy + _ -> [] + where + fromColumns FromUnit = [] + fromColumns (FromTable _ _) = [] + fromColumns (FromJoin _ l _ _ c) = fromColumns l ++ colsInExpr c + +statementFrom :: Statement -> FromClause +statementFrom Select { selectFrom = fromC } = fromC +statementFrom SelectExpr { selectFrom = fromC } = fromC +statementFrom _ = FromUnit + +resolveSubqueryStatement :: Database -> [(String, Column)] -> Statement -> Either String Statement +resolveSubqueryStatement db outer q@Select{} = resolveQuery db outer q (map (\c -> (c, Col c)) (selectCols q)) +resolveSubqueryStatement db outer q@SelectExpr{} = resolveQuery db outer q (selectItems q) +resolveSubqueryStatement _ _ _ = Left "subquery must be a SELECT" + +-- | 列名解析入口:查询走连接解析,改数据的语句按本表列解析 +resolveStatement :: Database -> Statement -> Either String Statement +resolveStatement db q = case q of + Select{} -> resolveSelect db q + SelectExpr{} -> resolveSelect db q + Update tbl assigns mWhere -> do + t <- lookupTable db tbl + let env = tableCols t + name = nameIn env "UPDATE" + assigns' <- mapM (\(c, e) -> (\e' -> (c, e')) <$> resolveExpr db env [] name e) assigns + w <- mapM (resolveExpr db env [] (nameIn env "WHERE")) mWhere + pure (Update tbl assigns' w) + Delete tbl mWhere -> do + t <- lookupTable db tbl + w <- mapM (resolveExpr db (tableCols t) [] (nameIn (tableCols t) "WHERE")) mWhere + pure (Delete tbl w) + Insert tbl cols rows -> do + t <- lookupTable db tbl + let env = tableCols t + rows' <- mapM (mapM (resolveExpr db env [] (nameIn env "INSERT"))) rows + pure (Insert tbl cols rows') + other -> Right other + +-- | 查一列的物理名 +nameIn :: [(String, Column)] -> String -> String -> Either String String +nameIn env place c = fst <$> columnAt place env c + +-- | 内层先查,查不到再落到外层;内层同名引起的歧义不往外层让 +scopedColumnAt :: String -> [(String, Column)] -> [(String, Column)] -> String -> Either String (String, Column) +scopedColumnAt place inner outer c = scopeAt place (Scope inner outer) c + +-- | 单表保留原表头,连接使用唯一前缀 +resolveSelect :: Database -> Statement -> Either String Statement +resolveSelect db q@Select{} = resolveQuery db [] q (map (\c -> (c, Col c)) (selectCols q)) +resolveSelect db q@SelectExpr{} = resolveQuery db [] q (selectItems q) +resolveSelect _ q = Right q + +-- | 解析投影、条件、排序与连接引用;outer 是相关子查询能看见的外层列 +resolveQuery :: Database -> [(String, Column)] -> Statement -> [(String, Expr)] -> Either String Statement +resolveQuery db outer q items = do + env <- checkFrom db outer (selectFrom q) + let physical c = case selectFrom q of + FromTable Nothing tbl -> unqualify (Just tbl) c + _ -> c + name place c = physical . fst <$> scopedColumnAt place env outer c + output (_, Col c) = do + k <- name "SELECT" c + pure (k, Col k) + output (label, e) = do + e' <- resolveExpr db env outer (name "SELECT") e + pure (label, e') + projected <- if items == [("*", Col "*")] + then if null env && selectFrom q == FromUnit + then Left "SELECT: * requires FROM" + else Right [(physical c, Col (physical c)) | (c, _) <- env] + else mapM output items + condition <- mapM (resolveExpr db env outer (name "WHERE")) (selectWhere q) + grouping <- mapM (name "GROUP BY") (selectGroupBy q) + ordering <- mapM (\(c, d) -> (\k -> (k, d)) <$> name "ORDER BY" c) (selectOrderBy q) + source <- case selectFrom q of + single@FromTable{} -> Right single + other -> resolveFrom db outer other + pure $ case q of + Select{} -> Select (map fst projected) source condition grouping ordering (selectLimit q) + _ -> SelectExpr projected source condition grouping ordering (selectLimit q) + +-- | 连接两侧默认取表名作别名;ON 里的子查询也能引用外层列 +resolveFrom :: Database -> [(String, Column)] -> FromClause -> Either String FromClause +resolveFrom _ _ FromUnit = Right FromUnit +resolveFrom _ _ (FromTable alias tbl) = Right (FromTable (Just (fromMaybe tbl alias)) tbl) +resolveFrom db outer fromC@(FromJoin kind left alias tbl cond) = do + env <- checkFrom db outer fromC + l <- resolveFrom db outer left + c <- resolveExpr db env outer (fmap fst . columnAt "ON" env) cond + pure (FromJoin kind l (Just (fromMaybe tbl alias)) tbl c) diff --git a/chusql-engine/src/ChuSQL/Storage.hs b/chusql-engine/src/ChuSQL/Storage.hs index d07e8c9..706ad42 100644 --- a/chusql-engine/src/ChuSQL/Storage.hs +++ b/chusql-engine/src/ChuSQL/Storage.hs @@ -8,14 +8,29 @@ import ChuSQL.Model (Column, Database, Row, Table (..), Value (..)) -- 存储层抽象:上层只认 domain 类型,不关心底层实现。 data IndexResult - = IndexRow (Maybe Row) + = IndexRows [Row] | NoIndex deriving (Show, Eq) class (Monad m) => MonadStorage m where + createDatabase :: String -> m (Either String ()) + createDatabase _ = pure (Left "database management is unavailable in this storage") + dropDatabase :: String -> m (Either String ()) + dropDatabase _ = pure (Left "database management is unavailable in this storage") + useDatabase :: String -> m (Either String ()) + useDatabase _ = pure (Left "database selection is unavailable in this storage") + listDatabases :: m (Either String [String]) + listDatabases = pure (Right []) + -- \| 全表扫描 scan :: String -> m (Either String [Row]) + -- \| 只返回指定列;空清单保留每行的存在性。 + scanColumns :: String -> [String] -> m (Either String [Row]) + scanColumns t cols = do + result <- scan t + pure (fmap (map (\row -> [(c, v) | c <- cols, Just v <- [lookup c row]])) result) + -- \| 追加一行 insert :: String -> Row -> m (Either String ()) @@ -23,7 +38,6 @@ class (Monad m) => MonadStorage m where insertMany :: String -> [Row] -> m (Either String ()) insertMany t = go where - -- \| 一行一行来,遇到错就停 go [] = pure (Right ()) go (r : rs) = do x <- insert t r @@ -39,7 +53,6 @@ class (Monad m) => MonadStorage m where Left e -> pure (Left e) Right rs -> replaceAll t [r | r <- rs, not (doomed r)] where - -- \| 这一行的 id 在待删清单里吗 doomed r = case lookup "id" r of Just (VInt k) -> k `elem` ks _ -> False @@ -47,10 +60,19 @@ class (Monad m) => MonadStorage m where -- \| 整表替换 replaceAll :: String -> [Row] -> m (Either String ()) - -- \| 按某一列的索引取一行 - lookupByColumn :: String -> String -> Int -> m (Either String IndexResult) + -- \| 按某一列的索引取一行(键可以是整数或字符串) + lookupByColumn :: String -> String -> Value -> m (Either String IndexResult) lookupByColumn _ _ _ = pure (Right NoIndex) + -- \| 按某一列的索引做范围扫描;没有可用索引就回 Nothing + scanRange :: + String -> + String -> + Maybe (Value, Bool) -> + Maybe (Value, Bool) -> + m (Either String (Maybe [Row])) + scanRange _ _ _ _ = pure (Right Nothing) + -- \| 建一张空表 createTable :: String -> [(String, Column)] -> m (Either String ()) @@ -68,6 +90,9 @@ class (Monad m) => MonadStorage m where -- \| 删一列,连它的索引与数据一起去掉 dropColumn :: String -> String -> m (Either String ()) + -- \| ALTER 用:整表换列定义与全部行 + replaceSchema :: String -> [(String, Column)] -> [Row] -> m (Either String ()) + -- \| 取全库快照(表名 + 列 + 全部行) snapshot :: m Database @@ -75,5 +100,4 @@ class (Monad m) => MonadStorage m where schema :: m Database schema = fmap (map withoutRows) snapshot where - -- \| 抹掉行,只留表名与列定义 withoutRows (name, t) = (name, t {tableRows = []}) diff --git a/chusql-engine/src/ChuSQL/Storage/IPC.hs b/chusql-engine/src/ChuSQL/Storage/IPC.hs index 2ae3bf5..d8f0bf1 100644 --- a/chusql-engine/src/ChuSQL/Storage/IPC.hs +++ b/chusql-engine/src/ChuSQL/Storage/IPC.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE ImportQualifiedPost #-} {-# LANGUAGE OverloadedStrings #-} @@ -5,10 +6,14 @@ module ChuSQL.Storage.IPC ( IPCStorage (..), Request (..), Response (..), + Account (..), SchemaColumn (..), TableInfo (..), sendRequest, closeConnection, + defaultPipeName, + setPipeName, + getPipeName, doListTables, doLookupByColumn, doInsert, @@ -21,8 +26,11 @@ module ChuSQL.Storage.IPC ( import ChuSQL.Model import ChuSQL.Storage -import Control.Concurrent.MVar (MVar, modifyMVar, modifyMVar_, newMVar) +import Control.Concurrent.MVar (MVar, modifyMVar, modifyMVar_, newMVar, readMVar) import Control.Exception (IOException, try) +#if !defined(mingw32_HOST_OS) +import Control.Exception (onException) +#endif import Data.Aeson (FromJSON (..), ToJSON (..), eitherDecodeStrict, encode, object, withObject, (.:), (.:?), (.!=), (.=)) import Data.Aeson qualified as A import Data.Aeson.Key qualified as K @@ -30,13 +38,24 @@ import Data.Aeson.KeyMap qualified as KM import Data.Aeson.Types (Parser) import Data.ByteString.Char8 qualified as BSC import Data.ByteString.Lazy qualified as BL -import Data.Scientific (floatingOrInteger) +import Data.Maybe (fromMaybe) +import Data.Scientific (floatingOrInteger, fromFloatDigits) import Data.Text qualified as T -import System.Environment (lookupEnv) import System.IO import System.IO.Unsafe (unsafePerformIO) +#if !defined(mingw32_HOST_OS) +import Network.Socket (Family (AF_UNIX), SockAddr (SockAddrUnix), SocketType (Stream), close, connect, defaultProtocol, socket, socketToHandle) +import System.Environment (lookupEnv) +#endif -- IPC 存储实现:每个存储方法翻成管道上的一条 JSON 请求。 +-- 端点:Windows 是具名管道 \\.\pipe\,Unix 是文件系统套接字 /.sock。 + +#if !defined(mingw32_HOST_OS) +-- | Unix 套接字路径的最大长度(Linux 108 含结尾 NUL,BSD 104,这里留余量) +maxSocketPathLength :: Int +maxSocketPathLength = 100 +#endif newtype IPCStorage a = IPCStorage { runIPCStorage :: IO a @@ -58,13 +77,37 @@ instance Monad IPCStorage where connectionRef :: MVar (Maybe (FilePath, Handle)) connectionRef = unsafePerformIO (newMVar Nothing) --- | 打开管道 +-- | 打开端点连接:Windows 把管道当文件打开,Unix 连一个 AF_UNIX 套接字 openPipe :: FilePath -> IO Handle +#if defined(mingw32_HOST_OS) openPipe path = do h <- openFile path ReadWriteMode hSetBuffering h LineBuffering hSetNewlineMode h noNewlineTranslation pure h +#else +openPipe path = do + if length path > maxSocketPathLength + then ioError (userError ("socket path too long (" ++ show (length path) ++ " bytes): " ++ path)) + else pure () + sock <- socket AF_UNIX Stream defaultProtocol + connect sock (SockAddrUnix path) `onException` close sock + h <- socketToHandle sock ReadWriteMode + hSetBuffering h LineBuffering + hSetNewlineMode h noNewlineTranslation + pure h + +-- | Unix 套接字目录:$XDG_RUNTIME_DIR → $TMPDIR → /tmp(与 Rust 侧同一规则) +socketDir :: IO String +socketDir = do + runtime <- fmap nonEmpty (lookupEnv "XDG_RUNTIME_DIR") + tmp <- fmap nonEmpty (lookupEnv "TMPDIR") + pure (fromMaybe "/tmp" (runtime <> tmp)) + where + nonEmpty v = case v of + Just s | not (null s) -> Just s + _ -> Nothing +#endif -- | 关掉缓存里的句柄(没有就什么都不做) closeCached :: Maybe (FilePath, Handle) -> IO () @@ -91,11 +134,34 @@ dropConnection = modifyMVar_ connectionRef $ \cached -> do closeConnection :: IO () closeConnection = dropConnection --- | 管道路径 +-- | 没配置管名时用的端点,与 chusql.toml 的 [server] pipe_name 默认值一致 +defaultPipeName :: String +defaultPipeName = "chusql-joint" + +{-# NOINLINE pipeNameRef #-} +pipeNameRef :: MVar String +pipeNameRef = unsafePerformIO (newMVar defaultPipeName) + +-- | 设定本进程要连的端点;配置从哪来由调用方决定(本层不读环境变量) +setPipeName :: String -> IO () +setPipeName name = modifyMVar_ pipeNameRef (\_ -> pure name) + +-- | 当前端点 +getPipeName :: IO String +getPipeName = readMVar pipeNameRef + +-- | 端点的完整路径 pipePath :: IO String +#if defined(mingw32_HOST_OS) pipePath = do - name <- maybe "chusql-storage" id <$> lookupEnv "CHUSQL_PIPE" + name <- getPipeName pure ("\\\\.\\pipe\\" ++ name) +#else +pipePath = do + name <- getPipeName + dir <- socketDir + pure (dir ++ "/" ++ name ++ ".sock") +#endif -- | 发一条请求,管道不通就重连 sendRequest :: Request -> IO Response @@ -124,38 +190,83 @@ roundTrip req path = do data SchemaColumn = SchemaColumn { scName :: String , scType :: String + , scNullable :: Bool + , scDefault :: Maybe Value + , scAutoIncrement :: Bool + , scPrimaryKey :: Bool + , scUnique :: Bool + , scCheck :: Maybe String } deriving (Show, Eq) instance ToJSON SchemaColumn where - toJSON (SchemaColumn n t) = object ["name" .= n, "ty" .= t] + toJSON sc = + object + [ "name" .= scName sc + , "ty" .= scType sc + , "nullable" .= scNullable sc + , "default" .= fmap valueToJSON (scDefault sc) + , "auto_increment" .= scAutoIncrement sc + , "primary_key" .= scPrimaryKey sc + , "unique" .= scUnique sc + , "check" .= scCheck sc + ] instance FromJSON SchemaColumn where parseJSON = withObject "SchemaColumn" $ \o -> do n <- o .: "name" t <- o .: "ty" - pure (SchemaColumn n t) + nullable <- o .:? "nullable" .!= True + defJson <- o .:? "default" + def <- mapM valueFromJSON defJson + auto <- o .:? "auto_increment" .!= False + pk <- o .:? "primary_key" .!= False + uniq <- o .:? "unique" .!= False + chk <- o .:? "check" + pure (SchemaColumn n t nullable def auto pk uniq chk) data Request = ReqPing + | ReqDatabase String String + | ReqAllCatalog + | ReqInDatabase String Request + | ReqAccountsList + | ReqAccountCreate T.Text T.Text + | ReqAccountReset T.Text T.Text + | ReqAccountLogin T.Text (Maybe T.Text) + | ReqAccountDrop T.Text | ReqScan String + | ReqScanColumns String [String] | ReqInsert String Row | ReqInsertBatch String [Row] | ReqDeleteKeys String [Int] | ReqReplaceAll String [Row] | ReqListTables - | ReqLookupByColumn String String Int + | ReqLookupByColumn String String Value + | ReqRangeByIndex String String (Maybe (Value, Bool)) (Maybe (Value, Bool)) | ReqDescribeTable String | ReqCreateTable String [SchemaColumn] | ReqDropTable String | ReqCreateIndex String String | ReqDropIndex String String | ReqDropColumn String String + | ReqReplaceSchema String [SchemaColumn] [Row] | ReqListCatalog instance ToJSON Request where + toJSON (ReqDatabase method name) = object ["method" .= method, "database" .= name] + toJSON ReqAllCatalog = object ["method" .= ("all_catalogs" :: T.Text)] + toJSON (ReqInDatabase name req) = case toJSON req of + A.Object fields -> A.Object (KM.insert "database" (A.String (T.pack name)) fields) + other -> other + toJSON ReqAccountsList = object ["method" .= ("accounts_list" :: T.Text)] + toJSON (ReqAccountCreate u h) = object ["method" .= ("account_create" :: T.Text), "user" .= u, "password_hash" .= h] + toJSON (ReqAccountReset u h) = object ["method" .= ("account_reset" :: T.Text), "user" .= u, "password_hash" .= h] + toJSON (ReqAccountLogin u at) = object ["method" .= ("account_login" :: T.Text), "user" .= u, "at" .= at] + toJSON (ReqAccountDrop u) = object ["method" .= ("account_drop" :: T.Text), "user" .= u] toJSON ReqPing = object ["method" .= ("ping" :: T.Text)] toJSON (ReqScan t) = object ["method" .= ("scan" :: T.Text), "table" .= t] + toJSON (ReqScanColumns t cols) = object ["method" .= ("scan" :: T.Text), "table" .= t, "columns" .= cols] toJSON (ReqInsert t r) = object [ "method" .= ("insert" :: T.Text) @@ -186,7 +297,17 @@ instance ToJSON Request where [ "method" .= ("lookup_by_index" :: T.Text) , "table" .= t , "column" .= c - , "key" .= k + , "key" .= valueToJSON k + ] + toJSON (ReqRangeByIndex t c lo hi) = + object + [ "method" .= ("range_by_index" :: T.Text) + , "table" .= t + , "column" .= c + , "lo" .= fmap (valueToJSON . fst) lo + , "lo_inclusive" .= maybe True snd lo + , "hi" .= fmap (valueToJSON . fst) hi + , "hi_inclusive" .= maybe True snd hi ] toJSON (ReqDescribeTable t) = object @@ -219,6 +340,13 @@ instance ToJSON Request where , "table" .= t , "column" .= c ] + toJSON (ReqReplaceSchema t cols rs) = + object + [ "method" .= ("replace_schema" :: T.Text) + , "table" .= t + , "columns" .= cols + , "rows" .= map rowToJSON rs + ] toJSON ReqListCatalog = object ["method" .= ("list_catalog" :: T.Text)] data TableInfo = TableInfo @@ -230,8 +358,31 @@ data TableInfo = TableInfo } deriving (Show, Eq) +data Account = Account + { accountId :: Integer + , accountUser :: T.Text + , accountHash :: T.Text + , accountRevision :: Integer + , accountRegisteredAt :: T.Text + , accountLastLoginAt :: Maybe T.Text + } deriving (Eq) + +instance Show Account where + show a = "Account " ++ show (accountUser a) ++ " revision=" ++ show (accountRevision a) + +instance FromJSON Account where + parseJSON = withObject "Account" $ \o -> Account <$> o .: "id" <*> o .: "user" + <*> o .: "password_hash" <*> o .: "revision" + <*> o .:? "registered_at" .!= "" <*> o .:? "last_login_at" + +instance ToJSON Account where + toJSON a = object ["id" .= accountId a, "user" .= accountUser a, + "password_hash" .= accountHash a, "revision" .= accountRevision a, + "registered_at" .= accountRegisteredAt a, "last_login_at" .= accountLastLoginAt a] + data Response = RespPong + | RespAccounts [Account] | RespRows [Row] | RespTables [String] | RespSchema TableInfo @@ -244,6 +395,7 @@ instance FromJSON Response where parseJSON = withObject "Response" $ \o -> do status <- o .: "status" case status :: T.Text of + "accounts" -> RespAccounts <$> o .: "accounts" "pong" -> pure RespPong "ok" -> pure RespOk "no_index" -> pure RespNoIndex @@ -256,7 +408,6 @@ instance FromJSON Response where RespCatalog <$> mapM parseTable xs other -> fail ("unknown status: " ++ T.unpack other) where - -- \| 解一张表的 schema parseTable :: A.Value -> Parser TableInfo parseTable = withObject "TableInfo" $ \o -> do t <- o .: "table" @@ -268,7 +419,6 @@ instance FromJSON Response where stats <- mapM parseStat (sts :: [A.Value]) pure (TableInfo t cols cnt idxCols stats) - -- \| 解一条列统计 parseStat :: A.Value -> Parser (String, Int, Bool) parseStat = withObject "ColumnStat" $ \o -> do name <- o .: "name" @@ -278,16 +428,19 @@ instance FromJSON Response where -- | 值编码成 JSON valueToJSON :: Value -> A.Value +valueToJSON VNull = A.Null valueToJSON (VInt n) = A.Number (fromIntegral n) +valueToJSON (VFloat d) = A.Number (fromFloatDigits d) valueToJSON (VStr s) = A.String (T.pack s) valueToJSON (VBool b) = A.Bool b -- | JSON 解回值 valueFromJSON :: A.Value -> Parser Value +valueFromJSON A.Null = pure VNull valueFromJSON (A.Number n) = case floatingOrInteger n :: Either Double Integer of Right i -> pure (VInt (fromIntegral i)) - Left _ -> fail "non-integer number" + Left d -> pure (VFloat d) valueFromJSON (A.String s) = pure (VStr (T.unpack s)) valueFromJSON (A.Bool b) = pure (VBool b) valueFromJSON _ = fail "unsupported value type" @@ -301,7 +454,6 @@ rowToJSON r = rowFromJSON :: A.Value -> Parser Row rowFromJSON (A.Object o) = mapM toPair (KM.toList o) where - -- \| 解一个键值对 toPair (k, v) = do val <- valueFromJSON v pure (K.toString k, val) @@ -356,14 +508,25 @@ doListCatalog = ask "list_catalog" ReqListCatalog $ \resp -> case resp of RespCatalog xs -> Just xs _ -> Nothing --- | 按某一列的索引取一行 -doLookupByColumn :: String -> String -> Int -> IO (Either String IndexResult) +-- | 按某一列的索引取一行(键可以是整数或字符串) +doLookupByColumn :: String -> String -> Value -> IO (Either String IndexResult) doLookupByColumn t c k = ask "lookup_by_index" (ReqLookupByColumn t c k) $ \resp -> case resp of - RespRows [] -> Just (IndexRow Nothing) - RespRows (r : _) -> Just (IndexRow (Just r)) + RespRows rows -> Just (IndexRows rows) RespNoIndex -> Just NoIndex _ -> Nothing +-- | 范围扫描:没有可用索引就回 Nothing,让上层退回扫描 +doScanRange :: + String -> + String -> + Maybe (Value, Bool) -> + Maybe (Value, Bool) -> + IO (Either String (Maybe [Row])) +doScanRange t c lo hi = ask "range_by_index" (ReqRangeByIndex t c lo hi) $ \resp -> case resp of + RespRows rows -> Just (Just rows) + RespNoIndex -> Just Nothing + _ -> Nothing + -- | 发 DescribeTable doDescribeTable :: String -> IO (Either String TableInfo) doDescribeTable t = ask "describe_table" (ReqDescribeTable t) $ \resp -> case resp of @@ -382,6 +545,10 @@ doDropIndex t c = ask "drop_index" (ReqDropIndex t c) okOnly doDropColumn :: String -> String -> IO (Either String ()) doDropColumn t c = ask "drop_column" (ReqDropColumn t c) okOnly +-- | 发 ReplaceSchema(ALTER 用) +doReplaceSchema :: String -> [SchemaColumn] -> [Row] -> IO (Either String ()) +doReplaceSchema t cols rs = ask "replace_schema" (ReqReplaceSchema t cols rs) okOnly + -- | 发 CreateTable doCreateTable :: String -> [SchemaColumn] -> IO (Either String ()) doCreateTable t cols = ask "create_table" (ReqCreateTable t cols) okOnly @@ -392,16 +559,48 @@ doDropTable t = ask "drop_table" (ReqDropTable t) okOnly -- | 线上 schema 转本地列 schemaToColumns :: [SchemaColumn] -> [(String, Column)] -schemaToColumns = map go - where - go (SchemaColumn n "int") = (n, TInt) - go (SchemaColumn n "str") = (n, TStr) - go (SchemaColumn n "bool") = (n, TBool) - go (SchemaColumn n _) = (n, TStr) +schemaToColumns = map toColumn + +toColumn :: SchemaColumn -> (String, Column) +toColumn sc = + ( scName sc + , Column + { columnType = fromMaybe CStr (parseColumnType (scType sc)) + , columnNullable = scNullable sc + , columnDefault = scDefault sc + , columnAutoIncrement = scAutoIncrement sc + , columnUnique = scUnique sc + , columnPrimaryKey = scPrimaryKey sc + , columnCheck = scCheck sc + } + ) + +-- | 本地列转线上 schema +toWire :: (String, Column) -> SchemaColumn +toWire (n, c) = + SchemaColumn + { scName = n + , scType = typeName (columnType c) + , scNullable = columnNullable c + , scDefault = columnDefault c + , scAutoIncrement = columnAutoIncrement c + , scPrimaryKey = columnPrimaryKey c + , scUnique = columnUnique c + , scCheck = columnCheck c + } instance MonadStorage IPCStorage where + createDatabase name = IPCStorage (ask "create_database" (ReqDatabase "create_database" name) okOnly) + dropDatabase name = IPCStorage (ask "drop_database" (ReqDatabase "drop_database" name) okOnly) + useDatabase name = IPCStorage (ask "use_database" (ReqDatabase "use_database" name) okOnly) + listDatabases = IPCStorage $ ask "list_databases" (ReqDatabase "list_databases" "") $ \resp -> case resp of + RespTables names -> Just names + _ -> Nothing -- \| 发 Scan scan t = IPCStorage (doScan t) + scanColumns t cols = IPCStorage $ ask "scan" (ReqScanColumns t cols) $ \resp -> case resp of + RespRows rows -> Just [[(c, v) | c <- cols, Just v <- [lookup c row]] | row <- rows] + _ -> Nothing -- \| 发 Insert(索引由存储层自己维护) insert t r = IPCStorage (doInsert t r) @@ -418,13 +617,14 @@ instance MonadStorage IPCStorage where -- \| 按某一列的索引取一行(没有索引就回 NoIndex) lookupByColumn t c k = IPCStorage (doLookupByColumn t c k) + -- \| 范围扫描(没有索引就回 Nothing) + scanRange t c lo hi = IPCStorage (doScanRange t c lo hi) + -- \| 发 CreateTable createTable name cols = IPCStorage (doCreateTable name (map toWire cols)) - where - -- \| 本地列类型转线上字符串 - toWire (n, TInt) = SchemaColumn n "int" - toWire (n, TStr) = SchemaColumn n "str" - toWire (n, TBool) = SchemaColumn n "bool" + + -- \| ALTER:整表换列定义与全部行 + replaceSchema name cols rows = IPCStorage (doReplaceSchema name (map toWire cols) rows) -- \| 发 DropTable dropTable name = IPCStorage (doDropTable name) @@ -445,7 +645,6 @@ instance MonadStorage IPCStorage where Left _ -> pure [] Right xs -> mapM loadEntry xs where - -- \| 加载一张表,行来自 scan loadEntry info = do rows <- doScan (tiTable info) pure @@ -455,7 +654,9 @@ instance MonadStorage IPCStorage where -- \| 只问数据字典要结构,一行数据都不拉过来 schema = IPCStorage $ do - result <- doListCatalog + result <- ask "all_catalogs" ReqAllCatalog $ \resp -> case resp of + RespCatalog infos -> Just infos + _ -> Nothing pure $ case result of Left _ -> [] Right xs -> diff --git a/chusql-engine/src/ChuSQL/Storage/Memory.hs b/chusql-engine/src/ChuSQL/Storage/Memory.hs index d83ee39..b5933ed 100644 --- a/chusql-engine/src/ChuSQL/Storage/Memory.hs +++ b/chusql-engine/src/ChuSQL/Storage/Memory.hs @@ -74,6 +74,11 @@ instance MonadStorage MemoryStorage where -- \| 原样返回当前库 snapshot = MemoryStorage $ \db -> Right (db, db) + -- \| 改表结构:列定义与行一起换 + replaceSchema name cols rows = MemoryStorage $ \db -> case lookup name db of + Nothing -> Right (Left ("unknown table: " ++ name), db) + Just tbl -> Right (Right (), replaceTable name tbl{tableCols = cols, tableRows = rows} db) + -- | 用给定表替换同名表 replaceTable :: String -> Table -> Database -> Database replaceTable name table = diff --git a/chusql-engine/src/ChuSQL/Syntax/AST.hs b/chusql-engine/src/ChuSQL/Syntax/AST.hs index 69de683..c5251c3 100644 --- a/chusql-engine/src/ChuSQL/Syntax/AST.hs +++ b/chusql-engine/src/ChuSQL/Syntax/AST.hs @@ -1,6 +1,6 @@ -module ChuSQL.Syntax.AST (Statement (..), FromClause (..), Expr (..), SortDir (..), makeSelect) where +module ChuSQL.Syntax.AST (Statement (..), FromClause (..), JoinKind (..), Expr (..), Subquery (..), SortDir (..), makeSelect) where -import ChuSQL.Model (Column (..)) +import ChuSQL.Model (Column (..), ColumnType, Value (..)) -- 语法树:语句、数据来源、条件表达式、排序方向。 @@ -9,6 +9,15 @@ data Statement { selectCols :: [String] , selectFrom :: FromClause , selectWhere :: Maybe Expr + , selectGroupBy :: [String] + , selectOrderBy :: [(String, SortDir)] + , selectLimit :: Maybe Int + } + | SelectExpr + { selectItems :: [(String, Expr)] + , selectFrom :: FromClause + , selectWhere :: Maybe Expr + , selectGroupBy :: [String] , selectOrderBy :: [(String, SortDir)] , selectLimit :: Maybe Int } @@ -20,23 +29,83 @@ data Statement | CreateIndex String String | DropIndex String String | DropColumn String String + | AddColumn String (String, Column) + | RenameColumn String String String + | AlterColumnType String String ColumnType + | AlterColumnDefault String String (Maybe Value) + | AlterColumnNull String String Bool + | CreateUser String String + | AlterUser String String + | DropUser String + -- | 新角色:权限的载体,用户靠成员资格继承它的权限 + | CreateRole String + -- | 删角色:连同它的授权与成员一起删 + | DropRole String + -- | GRANT <权限>[, ...] ON <表|*> TO <角色> + | GrantPrivileges [String] String String + -- | REVOKE <权限>[, ...] ON <表|*> FROM <角色> + | RevokePrivileges [String] String String + -- | GRANT <角色> TO <用户>[, ...]:给用户加角色 + | GrantRole String [String] + -- | REVOKE <角色> FROM <用户>[, ...] + | RevokeRole String [String] + | CreateDatabase String + | DropDatabase String + | UseDatabase String + | ShowDatabases + deriving (Show, Eq) + +-- | 连接类型:内连接与左外连接 +data JoinKind + = InnerJoin + | LeftJoin deriving (Show, Eq) data FromClause = FromTable (Maybe String) String - | FromJoin FromClause (Maybe String) String Expr + | FromJoin JoinKind FromClause (Maybe String) String Expr + | FromUnit + deriving (Show, Eq) + +-- | 子查询:语句,加上它对外层列的引用(自由变量,语义检查时填) +data Subquery = Subquery + { subqueryStatement :: Statement + , subqueryRefs :: [String] + } deriving (Show, Eq) data Expr = Col String + | LitNull | LitInt Int + | LitFloat Double | LitStr String | LitBool Bool + | LitDate String + | LitTimestamp String + | LitBlob String + | Add Expr Expr + | Sub Expr Expr + | Mul Expr Expr + | Div Expr Expr + | Neg Expr | Gt Expr Expr | Lt Expr Expr | Eq Expr Expr | And Expr Expr | Or Expr Expr + | IsNull Expr + | IsNotNull Expr + | CountAll + | CountOf Expr + | SumOf Expr + | AvgOf Expr + | MinOf Expr + | MaxOf Expr + | ScalarSub Subquery + | InSub Expr Subquery Bool + | InList Expr [Expr] Bool + | ExistsSub Subquery Bool deriving (Show, Eq) data SortDir @@ -51,6 +120,7 @@ makeSelect cols tbl w = { selectCols = cols , selectFrom = FromTable Nothing tbl , selectWhere = w + , selectGroupBy = [] , selectOrderBy = [] , selectLimit = Nothing } diff --git a/chusql-engine/src/ChuSQL/Syntax/Parser.hs b/chusql-engine/src/ChuSQL/Syntax/Parser.hs index 35f0a32..15c219c 100644 --- a/chusql-engine/src/ChuSQL/Syntax/Parser.hs +++ b/chusql-engine/src/ChuSQL/Syntax/Parser.hs @@ -1,11 +1,12 @@ {-# LANGUAGE ImportQualifiedPost #-} -module ChuSQL.Syntax.Parser (parseStatement) where +module ChuSQL.Syntax.Parser (parseStatement, parseExpression) where -import ChuSQL.Model (Column (..)) +import ChuSQL.Model (Column (..), ColumnType (..), Value (..), plainColumn) import ChuSQL.Syntax.AST +import Control.Monad (void) import Control.Monad.Combinators.Expr (Operator (..), makeExprParser) -import Data.Char (isAlpha, isAlphaNum, toLower) +import Data.Char (isAlpha, isAlphaNum, isHexDigit, isSpace, toLower, toUpper) import Data.List (intercalate) import Data.Maybe (fromMaybe) import Data.Void (Void) @@ -19,7 +20,7 @@ type Parser = Parsec Void String -- | 跳过空白 sc :: Parser () -sc = Lxr.space space1 empty empty +sc = Lxr.space space1 (Lxr.skipLineComment "--") (Lxr.skipBlockComment "/*" "*/") -- | 词后面吃掉空白 lexeme :: Parser a -> Parser a @@ -42,6 +43,10 @@ identifier = lexeme $ do rest <- many (satisfy isIdentChar) return (first : rest) +-- | 账号名:裸名字或单引号字符串(账号名允许点与短横线,字符串形式才写得出来) +userName :: Parser String +userName = identifier <|> stringLit + -- | 名字里允许的字符 isIdentChar :: Char -> Bool isIdentChar ch = isAlphaNum ch || ch == '_' @@ -50,6 +55,10 @@ isIdentChar ch = isAlphaNum ch || ch == '_' integer :: Parser Int integer = lexeme Lxr.decimal +-- | 读一个浮点数(必须有小数点或指数) +floatLit :: Parser Double +floatLit = lexeme Lxr.float + -- | 读一个字符串字面量 stringLit :: Parser String stringLit = lexeme $ do @@ -66,6 +75,15 @@ stringChar = , satisfy (/= '\'') ] +-- | X'ABCD' 形式的二进制字面量,统一存成大写十六进制文本 +blobLit :: Parser String +blobLit = lexeme $ do + _ <- char 'x' <|> char 'X' + _ <- char '\'' + s <- many (satisfy isHexDigit) + _ <- char '\'' + return (map toUpper s) + -- | 升序或降序 sortDir :: Parser SortDir sortDir = (Desc <$ keyword "desc") <|> (Asc <$ keyword "asc") <|> pure Asc @@ -83,7 +101,6 @@ qualifiedName = do rest <- many (symbol "." *> identifier) return (intercalate "." (first : rest)) - -- | 读一个表达式 expr :: Parser Expr expr = makeExprParser atom operatorTable @@ -91,11 +108,17 @@ expr = makeExprParser atom operatorTable -- | 运算符优先级(从紧到松) operatorTable :: [[Operator Parser Expr]] operatorTable = - [ - [ InfixN (Gt <$ symbol ">") + [ [Prefix (Neg <$ symbol "-")] + , [InfixL (Mul <$ symbol "*"), InfixL (Div <$ symbol "/")] + , [InfixL (Add <$ symbol "+"), InfixL (Sub <$ symbol "-")] + , [ InfixN (Gt <$ symbol ">") , InfixN (Lt <$ symbol "<") , InfixN (Eq <$ symbol "=") ] + , [ Postfix inPredicate + , Postfix (IsNotNull <$ try (keyword "is" *> keyword "not" *> keyword "null")) + , Postfix (IsNull <$ (keyword "is" *> keyword "null")) + ] , [InfixL (And <$ keyword "AND")] , [InfixL (Or <$ keyword "OR")] ] @@ -104,14 +127,79 @@ operatorTable = atom :: Parser Expr atom = choice - [ LitBool True <$ keyword "TRUE" + [ LitNull <$ keyword "null" + , LitBool True <$ keyword "TRUE" , LitBool False <$ keyword "FALSE" + , try (LitDate <$> (keyword "date" *> stringLit)) + , try (LitTimestamp <$> (keyword "timestamp" *> stringLit)) + , try (LitBlob <$> blobLit) + , LitFloat <$> try floatLit , LitInt <$> integer , LitStr <$> stringLit + , try aggregateCall + , try existsPredicate , Col <$> qualifiedName + , try scalarSubquery , between (symbol "(") (symbol ")") expr ] +-- | [NOT] EXISTS (SELECT ...) +existsPredicate :: Parser Expr +existsPredicate = do + negated <- (True <$ try (keyword "not" *> keyword "exists")) <|> (False <$ keyword "exists") + sq <- between (symbol "(") (symbol ")") subquerySelect + return (ExistsSub sq negated) + +-- | 标量子查询:(SELECT ...) +scalarSubquery :: Parser Expr +scalarSubquery = ScalarSub <$> between (symbol "(") (symbol ")") subquerySelect + +-- | [NOT] IN (SELECT ...) 或 [NOT] IN (值清单) +inPredicate :: Parser (Expr -> Expr) +inPredicate = do + negated <- (True <$ try (keyword "not" *> keyword "in")) <|> (False <$ keyword "in") + body <- + between (symbol "(") (symbol ")") $ + choice + [ Left <$> try subquerySelect + , Right <$> sepBy1 expr (symbol ",") + ] + return $ \e -> case body of + Left sq -> InSub e sq negated + Right es -> InList e es negated + +-- | 括号里的子查询;对外层列的引用留给语义检查填 +subquerySelect :: Parser Subquery +subquerySelect = Subquery <$> selectStatement <*> pure [] + +-- | 聚合调用:COUNT(*) / COUNT(x) / SUM(x) / AVG(x) / MIN(x) / MAX(x) +aggregateCall :: Parser Expr +aggregateCall = + choice + [ CountAll <$ try (keyword "count" *> between (symbol "(") (symbol ")") (symbol "*")) + , CountOf <$> aggArg "count" + , SumOf <$> aggArg "sum" + , AvgOf <$> aggArg "avg" + , MinOf <$> aggArg "min" + , MaxOf <$> aggArg "max" + ] + where + aggArg name = try (keyword name *> between (symbol "(") (symbol ")") expr) + +-- | 只认字面量,给 DEFAULT 用 +literalValue :: Parser Value +literalValue = + choice + [ VNull <$ keyword "null" + , VBool True <$ keyword "TRUE" + , VBool False <$ keyword "FALSE" + , try (negateLit <$> floatLit) + , VInt <$> integer + , VFloat <$> try floatLit + , VStr <$> stringLit + ] + where + negateLit d = VFloat (negate d) -- | 一个排序列 orderItem :: Parser (String, SortDir) @@ -127,6 +215,13 @@ orderByClause = do keyword "by" sepBy1 orderItem (symbol ",") +-- | GROUP BY 的列表 +groupByClause :: Parser [String] +groupByClause = do + keyword "group" + keyword "by" + sepBy1 qualifiedName (symbol ",") + -- | SET 里的一条赋值 assignment :: Parser (String, Expr) assignment = do @@ -135,16 +230,21 @@ assignment = do e <- expr return (col, e) - reservedWords :: [String] reservedWords = [ "select" + , "as" , "from" , "where" , "order" , "by" , "limit" , "join" + , "left" + , "outer" + , "inner" + , "exists" + , "in" , "on" , "and" , "or" @@ -161,7 +261,23 @@ reservedWords = , "index" , "drop" , "alter" + , "add" + , "rename" + , "to" , "column" + , "user" + , "identified" + , "null" + , "is" + , "not" + , "default" + , "check" + , "unique" + , "primary" + , "key" + , "auto_increment" + , "type" + , "group" ] -- | 别名(不许用保留字) @@ -173,41 +289,53 @@ aliasName = try $ do -- | 表名 + 可选别名 tableRef :: Parser (Maybe String, String) tableRef = do - tbl <- identifier - mAlias <- optional aliasName + tbl <- tableName + mAlias <- optional ((keyword "as" *> aliasName) <|> aliasName) return (mAlias, tbl) +-- | 连接类型:内连接 / 左外连接 +joinKind :: Parser JoinKind +joinKind = + try (keyword "left" *> optional (keyword "outer") *> keyword "join" *> return LeftJoin) + <|> try (keyword "inner" *> keyword "join" *> return InnerJoin) + <|> (keyword "join" *> return InnerJoin) + -- | 一个 JOIN ... ON -joinClause :: Parser (Maybe String, String, Expr) +joinClause :: Parser (JoinKind, Maybe String, String, Expr) joinClause = do - keyword "join" + kind <- joinKind (mAlias, tbl) <- tableRef keyword "on" cond <- expr - return (mAlias, tbl, cond) + return (kind, mAlias, tbl, cond) -- | FROM 子句(可含多个 JOIN) fromClause :: Parser FromClause fromClause = do (mAlias, tbl) <- tableRef joins <- many joinClause - return (foldl (\acc (a, t, c) -> FromJoin acc a t c) (FromTable mAlias tbl) joins) + return (foldl (\acc (k, a, t, c) -> FromJoin k acc a t c) (FromTable mAlias tbl) joins) -- | SELECT 的列清单 -selectList :: Parser [String] +selectList :: Parser [(String, Expr)] selectList = choice - [ ["*"] <$ symbol "*" - , (:) <$> qualifiedName <*> many (symbol "," *> qualifiedName) + [ [("*", Col "*")] <$ symbol "*" + , sepBy1 selectItem (symbol ",") ] +-- | 投影标签保留表达式原文 +selectItem :: Parser (String, Expr) +selectItem = do + (source, e) <- match expr + pure (case e of Col c -> (c, e); _ -> (reverse (dropWhile isSpace (reverse source)), e)) -- | 读 CREATE TABLE createTableStatement :: Parser Statement createTableStatement = do keyword "create" keyword "table" - name <- identifier + name <- tableName cols <- between (symbol "(") (symbol ")") (sepBy1 columnDef (symbol ",")) return (CreateTable name cols) @@ -216,7 +344,7 @@ dropTableStatement :: Parser Statement dropTableStatement = do keyword "drop" keyword "table" - name <- identifier + name <- tableName return (DropTable name) -- | 读 CREATE INDEX(列名即索引名) @@ -225,7 +353,7 @@ createIndexStatement = do keyword "create" keyword "index" keyword "on" - tbl <- identifier + tbl <- tableName col <- between (symbol "(") (symbol ")") identifier return (CreateIndex tbl col) @@ -235,64 +363,223 @@ dropIndexStatement = do keyword "drop" keyword "index" keyword "on" - tbl <- identifier + tbl <- tableName col <- between (symbol "(") (symbol ")") identifier return (DropIndex tbl col) --- | 读 ALTER TABLE ... DROP COLUMN -alterDropColumnStatement :: Parser Statement -alterDropColumnStatement = do +-- | 读 ALTER TABLE:加列、删列、改名、改类型、改默认值、改可空 +alterTableStatement :: Parser Statement +alterTableStatement = do keyword "alter" keyword "table" - tbl <- identifier - keyword "drop" - keyword "column" - col <- identifier - return (DropColumn tbl col) + tbl <- tableName + choice + [ do + keyword "add" + void (optional (keyword "column")) + (name, col) <- columnDef + pure (AddColumn tbl (name, col)) + , do + keyword "drop" + void (optional (keyword "column")) + DropColumn tbl <$> identifier + , do + keyword "rename" + void (optional (keyword "column")) + old <- identifier + keyword "to" + RenameColumn tbl old <$> identifier + , do + keyword "alter" + void (optional (keyword "column")) + col <- identifier + choice + [ AlterColumnType tbl col <$> (keyword "type" *> columnTypeP) + , try (AlterColumnNull tbl col False <$ (keyword "set" *> keyword "not" *> keyword "null")) + , try (AlterColumnNull tbl col True <$ (keyword "drop" *> keyword "not" *> keyword "null")) + , try (AlterColumnDefault tbl col . Just <$> (keyword "set" *> keyword "default" *> literalValue)) + , AlterColumnDefault tbl col Nothing <$ (keyword "drop" *> keyword "default") + ] + ] --- | 建表时的一列 +-- | 读 CREATE USER(口令是明文,交给账号服务加盐派生) +createUserStatement :: Parser Statement +createUserStatement = do + keyword "create" + keyword "user" + name <- userName + pw <- identifiedBy + return (CreateUser name pw) + +-- | 读 IDENTIFIED BY '口令' +identifiedBy :: Parser String +identifiedBy = do + keyword "identified" + keyword "by" + stringLit + +-- | 读 ALTER USER ... IDENTIFIED BY(改一个普通账号的口令) +alterUserStatement :: Parser Statement +alterUserStatement = do + keyword "alter" + keyword "user" + name <- userName + pw <- identifiedBy + return (AlterUser name pw) + +-- | 读 DROP USER +dropUserStatement :: Parser Statement +dropUserStatement = do + keyword "drop" + keyword "user" + name <- userName + return (DropUser name) + +-- | 读 CREATE ROLE +createRoleStatement :: Parser Statement +createRoleStatement = do + keyword "create" + keyword "role" + CreateRole <$> userName + +-- | 读 DROP ROLE +dropRoleStatement :: Parser Statement +dropRoleStatement = do + keyword "drop" + keyword "role" + DropRole <$> userName + +-- | 权限名,统一收成大写 +privilegeName :: Parser String +privilegeName = + choice + [ "SELECT" <$ keyword "select" + , "INSERT" <$ keyword "insert" + , "UPDATE" <$ keyword "update" + , "DELETE" <$ keyword "delete" + , "ALL" <$ keyword "all" + ] + +-- | ON 后面的授权对象:* 表示全部表,否则是一张表(可带库前缀) +grantObject :: Parser String +grantObject = ("*" <$ symbol "*") <|> tableName + +-- | 读 GRANT:带 ON 的是给角色授权限,不带的是给用户加角色 +grantStatement :: Parser Statement +grantStatement = do + keyword "grant" + choice + [ try $ do + privs <- sepBy1 privilegeName (symbol ",") + keyword "on" + obj <- grantObject + keyword "to" + GrantPrivileges privs obj <$> userName + , GrantRole <$> userName <*> (keyword "to" *> sepBy1 userName (symbol ",")) + ] + +-- | 读 REVOKE:与 GRANT 对称 +revokeStatement :: Parser Statement +revokeStatement = do + keyword "revoke" + choice + [ try $ do + privs <- sepBy1 privilegeName (symbol ",") + keyword "on" + obj <- grantObject + keyword "from" + RevokePrivileges privs obj <$> userName + , RevokeRole <$> userName <*> (keyword "from" *> sepBy1 userName (symbol ",")) + ] + +-- | 建表时的一列:类型后面跟若干约束 columnDef :: Parser (String, Column) columnDef = do name <- identifier - ty <- columnType - return (name, ty) + ty <- columnTypeP + mods <- many columnConstraint + return (name, foldl (\c f -> f c) (plainColumn ty) mods) + +-- | 一条列约束 +columnConstraint :: Parser (Column -> Column) +columnConstraint = + choice + [ try (keyword "not" *> keyword "null" *> pure (\c -> c{columnNullable = False})) + , keyword "null" *> pure (\c -> c{columnNullable = True}) + , keyword "auto_increment" *> pure (\c -> c{columnAutoIncrement = True, columnNullable = False}) + , try (keyword "primary" *> keyword "key" *> pure (\c -> c{columnPrimaryKey = True, columnUnique = True, columnNullable = False})) + , keyword "unique" *> pure (\c -> c{columnUnique = True}) + , keyword "default" *> ((\v c -> c{columnDefault = Just v}) <$> literalValue) + , keyword "check" *> ((\s c -> c{columnCheck = Just s}) <$> checkText) + ] + +-- | CHECK 后面括号里的表达式原文,交给执行期再解析 +checkText :: Parser String +checkText = between (symbol "(") (symbol ")") (match expr) >>= \(source, _) -> pure source + +-- | 括号里的长度参数 +sizeParen :: Parser Int +sizeParen = try (between (symbol "(") (symbol ")") integer) -- | 列类型关键字 -columnType :: Parser Column -columnType = - (keyword "integer" >> pure TInt) - <|> (keyword "int" >> pure TInt) - <|> (keyword "varchar" >> pure TStr) - <|> (keyword "text" >> pure TStr) - <|> (keyword "str" >> pure TStr) - <|> (keyword "boolean" >> pure TBool) - <|> (keyword "bool" >> pure TBool) +columnTypeP :: Parser ColumnType +columnTypeP = + choice + [ keyword "integer" >> pure CInt + , keyword "int" >> pure CInt + , keyword "bigint" >> pure CBigInt + , keyword "smallint" >> pure CSmallInt + , (keyword "varchar" >>) (maybe CStr CVarchar <$> optional sizeParen) + , (keyword "char" >>) (maybe (CChar 1) CChar <$> optional sizeParen) + , keyword "text" >> pure CStr + , keyword "str" >> pure CStr + , keyword "boolean" >> pure CBool + , keyword "bool" >> pure CBool + , keyword "float" >> pure CFloat + , keyword "real" >> pure CFloat + , try (keyword "double" *> keyword "precision" *> pure CDouble) + , keyword "double" >> pure CDouble + , (keyword "decimal" >>) decimalType + , (keyword "numeric" >>) decimalType + , keyword "date" >> pure CDate + , keyword "timestamp" >> pure CTimestamp + , keyword "blob" >> pure CBlob + ] + +-- | DECIMAL 的精度与小数位 +decimalType :: Parser ColumnType +decimalType = do + args <- optional (try (between (symbol "(") (symbol ")") decimalArgs)) + pure $ case args of + Nothing -> CDecimal 10 0 + Just (p, s) -> CDecimal p s + +decimalArgs :: Parser (Int, Int) +decimalArgs = do + p <- integer + s <- fromMaybe 0 <$> optional (symbol "," *> integer) + pure (p, s) -- | 读 SELECT selectStatement :: Parser Statement selectStatement = do keyword "select" cols <- selectList - keyword "from" - fromC <- fromClause + fromC <- fromMaybe FromUnit <$> optional (keyword "from" *> fromClause) mWhere <- optional (keyword "where" *> expr) + groupBy <- fromMaybe [] <$> optional groupByClause orderBy <- fromMaybe [] <$> optional orderByClause mLimit <- optional limitClause - return - Select - { selectCols = cols - , selectFrom = fromC - , selectWhere = mWhere - , selectOrderBy = orderBy - , selectLimit = mLimit - } + pure $ if all (\(name, e) -> e == Col name) cols + then Select (map fst cols) fromC mWhere groupBy orderBy mLimit + else SelectExpr cols fromC mWhere groupBy orderBy mLimit -- | 读 INSERT,可一次插多行 insertStatement :: Parser Statement insertStatement = do keyword "INSERT" keyword "INTO" - tbl <- identifier + tbl <- tableName cols <- between (symbol "(") @@ -304,14 +591,14 @@ insertStatement = do -- | 一行值:一对括号里的若干字面量 valueRow :: Parser [Expr] -valueRow = between (symbol "(") (symbol ")") (sepBy1 atom (symbol ",")) +valueRow = between (symbol "(") (symbol ")") (sepBy1 expr (symbol ",")) -- | 读 DELETE deleteStatement :: Parser Statement deleteStatement = do keyword "DELETE" keyword "FROM" - tbl <- identifier + tbl <- tableName mWhere <- optional (keyword "WHERE" *> expr) return (Delete tbl mWhere) @@ -319,7 +606,7 @@ deleteStatement = do updateStatement :: Parser Statement updateStatement = do keyword "update" - tbl <- identifier + tbl <- tableName keyword "set" assigns <- sepBy1 assignment (symbol ",") mWhere <- optional (keyword "where" *> expr) @@ -332,14 +619,38 @@ parseStatement input = Left err -> Left (errorBundlePretty err) Right q -> Right q where - -- \| 依次尝试各种语句 statementP = selectStatement + <|> try (CreateDatabase <$> (keyword "create" *> keyword "database" *> identifier)) + <|> try (DropDatabase <$> (keyword "drop" *> keyword "database" *> identifier)) + <|> (UseDatabase <$> (keyword "use" *> identifier)) + <|> (ShowDatabases <$ (keyword "show" *> keyword "databases")) <|> insertStatement <|> deleteStatement <|> updateStatement + <|> try createUserStatement + <|> try alterUserStatement + <|> try dropUserStatement + <|> try createRoleStatement + <|> try dropRoleStatement + <|> try grantStatement + <|> try revokeStatement <|> try createIndexStatement <|> try createTableStatement <|> try dropIndexStatement - <|> try alterDropColumnStatement + <|> try alterTableStatement <|> dropTableStatement + +-- | 解析一个裸表达式(CHECK 约束原文用) +parseExpression :: String -> Either String Expr +parseExpression input = + case runParser (sc *> expr <* sc <* eof) "" 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) diff --git a/chusql-engine/test/Spec.hs b/chusql-engine/test/Spec.hs index ad2cc62..d7ec22a 100644 --- a/chusql-engine/test/Spec.hs +++ b/chusql-engine/test/Spec.hs @@ -1,13 +1,17 @@ +{-# LANGUAGE CPP #-} + module Main where import ChuSQL.Algebra.Eval (evalRelOp) +import ChuSQL.Algebra.Expr (colsInExpr, evalExpr) import ChuSQL.Algebra.Op (RelOp (..), renderPlan) import ChuSQL.Algebra.Optimize (optimize, pushProject) import ChuSQL.Algebra.Planner (translate) import ChuSQL.Engine import ChuSQL.Model +import ChuSQL.Semantic (prepare) import ChuSQL.Storage (IndexResult (..), MonadStorage (..)) -import ChuSQL.Storage.IPC (IPCStorage (runIPCStorage), Request (ReqPing), Response (RespPong), doListTables, sendRequest) +import ChuSQL.Storage.IPC (IPCStorage (runIPCStorage), Request (ReqPing), Response (RespPong), doListTables, getPipeName, sendRequest, setPipeName) import ChuSQL.Syntax.AST import ChuSQL.Syntax.Parser import Control.Concurrent (threadDelay) @@ -16,11 +20,10 @@ import Data.List (isInfixOf, sortOn) import Data.Unique (hashUnique, newUnique) import System.CPUTime (getCPUTime) import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getTemporaryDirectory, removePathForcibly) -import System.Environment (getEnvironment, lookupEnv, setEnv, unsetEnv) import System.Exit (ExitCode (..)) import System.FilePath (()) -import System.IO (Handle) -import System.Process (CreateProcess (..), ProcessHandle, StdStream (NoStream), createProcess, proc, terminateProcess, waitForProcess) +import System.IO (Handle, IOMode (WriteMode), withFile) +import System.Process (CreateProcess (..), ProcessHandle, StdStream (NoStream, UseHandle), createProcess, proc, terminateProcess, waitForProcess) import Test.Hspec -- 引擎层的 hspec 测试:语法、执行、优化器与 IPC 行为。 @@ -54,23 +57,23 @@ testDB = [("users", users), ("orders", orders)] main :: IO () main = hspec $ do - describe "ChuSQL.Syntax.AST" $ do - it "equal Selects are equal" $ do - makeSelect ["name"] "users" Nothing - `shouldBe` makeSelect ["name"] "users" Nothing - - it "different Selects are not equal" $ do - makeSelect ["name"] "users" Nothing - `shouldNotBe` makeSelect ["age"] "users" Nothing - describe "ChuSQL.Syntax.Parser" $ do - it "parses a simple SELECT" $ do - parseStatement "SELECT name FROM users" - `shouldBe` Right (makeSelect ["name"] "users" Nothing) + it "resolves table-qualified columns inside database-qualified tables" $ do + let db = [("sales.users", users)] + rowsOf (parseStatement "SELECT users.name FROM sales.users WHERE users.id = 1" >>= runStatement db) + `shouldBe` Right [[("name", VStr "Alice")]] + rowsOf (parseStatement "SELECT sales.users.name FROM sales.users WHERE sales.users.id = 1" >>= runStatement db) + `shouldBe` Right [[("name", VStr "Alice")]] - it "parses SELECT *" $ do - parseStatement "SELECT * FROM users" - `shouldBe` Right (makeSelect ["*"] "users" Nothing) + it "parses database lifecycle statements" $ do + map (fmap show . parseStatement) ["CREATE DATABASE sales", "DROP DATABASE sales", "USE sales", "SHOW DATABASES"] + `shouldBe` map Right ["CreateDatabase \"sales\"", "DropDatabase \"sales\"", "UseDatabase \"sales\"", "ShowDatabases"] + + it "parses qualified tables for reads and writes" $ do + parseStatement "SELECT * FROM sales.items" `shouldBe` Right (makeSelect ["*"] "sales.items" Nothing) + parseStatement "INSERT INTO sales.items (id) VALUES (1)" `shouldBe` Right (Insert "sales.items" ["id"] [[LitInt 1]]) + parseStatement "CREATE TABLE sales.items (id int)" `shouldBe` Right (CreateTable "sales.items" [("id", TInt)]) + parseStatement "DROP TABLE sales.items" `shouldBe` Right (DropTable "sales.items") it "parses multiple columns" $ do parseStatement "SELECT name, age FROM users" @@ -126,15 +129,6 @@ main = hspec $ do parseStatement "SELECT name FROM users" `shouldBe` Right (makeSelect ["name"] "users" Nothing) - it "parses a string literal in WHERE" $ do - parseStatement "SELECT name FROM users WHERE name = 'Alice'" - `shouldBe` Right - ( makeSelect - ["name"] - "users" - (Just (Eq (Col "name") (LitStr "Alice"))) - ) - it "uses standard SQL escaping: '' is one quote" $ do parseStatement "SELECT name FROM users WHERE name = 'It''s ok'" `shouldBe` Right @@ -175,10 +169,6 @@ main = hspec $ do parseStatement "SELECT name FROM users WHERE name = 'oops" `shouldSatisfy` isLeft - it "allows digits inside identifiers" $ do - parseStatement "SELECT user1 FROM users" - `shouldBe` Right (makeSelect ["user1"] "users" Nothing) - it "honours parentheses and AND/OR precedence" $ do parseStatement "SELECT name FROM users WHERE (age > 18 OR age < 5) AND name = 'Bob'" `shouldBe` Right @@ -212,11 +202,13 @@ main = hspec $ do { selectCols = ["name"] , selectFrom = FromJoin + InnerJoin (FromTable Nothing "users") Nothing "orders" (Eq (Col "id") (Col "user_id")) , selectWhere = Nothing + , selectGroupBy = [] , selectOrderBy = [] , selectLimit = Nothing } @@ -229,22 +221,161 @@ main = hspec $ do { selectCols = ["u.name"] , selectFrom = FromJoin + InnerJoin (FromTable (Just "u") "users") (Just "o") "orders" (Eq (Col "u.id") (Col "o.user_id")) , selectWhere = Nothing + , selectGroupBy = [] , selectOrderBy = [] , selectLimit = Nothing } ) - it "parses qualified column names" $ do - case parseStatement "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id" of - Right q -> selectCols q `shouldBe` ["u.name", "o.product"] - Left err -> expectationFailure err + it "parses LEFT JOIN" $ do + parseStatement "SELECT u.name FROM users u LEFT JOIN orders o ON u.id = o.user_id" + `shouldBe` Right + ( Select + { selectCols = ["u.name"] + , selectFrom = + FromJoin + LeftJoin + (FromTable (Just "u") "users") + (Just "o") + "orders" + (Eq (Col "u.id") (Col "o.user_id")) + , selectWhere = Nothing + , selectGroupBy = [] + , selectOrderBy = [] + , selectLimit = Nothing + } + ) + + it "parses LEFT OUTER JOIN the same as LEFT JOIN" $ do + fmap selectFrom (parseStatement "SELECT u.name FROM users u LEFT OUTER JOIN orders o ON u.id = o.user_id") + `shouldBe` Right + ( FromJoin + LeftJoin + (FromTable (Just "u") "users") + (Just "o") + "orders" + (Eq (Col "u.id") (Col "o.user_id")) + ) describe "ChuSQL.Engine (SELECT)" $ do + let sql input = parseStatement input >>= rowsOf . runStatement testDB + values input = fmap (map (map snd)) (sql input) + it "skips line comments between tokens and at EOF" $ do + sql "-- start\nSELECT name -- list\nFROM users -- end" `shouldBe` sql "SELECT name FROM users" + it "skips block comments in lists and WHERE" $ do + sql "/* start */ SELECT name/* a */, age FROM users WHERE age/* b */>18/* end */" + `shouldBe` sql "SELECT name, age FROM users WHERE age > 18" + it "preserves comment markers in strings" $ do + values "SELECT 'a--b', '/*x*/'" `shouldBe` Right [[VStr "a--b", VStr "/*x*/"]] + it "reports the position of an unterminated block comment" $ do + parseStatement "SELECT name FROM users /*" + `shouldSatisfy` either (\err -> ":1:" `isInfixOf` err && "end of input" `isInfixOf` err) (const False) + it "evaluates multiplication before addition and comparison" $ do + values "SELECT 1 + 2 * 3 = 7" `shouldBe` Right [[VBool True]] + it "honours parentheses and unary minus" $ do + values "SELECT -(1 + 2) * 3, 5 - -2" `shouldBe` Right [[VInt (-9), VInt 7]] + it "evaluates arithmetic projections and predicates" $ do + values "SELECT age + 1 FROM users WHERE age + 1 > 25" `shouldBe` Right [[VInt 26], [VInt 31]] + it "uses left associative subtraction and integer division" $ do + values "SELECT 10 - 3 - 2, -7 / 2, 12 / 3 * 2" `shouldBe` Right [[VInt 5, VInt (-3), VInt 8]] + it "returns division by zero as Left" $ do + sql "SELECT 1 / 0" `shouldSatisfy` either (isInfixOf "division by zero") (const False) + it "rejects arithmetic type errors in SELECT" $ do + sql "SELECT name + 1 FROM users" `shouldSatisfy` either (isInfixOf "SELECT: operator needs TInt") (const False) + it "checks arithmetic columns even for empty tables" $ do + (parseStatement "SELECT age + missing FROM users" >>= rowsOf . runStatement emptyDB) + `shouldSatisfy` either (isInfixOf "unknown column") (const False) + it "accepts default table prefixes with identical results" $ do + sql "SELECT users.name FROM users WHERE users.id = 1" `shouldBe` sql "SELECT name FROM users WHERE id = 1" + it "resolves default prefixes in JOIN" $ do + values "SELECT users.name, orders.product FROM users JOIN orders ON users.id = orders.user_id" + `shouldBe` Right [[VStr "Alice", VStr "Book"], [VStr "Alice", VStr "Cup"], [VStr "Bob", VStr "Pen"]] + it "rejects ambiguous bare JOIN columns" $ do + sql "SELECT id FROM users JOIN orders ON users.id = orders.user_id" + `shouldSatisfy` either (isInfixOf "ambiguous column") (const False) + it "accepts AS and keeps explicit aliases" $ do + sql "SELECT u.name FROM users AS u WHERE u.id = 1" `shouldBe` Right [[("u.name", VStr "Alice")]] + it "resolves bare columns in aliased queries" $ do + sql "SELECT name FROM users u WHERE id = 1" `shouldBe` sql "SELECT u.name FROM users u WHERE u.id = 1" + it "sorts by default qualified columns" $ do + sql "SELECT name FROM users ORDER BY users.age" `shouldBe` sql "SELECT name FROM users ORDER BY age" + it "finds every column inside arithmetic" $ do + colsInExpr (Add (Neg (Col "a")) (Sub (Mul (Col "b") (Col "c")) (Div (Col "d") (Col "e")))) + `shouldBe` ["a", "b", "c", "d", "e"] + it "returns runtime arithmetic type errors" $ do + evalExpr (Add (LitStr "x") (LitInt 1)) [] `shouldBe` Left "type error: expected two numbers" + evalExpr (Div (LitInt 1) (LitBool False)) [] `shouldBe` Left "type error: expected two numbers" + evalExpr (Neg (LitStr "x")) [] `shouldBe` Left "type error: expected a number" + it "returns division overflow without throwing" $ do + evalExpr (Div (LitInt minBound) (LitInt (-1))) [] `shouldBe` Left "integer division overflow" + it "folds arithmetic constants in projections" $ do + let plan = Compute [("answer", Add (LitInt 1) (Mul (LitInt 2) (LitInt 3)))] Unit + optimize testDB plan `shouldBe` Compute [("answer", LitInt 7)] Unit + it "preserves division errors during folding" $ do + let plan = Compute [("bad", Div (LitInt 1) (Sub (LitInt 2) (LitInt 2)))] Unit + evalRelOp testDB (optimize testDB plan) `shouldBe` Left "division by zero" + it "preserves arithmetic JOIN results and order during optimization" $ do + let plan = do + q <- parseStatement "SELECT u.name, o.product, u.age + 1 FROM users u JOIN orders o ON u.id = o.user_id WHERE u.age + 1 > 20" + prepare testDB q >>= translate + case plan of + Left err -> expectationFailure err + Right p -> do + let expected = Right [[("u.name", VStr "Alice"), ("o.product", VStr "Book"), ("u.age + 1", VInt 26)], [("u.name", VStr "Alice"), ("o.product", VStr "Cup"), ("u.age + 1", VInt 26)]] + evalRelOp testDB p `shouldBe` expected + evalRelOp testDB (optimize testDB p) `shouldBe` expected + it "rejects ambiguous WHERE and ORDER BY columns" $ do + sql "SELECT users.name FROM users JOIN orders ON users.id = orders.user_id WHERE id = 1" + `shouldSatisfy` either (isInfixOf "ambiguous column") (const False) + sql "SELECT users.name FROM users JOIN orders ON users.id = orders.user_id ORDER BY id" + `shouldSatisfy` either (isInfixOf "ambiguous column") (const False) + it "does not accept the table prefix after an explicit alias" $ do + sql "SELECT users.name FROM users u" `shouldSatisfy` either (isInfixOf "unknown column") (const False) + it "rejects duplicate qualifiers instead of selecting the first column" $ do + sql "SELECT users.name FROM users JOIN users ON users.id = users.id" + `shouldSatisfy` either (isInfixOf "ambiguous column") (const False) + it "keeps default-qualified no-index lookup results" $ do + sql "SELECT users.name FROM users WHERE users.age = 25" `shouldBe` Right [[("name", VStr "Alice")]] + it "updates columns with arithmetic" $ do + case parseStatement "UPDATE users SET age = age + 1 WHERE id = 1" >>= runStatement testDB of + Left err -> expectationFailure err + Right (db, _) -> rowsOf (runStatement db (makeSelect ["age"] "users" (Just (Eq (Col "id") (LitInt 1))))) `shouldBe` Right [[("age", VInt 26)]] + it "inserts arithmetic and negative values" $ do + case parseStatement "INSERT INTO users (id, name, age) VALUES (4, 'a--b/*c*/', -2 + 1)" >>= runStatement testDB of + Left err -> expectationFailure err + Right (db, _) -> rowsOf (runStatement db (makeSelect ["age"] "users" (Just (Eq (Col "id") (LitInt 4))))) `shouldBe` Right [[("age", VInt (-1))]] + it "rejects arithmetic types in empty-table predicates" $ do + (parseStatement "SELECT name FROM users WHERE name + 1 > 90" >>= rowsOf . runStatement emptyDB) + `shouldSatisfy` either (isInfixOf "WHERE: operator needs TInt") (const False) + it "rejects wildcard without FROM" $ do + sql "SELECT *" `shouldBe` Left "SELECT: * requires FROM" + it "applies WHERE and LIMIT to the unit row" $ do + values "SELECT 1 + 2 WHERE FALSE" `shouldBe` Right [] + values "SELECT 1 + 2 LIMIT 0" `shouldBe` Right [] + it "does not move division errors ahead of an empty JOIN" $ do + let db = [(n, if n == "orders" then t{tableRows = []} else t) | (n, t) <- testDB] + plan = parseStatement "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id WHERE u.age / 0 > 1" >>= prepare db >>= translate + case plan of + Left err -> expectationFailure err + Right p -> do + evalRelOp db p `shouldBe` Right [] + evalRelOp db (optimize db p) `shouldBe` Right [] + it "does not hide JOIN division errors with a pushed predicate" $ do + let plan = parseStatement "SELECT u.name FROM users u JOIN orders o ON u.id / 0 = o.user_id WHERE u.age > 100" >>= prepare testDB >>= translate + case plan of + Left err -> expectationFailure err + Right p -> do + evalRelOp testDB p `shouldBe` Left "division by zero" + evalRelOp testDB (optimize testDB p) `shouldBe` Left "division by zero" + it "keeps default JOIN wildcard headers unambiguous" $ do + fmap (map (map fst)) (sql "SELECT * FROM users JOIN orders ON users.id = orders.user_id LIMIT 1") + `shouldBe` Right [["users.id", "users.name", "users.age", "orders.id", "orders.user_id", "orders.product"]] it "returns all rows without WHERE" $ do rowsOf (runStatement testDB (makeSelect ["*"] "users" Nothing)) `shouldBe` Right @@ -280,20 +411,6 @@ main = hspec $ do runStatement testDB (makeSelect ["name"] "nonexistent" Nothing) `shouldSatisfy` isLeft - it "returns Left for an unknown column" $ do - runStatement - testDB - ( makeSelect - ["name"] - "users" - (Just (Gt (Col "unknown") (LitInt 18))) - ) - `shouldSatisfy` isLeft - - it "returns Left when WHERE is not a boolean" $ do - runStatement testDB (makeSelect ["name"] "users" (Just (Col "age"))) - `shouldSatisfy` isLeft - it "returns Left for an unknown projected column" $ do runStatement testDB (makeSelect ["nope"] "users" Nothing) `shouldSatisfy` isLeft @@ -303,14 +420,6 @@ main = hspec $ do parseStatement "INSERT INTO users (name, age) VALUES ('Dave', 22)" `shouldBe` Right (Insert "users" ["name", "age"] [[LitStr "Dave", LitInt 22]]) - it "parses INSERT with a single column" $ do - parseStatement "INSERT INTO users (name) VALUES ('Eve')" - `shouldBe` Right (Insert "users" ["name"] [[LitStr "Eve"]]) - - it "parses INSERT case-insensitively" $ do - parseStatement "insert into users (name, age) values ('Dave', 22)" - `shouldBe` Right (Insert "users" ["name", "age"] [[LitStr "Dave", LitInt 22]]) - it "executes INSERT and adds a row at the end" $ do case runStatement testDB (Insert "users" ["name", "age"] [[LitStr "Dave", LitInt 22]]) of Left err -> expectationFailure err @@ -336,10 +445,6 @@ main = hspec $ do runStatement testDB (Insert "users" ["name", "age"] [[LitStr "Dave"]]) `shouldSatisfy` isLeft - it "returns Left for an unknown table" $ do - runStatement testDB (Insert "nonexistent" ["name"] [[LitStr "X"]]) - `shouldSatisfy` isLeft - it "returns Left when a value has the wrong type for comparison" $ do runStatement testDB (Insert "users" ["name"] [[Col "other"]]) `shouldSatisfy` isLeft @@ -350,18 +455,6 @@ main = hspec $ do rowsOf (parseStatement "SELECT name FROM users WHERE id = 42" >>= runStatement db') `shouldBe` Right [[("name", VStr "Zoe")]] - it "parses multi-row INSERT into one statement" $ do - parseStatement "INSERT INTO users (id, name) VALUES (10, 'A'), (11, 'B'), (12, 'C')" - `shouldBe` Right - ( Insert - "users" - ["id", "name"] - [ [LitInt 10, LitStr "A"] - , [LitInt 11, LitStr "B"] - , [LitInt 12, LitStr "C"] - ] - ) - it "inserts all rows of a multi-row INSERT" $ do case parseStatement "INSERT INTO users (id, name, age) VALUES (7, 'A', 1), (8, 'B', 2)" >>= runStatement testDB of Left err -> expectationFailure err @@ -421,6 +514,108 @@ main = hspec $ do it "the built-in id column cannot be dropped (the UI locates a row by it)" $ do (parseStatement "ALTER TABLE users DROP COLUMN id" >>= runStatement testDB) `shouldSatisfy` isLeft + describe "ChuSQL.Engine (USER)" $ do + it "parses CREATE USER / ALTER USER / DROP USER case-insensitively" $ do + parseStatement "CREATE USER alice IDENTIFIED BY 's3cret'" + `shouldBe` Right (CreateUser "alice" "s3cret") + parseStatement "create user alice identified by 's3cret'" + `shouldBe` Right (CreateUser "alice" "s3cret") + parseStatement "ALTER USER alice IDENTIFIED BY 'next-one'" + `shouldBe` Right (AlterUser "alice" "next-one") + parseStatement "alter user alice identified by 'next-one'" + `shouldBe` Right (AlterUser "alice" "next-one") + parseStatement "DROP USER alice" + `shouldBe` Right (DropUser "alice") + + it "accepts a quoted account name so dot and dash are usable" $ do + parseStatement "CREATE USER 'a.b-c' IDENTIFIED BY 'p'" + `shouldBe` Right (CreateUser "a.b-c" "p") + parseStatement "DROP USER 'a.b-c'" + `shouldBe` Right (DropUser "a.b-c") + + it "keeps a doubled quote inside the password" $ do + parseStatement "CREATE USER alice IDENTIFIED BY 'it''s ok'" + `shouldBe` Right (CreateUser "alice" "it's ok") + + it "an empty password or a missing clause is an error" $ do + (parseStatement "CREATE USER alice IDENTIFIED BY ''" >>= prepare testDB) + `shouldSatisfy` isLeft + parseStatement "CREATE USER alice" `shouldSatisfy` isLeft + parseStatement "CREATE USER" `shouldSatisfy` isLeft + parseStatement "DROP USER" `shouldSatisfy` isLeft + + it "refuses to invent account rows in the query engine" $ do + (parseStatement "CREATE USER alice IDENTIFIED BY 's3cret'" >>= runStatement testDB) + `shouldSatisfy` isLeft + (parseStatement "ALTER USER alice IDENTIFIED BY 'next'" >>= runStatement testDB) + `shouldSatisfy` isLeft + (parseStatement "DROP USER alice" >>= runStatement testDB) + `shouldSatisfy` isLeft + + describe "ChuSQL.Engine (ROLE)" $ do + it "parses CREATE ROLE / DROP ROLE case-insensitively" $ do + parseStatement "CREATE ROLE analyst" `shouldBe` Right (CreateRole "analyst") + parseStatement "create role analyst" `shouldBe` Right (CreateRole "analyst") + parseStatement "DROP ROLE analyst" `shouldBe` Right (DropRole "analyst") + parseStatement "drop role 'a.b'" `shouldBe` Right (DropRole "a.b") + + it "parses privilege grants on one table, on another database's table or on everything" $ do + parseStatement "GRANT SELECT ON users TO analyst" + `shouldBe` Right (GrantPrivileges ["SELECT"] "users" "analyst") + parseStatement "grant select, insert on users to analyst" + `shouldBe` Right (GrantPrivileges ["SELECT", "INSERT"] "users" "analyst") + parseStatement "GRANT ALL ON sales.orders TO analyst" + `shouldBe` Right (GrantPrivileges ["ALL"] "sales.orders" "analyst") + parseStatement "GRANT DELETE ON * TO analyst" + `shouldBe` Right (GrantPrivileges ["DELETE"] "*" "analyst") + + it "parses REVOKE symmetrically" $ do + parseStatement "REVOKE SELECT ON users FROM analyst" + `shouldBe` Right (RevokePrivileges ["SELECT"] "users" "analyst") + parseStatement "revoke update, delete on * from analyst" + `shouldBe` Right (RevokePrivileges ["UPDATE", "DELETE"] "*" "analyst") + + it "tells a role membership apart from a privilege grant" $ do + parseStatement "GRANT analyst TO alice" + `shouldBe` Right (GrantRole "analyst" ["alice"]) + parseStatement "grant analyst to alice, bob" + `shouldBe` Right (GrantRole "analyst" ["alice", "bob"]) + parseStatement "REVOKE analyst FROM alice, bob" + `shouldBe` Right (RevokeRole "analyst" ["alice", "bob"]) + + it "an unknown privilege or a missing clause is an error" $ do + parseStatement "GRANT EXECUTE ON users TO analyst" `shouldSatisfy` isLeft + parseStatement "GRANT SELECT ON users" `shouldSatisfy` isLeft + parseStatement "GRANT analyst TO" `shouldSatisfy` isLeft + parseStatement "REVOKE analyst FROM" `shouldSatisfy` isLeft + parseStatement "CREATE ROLE" `shouldSatisfy` isLeft + parseStatement "DROP ROLE" `shouldSatisfy` isLeft + + it "checks role names, privileges and grant objects before execution" $ do + (parseStatement "CREATE ROLE select" >>= prepare testDB) `shouldSatisfy` isLeft + (parseStatement "DROP ROLE insert" >>= prepare testDB) `shouldSatisfy` isLeft + (parseStatement "GRANT SELECT ON users TO analyst" >>= prepare testDB) + `shouldBe` Right (GrantPrivileges ["SELECT"] "users" "analyst") + (parseStatement "GRANT SELECT ON users TO select" >>= prepare testDB) `shouldSatisfy` isLeft + (parseStatement "REVOKE SELECT ON users FROM analyst" >>= prepare testDB) + `shouldBe` Right (RevokePrivileges ["SELECT"] "users" "analyst") + (parseStatement "GRANT analyst TO alice, bob" >>= prepare testDB) + `shouldBe` Right (GrantRole "analyst" ["alice", "bob"]) + (parseStatement "REVOKE analyst FROM alice" >>= prepare testDB) + `shouldBe` Right (RevokeRole "analyst" ["alice"]) + + it "refuses to invent roles in the query engine (the web services own them)" $ do + (parseStatement "CREATE ROLE analyst" >>= runStatement testDB) `shouldSatisfy` isLeft + (parseStatement "DROP ROLE analyst" >>= runStatement testDB) `shouldSatisfy` isLeft + (parseStatement "GRANT SELECT ON users TO analyst" >>= runStatement testDB) `shouldSatisfy` isLeft + (parseStatement "REVOKE SELECT ON users FROM analyst" >>= runStatement testDB) `shouldSatisfy` isLeft + (parseStatement "GRANT analyst TO alice" >>= runStatement testDB) `shouldSatisfy` isLeft + (parseStatement "REVOKE analyst FROM alice" >>= runStatement testDB) `shouldSatisfy` isLeft + + it "user is reserved as an alias but still usable inside a longer name" $ do + parseStatement "SELECT * FROM users user" `shouldSatisfy` isLeft + parseStatement "SELECT user1 FROM users" `shouldBe` Right (Select ["user1"] (FromTable Nothing "users") Nothing [] [] Nothing) + describe "ChuSQL.Engine (DELETE)" $ do it "parses DELETE with WHERE" $ do parseStatement "DELETE FROM users WHERE age < 18" @@ -430,10 +625,6 @@ main = hspec $ do parseStatement "DELETE FROM users" `shouldBe` Right (Delete "users" Nothing) - it "parses DELETE case-insensitively" $ do - parseStatement "delete from users where age < 18" - `shouldBe` Right (Delete "users" (Just (Lt (Col "age") (LitInt 18)))) - it "executes DELETE with WHERE, removing matching rows" $ do case runStatement testDB (Delete "users" (Just (Lt (Col "age") (LitInt 18)))) of Left err -> expectationFailure err @@ -451,19 +642,6 @@ main = hspec $ do rowsOf (runStatement db' (makeSelect ["name"] "users" Nothing)) `shouldBe` Right [] - it "does not modify the original database" $ do - case runStatement testDB (Delete "users" Nothing) of - Left err -> expectationFailure err - Right (_, _) -> do - rowsOf (runStatement testDB (makeSelect ["name"] "users" Nothing)) - `shouldSatisfy` \r -> case r of - Right rows -> length rows == 3 - Left _ -> False - - it "returns Left for an unknown table" $ do - runStatement testDB (Delete "nonexistent" Nothing) - `shouldSatisfy` isLeft - it "returns Left when WHERE references an unknown column" $ do runStatement testDB (Delete "users" (Just (Gt (Col "unknown") (LitInt 18)))) `shouldSatisfy` isLeft @@ -492,15 +670,6 @@ main = hspec $ do Nothing ) - it "parses UPDATE case-insensitively" $ do - parseStatement "update users set age = 26 where name = 'Alice'" - `shouldBe` Right - ( Update - "users" - [("age", LitInt 26)] - (Just (Eq (Col "name") (LitStr "Alice"))) - ) - it "executes UPDATE with WHERE, modifying matching rows" $ do case runStatement testDB (Update "users" [("age", LitInt 99)] (Just (Eq (Col "name") (LitStr "Alice")))) of Left err -> expectationFailure err @@ -534,35 +703,11 @@ main = hspec $ do , [("name", VStr "X"), ("age", VInt 99)] ] - it "does not modify the original database" $ do - case runStatement testDB (Update "users" [("age", LitInt 0)] Nothing) of - Left err -> expectationFailure err - Right (_, _) -> do - rowsOf (runStatement testDB (makeSelect ["name"] "users" Nothing)) - `shouldSatisfy` \r -> case r of - Right rows -> length rows == 3 - Left _ -> False - - it "returns Left for an unknown table" $ do - runStatement testDB (Update "nonexistent" [("age", LitInt 0)] Nothing) - `shouldSatisfy` isLeft - it "returns Left when WHERE references an unknown column" $ do runStatement testDB (Update "users" [("age", LitInt 0)] (Just (Gt (Col "unknown") (LitInt 1)))) `shouldSatisfy` isLeft - it "returns Left when SET references an unknown column" $ do - runStatement testDB (Update "users" [("unknown", LitInt 0)] Nothing) - `shouldSatisfy` \r -> case r of - Right _ -> True - Left _ -> True - describe "ChuSQL.Engine (ORDER BY)" $ do - it "parses ORDER BY ASC" $ do - case parseStatement "SELECT name FROM users ORDER BY age ASC" of - Right q -> selectOrderBy q `shouldBe` [("age", Asc)] - Left err -> expectationFailure err - it "parses ORDER BY DESC" $ do case parseStatement "SELECT name FROM users ORDER BY age DESC" of Right q -> selectOrderBy q `shouldBe` [("age", Desc)] @@ -578,17 +723,13 @@ main = hspec $ do Right q -> selectOrderBy q `shouldBe` [("age", Desc), ("name", Asc)] Left err -> expectationFailure err - it "parses without ORDER BY" $ do - case parseStatement "SELECT name FROM users" of - Right q -> selectOrderBy q `shouldBe` [] - Left err -> expectationFailure err - it "executes ORDER BY age ASC" $ do let q = Select { selectCols = ["name"] , selectFrom = FromTable Nothing "users" , selectWhere = Nothing + , selectGroupBy = [] , selectOrderBy = [("age", Asc)] , selectLimit = Nothing } @@ -605,6 +746,7 @@ main = hspec $ do { selectCols = ["name"] , selectFrom = FromTable Nothing "users" , selectWhere = Nothing + , selectGroupBy = [] , selectOrderBy = [("age", Desc)] , selectLimit = Nothing } @@ -615,33 +757,6 @@ main = hspec $ do , [("name", VStr "Bob")] ] - it "executes ORDER BY name ASC" $ do - let q = - Select - { selectCols = ["name"] - , selectFrom = FromTable Nothing "users" - , selectWhere = Nothing - , selectOrderBy = [("name", Asc)] - , selectLimit = Nothing - } - rowsOf (runStatement testDB q) - `shouldBe` Right - [ [("name", VStr "Alice")] - , [("name", VStr "Bob")] - , [("name", VStr "Carol")] - ] - - it "returns Left for unknown ORDER BY column" $ do - let q = - Select - { selectCols = ["name"] - , selectFrom = FromTable Nothing "users" - , selectWhere = Nothing - , selectOrderBy = [("nope", Asc)] - , selectLimit = Nothing - } - runStatement testDB q `shouldSatisfy` isLeft - describe "ChuSQL.Engine (LIMIT)" $ do it "parses LIMIT" $ do case parseStatement "SELECT * FROM users LIMIT 2" of @@ -655,17 +770,13 @@ main = hspec $ do selectLimit q `shouldBe` Just 1 Left err -> expectationFailure err - it "parses without LIMIT" $ do - case parseStatement "SELECT * FROM users" of - Right q -> selectLimit q `shouldBe` Nothing - Left err -> expectationFailure err - it "executes LIMIT without ORDER BY" $ do let q = Select { selectCols = ["name"] , selectFrom = FromTable Nothing "users" , selectWhere = Nothing + , selectGroupBy = [] , selectOrderBy = [] , selectLimit = Just 2 } @@ -681,6 +792,7 @@ main = hspec $ do { selectCols = ["name"] , selectFrom = FromTable Nothing "users" , selectWhere = Nothing + , selectGroupBy = [] , selectOrderBy = [("age", Desc)] , selectLimit = Just 2 } @@ -690,28 +802,13 @@ main = hspec $ do , [("name", VStr "Alice")] ] - it "executes LIMIT larger than row count" $ do - let q = - Select - { selectCols = ["name"] - , selectFrom = FromTable Nothing "users" - , selectWhere = Nothing - , selectOrderBy = [] - , selectLimit = Just 10 - } - rowsOf (runStatement testDB q) - `shouldBe` Right - [ [("name", VStr "Alice")] - , [("name", VStr "Bob")] - , [("name", VStr "Carol")] - ] - it "executes LIMIT 0" $ do let q = Select { selectCols = ["name"] , selectFrom = FromTable Nothing "users" , selectWhere = Nothing + , selectGroupBy = [] , selectOrderBy = [] , selectLimit = Just 0 } @@ -760,43 +857,237 @@ main = hspec $ do { selectCols = ["u.name"] , selectFrom = FromJoin + InnerJoin (FromTable (Just "u") "users") (Just "x") "nonexistent" (Eq (Col "u.id") (Col "x.id")) , selectWhere = Nothing + , selectGroupBy = [] , selectOrderBy = [] , selectLimit = Nothing } ) `shouldSatisfy` isLeft - it "returns Left when ON references an unknown column" $ do - runStatement - testDB - ( Select - { selectCols = ["u.name"] - , selectFrom = - FromJoin - (FromTable (Just "u") "users") - (Just "o") - "orders" - (Eq (Col "u.nope") (Col "o.user_id")) - , selectWhere = Nothing - , selectOrderBy = [] - , selectLimit = Nothing - } - ) - `shouldSatisfy` isLeft + describe "ChuSQL.Engine (LEFT JOIN)" $ do + let sql input = parseStatement input >>= rowsOf . runStatement testDB - describe "ChuSQL end-to-end" $ do - it "parses and executes a simple query" $ do - rowsOf (parseStatement "SELECT name FROM users WHERE age > 18" >>= runStatement testDB) + it "keeps unmatched left rows and fills the right side with NULL" $ do + sql "SELECT u.name, o.product FROM users u LEFT JOIN orders o ON u.id = o.user_id" `shouldBe` Right - [ [("name", VStr "Alice")] - , [("name", VStr "Carol")] + [ [("u.name", VStr "Alice"), ("o.product", VStr "Book")] + , [("u.name", VStr "Alice"), ("o.product", VStr "Cup")] + , [("u.name", VStr "Bob"), ("o.product", VStr "Pen")] + , [("u.name", VStr "Carol"), ("o.product", VNull)] ] + it "emits NULL rows even when the right table is empty" $ do + let db = [("users", users), ("orders", orders {tableRows = []})] + (parseStatement "SELECT u.name, o.product FROM users u LEFT JOIN orders o ON u.id = o.user_id" >>= rowsOf . runStatement db) + `shouldBe` Right + [ [("u.name", VStr "Alice"), ("o.product", VNull)] + , [("u.name", VStr "Bob"), ("o.product", VNull)] + , [("u.name", VStr "Carol"), ("o.product", VNull)] + ] + + it "a WHERE on the right side drops the NULL rows again" $ do + sql "SELECT u.name, o.product FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.product IS NOT NULL" + `shouldBe` Right + [ [("u.name", VStr "Alice"), ("o.product", VStr "Book")] + , [("u.name", VStr "Alice"), ("o.product", VStr "Cup")] + , [("u.name", VStr "Bob"), ("o.product", VStr "Pen")] + ] + + it "a WHERE on the left side keeps the NULL rows" $ do + sql "SELECT u.name, o.product FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE u.age > 18" + `shouldBe` Right + [ [("u.name", VStr "Alice"), ("o.product", VStr "Book")] + , [("u.name", VStr "Alice"), ("o.product", VStr "Cup")] + , [("u.name", VStr "Carol"), ("o.product", VNull)] + ] + + it "matches INNER JOIN when every left row has a match" $ do + sql "SELECT u.name, o.product FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.product IS NOT NULL ORDER BY o.product" + `shouldBe` sql "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id ORDER BY o.product" + + it "keeps LEFT JOIN results identical after optimization" $ do + sameResultAsUnoptimized "SELECT u.name, o.product FROM users u LEFT JOIN orders o ON u.id = o.user_id" + sameResultAsUnoptimized "SELECT u.name, o.product FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE u.age > 18" + + it "keeps the LeftJoin node and does not push a right-side predicate across it" $ do + fmap (leftJoinKinds . optimize testDB) (parseStatement "SELECT u.name FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.product = 'Book'" >>= translate) + `shouldBe` Right [True] + + it "a non-equi ON goes through the nested loop path and still fills NULLs" $ do + sql "SELECT u.name, o.product FROM users u LEFT JOIN orders o ON u.id > o.user_id" + `shouldBe` Right + [ [("u.name", VStr "Alice"), ("o.product", VNull)] + , [("u.name", VStr "Bob"), ("o.product", VStr "Book")] + , [("u.name", VStr "Bob"), ("o.product", VStr "Cup")] + , [("u.name", VStr "Carol"), ("o.product", VStr "Book")] + , [("u.name", VStr "Carol"), ("o.product", VStr "Pen")] + , [("u.name", VStr "Carol"), ("o.product", VStr "Cup")] + ] + + describe "ChuSQL.Engine (JOIN 嵌套循环)" $ do + let sql input = parseStatement input >>= rowsOf . runStatement testDB + + it "keeps columns from both sides when the ON has no equi key" $ do + sql "SELECT u.name, o.product FROM users u JOIN orders o ON u.id > o.user_id" + `shouldBe` Right + [ [("u.name", VStr "Bob"), ("o.product", VStr "Book")] + , [("u.name", VStr "Bob"), ("o.product", VStr "Cup")] + , [("u.name", VStr "Carol"), ("o.product", VStr "Book")] + , [("u.name", VStr "Carol"), ("o.product", VStr "Pen")] + , [("u.name", VStr "Carol"), ("o.product", VStr "Cup")] + ] + + describe "ChuSQL.Engine (子查询)" $ do + let sql input = parseStatement input >>= rowsOf . runStatement testDB + + it "IN (subquery) keeps the rows whose value the subquery returns" $ do + sql "SELECT name FROM users WHERE id IN (SELECT user_id FROM orders)" + `shouldBe` Right [[("name", VStr "Alice")], [("name", VStr "Bob")]] + + it "NOT IN (subquery) keeps the other rows" $ do + sql "SELECT name FROM users WHERE id NOT IN (SELECT user_id FROM orders)" + `shouldBe` Right [[("name", VStr "Carol")]] + + it "EXISTS works as a correlated subquery" $ do + sql "SELECT u.name FROM users u WHERE EXISTS (SELECT * FROM orders o WHERE o.user_id = u.id)" + `shouldBe` Right [[("u.name", VStr "Alice")], [("u.name", VStr "Bob")]] + + it "NOT EXISTS keeps the rows with no match" $ do + sql "SELECT u.name FROM users u WHERE NOT EXISTS (SELECT * FROM orders o WHERE o.user_id = u.id)" + `shouldBe` Right [[("u.name", VStr "Carol")]] + + it "a scalar subquery is compared as a single value" $ do + sql "SELECT name FROM users WHERE age > (SELECT AVG(age) FROM users)" + `shouldBe` Right [[("name", VStr "Alice")], [("name", VStr "Carol")]] + + it "a scalar subquery with no rows behaves like NULL" $ do + sql "SELECT name FROM users WHERE age > (SELECT age FROM users WHERE id = 99)" + `shouldBe` Right [] + + it "rejects a scalar subquery that returns more than one row" $ do + (parseStatement "SELECT name FROM users WHERE age > (SELECT age FROM users)" >>= runStatement testDB) + `shouldSatisfy` isLeft + + it "rejects a scalar subquery with more than one column" $ do + (parseStatement "SELECT name FROM users WHERE age > (SELECT id, age FROM users)" >>= runStatement testDB) + `shouldSatisfy` isLeft + + it "rejects an unknown column inside a subquery" $ do + (parseStatement "SELECT name FROM users WHERE id IN (SELECT nope FROM orders)" >>= runStatement testDB) + `shouldSatisfy` isLeft + + it "rejects a subquery that is not a SELECT" $ do + runStatement testDB (Delete "users" (Just (ExistsSub (Subquery (DropTable "orders") []) False))) + `shouldSatisfy` isLeft + + it "runs a subquery in a JOIN condition" $ do + sql "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id AND o.product IN (SELECT product FROM orders WHERE user_id = 1)" + `shouldBe` Right + [ [("u.name", VStr "Alice"), ("o.product", VStr "Book")] + , [("u.name", VStr "Alice"), ("o.product", VStr "Cup")] + ] + + it "keeps subquery results identical after optimization" $ do + sameResultAsUnoptimized "SELECT name FROM users WHERE id IN (SELECT user_id FROM orders)" + sameResultAsUnoptimized "SELECT u.name FROM users u WHERE EXISTS (SELECT * FROM orders o WHERE o.user_id = u.id)" + + describe "ChuSQL.Engine (聚合与 GROUP BY)" $ do + let sql input = parseStatement input >>= rowsOf . runStatement testDB + sqlDb db input = parseStatement input >>= rowsOf . runStatement db + vals input = fmap (map (map snd)) (sql input) + + it "counts rows without GROUP BY" $ do + vals "SELECT COUNT(*) FROM users" `shouldBe` Right [[VInt 3]] + + it "counts only the non-null values of a column" $ do + let db = [(n, if n == "users" then t {tableRows = [[("id", VInt 1), ("name", VStr "a"), ("age", VNull)]]} else t) | (n, t) <- testDB] + fmap (map (map snd)) (sqlDb db "SELECT COUNT(age), COUNT(*) FROM users") + `shouldBe` Right [[VInt 0, VInt 1]] + + it "sums, averages and takes extremes" $ do + vals "SELECT SUM(age), AVG(age), MIN(age), MAX(age) FROM users" + `shouldBe` Right [[VInt 72, VFloat 24, VInt 17, VInt 30]] + + it "ignores nulls in the aggregates that skip them" $ do + let db = [(n, if n == "users" then t {tableRows = [[("id", VInt 1), ("name", VStr "a"), ("age", VNull)], [("id", VInt 2), ("name", VStr "b"), ("age", VInt 4)]]} else t) | (n, t) <- testDB] + fmap (map (map snd)) (sqlDb db "SELECT SUM(age), AVG(age), MIN(age), MAX(age) FROM users") + `shouldBe` Right [[VInt 4, VFloat 4, VInt 4, VInt 4]] + + it "groups by a column and counts each group" $ do + vals "SELECT user_id, COUNT(*) FROM orders GROUP BY user_id ORDER BY user_id" + `shouldBe` Right [[VInt 1, VInt 2], [VInt 2, VInt 1]] + + it "keeps the group key in the output row" $ do + fmap (map (map fst)) (sql "SELECT age, COUNT(*) FROM users GROUP BY age ORDER BY age") + `shouldBe` Right [["age", "COUNT(*)"], ["age", "COUNT(*)"], ["age", "COUNT(*)"]] + + it "computes expressions on top of aggregate results" $ do + vals "SELECT SUM(age) / 2 FROM users" `shouldBe` Right [[VInt 36]] + + it "uses the same aggregate only once" $ do + case parseStatement "SELECT SUM(age), SUM(age) + 1 FROM users" of + Left err -> expectationFailure err + Right q -> case translate =<< prepare testDB q of + Left err -> expectationFailure err + Right plan -> case plan of + Compute _ (Aggregate _ aggs _) -> length aggs `shouldBe` 1 + other -> expectationFailure ("unexpected plan: " ++ renderPlan other) + + it "returns one row with null sum for an empty table" $ do + let empty = [(n, if n == "users" then t {tableRows = []} else t) | (n, t) <- testDB] + fmap (map (map snd)) (sqlDb empty "SELECT COUNT(*), SUM(age) FROM users") + `shouldBe` Right [[VInt 0, VNull]] + + it "returns no groups for an empty table when grouping" $ do + let empty = [(n, if n == "users" then t {tableRows = []} else t) | (n, t) <- testDB] + sqlDb empty "SELECT age, COUNT(*) FROM users GROUP BY age" `shouldBe` Right [] + + it "rejects a bare column that is not grouped" $ do + sql "SELECT name, COUNT(*) FROM users GROUP BY age" + `shouldSatisfy` either (isInfixOf "must appear in GROUP BY") (const False) + + it "rejects an aggregate in WHERE" $ do + sql "SELECT name FROM users WHERE COUNT(*) > 1" + `shouldSatisfy` either (isInfixOf "aggregate functions are not allowed here") (const False) + + it "rejects nested aggregates" $ do + sql "SELECT SUM(COUNT(*)) FROM users" + `shouldSatisfy` either (isInfixOf "nested aggregates") (const False) + + it "rejects SUM on a text column" $ do + sql "SELECT SUM(name) FROM users" + `shouldSatisfy` either (isInfixOf "needs a number") (const False) + + it "rejects an unknown GROUP BY column" $ do + sql "SELECT age, COUNT(*) FROM users GROUP BY nope" + `shouldSatisfy` either (isInfixOf "unknown column") (const False) + + it "parses GROUP BY and the aggregate calls" $ do + case parseStatement "SELECT age, COUNT(*), SUM(age) FROM users GROUP BY age" of + Left err -> expectationFailure err + Right q -> do + selectGroupBy q `shouldBe` ["age"] + selectItems q + `shouldBe` [ ("age", Col "age") + , ("COUNT(*)", CountAll) + , ("SUM(age)", SumOf (Col "age")) + ] + + it "keeps grouped results identical after optimization" $ do + let query = "SELECT user_id, COUNT(*) FROM orders GROUP BY user_id ORDER BY user_id" + case parseStatement query >>= prepare testDB >>= translate of + Left err -> expectationFailure err + Right plan -> do + evalRelOp testDB plan `shouldBe` Right [[("user_id", VInt 1), ("COUNT(*)", VInt 2)], [("user_id", VInt 2), ("COUNT(*)", VInt 1)]] + evalRelOp testDB (optimize testDB plan) `shouldBe` evalRelOp testDB plan + + describe "ChuSQL end-to-end" $ do it "parses and executes a complex query" $ do rowsOf ( parseStatement "SELECT name FROM users WHERE age < 18 OR age > 28" @@ -807,69 +1098,12 @@ main = hspec $ do , [("name", VStr "Carol")] ] - it "returns Left when parsing fails" $ do - (parseStatement "SELECT name FROM" >>= runStatement testDB) - `shouldSatisfy` isLeft - - it "INSERT then SELECT end-to-end" $ do - case parseStatement "INSERT INTO users (name, age) VALUES ('Dave', 22)" >>= runStatement testDB of - Left err -> expectationFailure err - Right (db', _) -> do - rowsOf (parseStatement "SELECT name FROM users WHERE age > 18" >>= runStatement db') - `shouldBe` Right - [ [("name", VStr "Alice")] - , [("name", VStr "Carol")] - , [("name", VStr "Dave")] - ] - - it "DELETE then SELECT end-to-end" $ do - case parseStatement "DELETE FROM users WHERE age > 20" >>= runStatement testDB of - Left err -> expectationFailure err - Right (db', _) -> do - rowsOf (parseStatement "SELECT name FROM users" >>= runStatement db') - `shouldBe` Right - [ [("name", VStr "Bob")] - ] - - it "UPDATE then SELECT end-to-end" $ do - case parseStatement "UPDATE users SET age = 99 WHERE name = 'Alice'" >>= runStatement testDB of - Left err -> expectationFailure err - Right (db', _) -> do - rowsOf (parseStatement "SELECT name, age FROM users" >>= runStatement db') - `shouldBe` Right - [ [("name", VStr "Alice"), ("age", VInt 99)] - , [("name", VStr "Bob"), ("age", VInt 17)] - , [("name", VStr "Carol"), ("age", VInt 30)] - ] - - it "ORDER BY with WHERE end-to-end" $ do - rowsOf (parseStatement "SELECT name FROM users WHERE age > 18 ORDER BY age DESC" >>= runStatement testDB) - `shouldBe` Right - [ [("name", VStr "Carol")] - , [("name", VStr "Alice")] - ] - - it "ORDER BY with LIMIT end-to-end" $ do - rowsOf (parseStatement "SELECT name FROM users ORDER BY age DESC LIMIT 2" >>= runStatement testDB) - `shouldBe` Right - [ [("name", VStr "Carol")] - , [("name", VStr "Alice")] - ] - it "WHERE ORDER BY LIMIT end-to-end" $ do rowsOf (parseStatement "SELECT name FROM users WHERE age > 15 ORDER BY age DESC LIMIT 1" >>= runStatement testDB) `shouldBe` Right [ [("name", VStr "Carol")] ] - it "JOIN with WHERE and ORDER BY end-to-end" $ do - rowsOf (parseStatement "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id WHERE u.age > 15 ORDER BY o.product" >>= runStatement testDB) - `shouldBe` Right - [ [("u.name", VStr "Alice"), ("o.product", VStr "Book")] - , [("u.name", VStr "Alice"), ("o.product", VStr "Cup")] - , [("u.name", VStr "Bob"), ("o.product", VStr "Pen")] - ] - describe "ChuSQL.Algebra.Optimize" $ do it "keeps single-table WHERE results identical" $ do sameResultAsUnoptimized "SELECT name FROM users WHERE age > 18" @@ -877,12 +1111,12 @@ main = hspec $ do it "keeps JOIN with WHERE results identical" $ do sameResultAsUnoptimized "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id WHERE u.age > 20" + it "keeps a JOIN without an equi key (nested loop path) results identical" $ do + sameResultAsUnoptimized "SELECT u.name, o.product FROM users u JOIN orders o ON u.id > o.user_id" + it "keeps JOIN with WHERE, ORDER BY and LIMIT results identical" $ do sameResultAsUnoptimized "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id WHERE u.age > 15 ORDER BY o.product DESC LIMIT 2" - it "keeps SELECT * results identical" $ do - sameResultAsUnoptimized "SELECT * FROM users" - it "pushes a single-side predicate into the left side of a JOIN" $ do case parseStatement "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id WHERE u.age > 20" of Left err -> expectationFailure err @@ -899,11 +1133,11 @@ main = hspec $ do case translate q of Left err -> expectationFailure err Right relOp -> - optimize testDB relOp `shouldBe` Scan Nothing "users" + optimize testDB relOp `shouldBe` Scan Nothing "users" Nothing it "does not push a filter through LIMIT" $ do let cond = Gt (Col "age") (LitInt 18) - relOp = Filter cond (Limit 2 (Scan Nothing "users")) + relOp = Filter cond (Limit 2 (Scan Nothing "users" Nothing)) optimize testDB relOp `shouldBe` relOp it "is idempotent after reaching a fixed point" $ do @@ -927,9 +1161,9 @@ main = hspec $ do fmap anyFilter (optimizedPlan "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id WHERE 1 = 1") `shouldBe` Right False - it "leaves a predicate that mentions a column untouched" $ do - fmap firstFilterCond (optimizedPlan "SELECT name FROM users WHERE age > 18") - `shouldBe` Right (Just (Gt (Col "age") (LitInt 18))) + it "leaves an expression predicate as a filter" $ do + fmap firstFilterCond (optimizedPlan "SELECT name FROM users WHERE age + 1 > 18") + `shouldBe` Right (Just (Gt (Add (Col "age") (LitInt 1)) (LitInt 18))) it "folds the constant part of a predicate that cannot fold as a whole" $ do fmap firstFilterCond (optimizedPlan "SELECT name FROM users WHERE age > 18 AND 1 = 1") @@ -939,18 +1173,6 @@ main = hspec $ do fmap firstFilterCond (optimizedPlan "SELECT name FROM users WHERE 'abc' > 1") `shouldBe` Right (Just (Gt (LitStr "abc") (LitInt 1))) - it "keeps constant-false results identical" $ do - sameResultAsUnoptimized "SELECT name FROM users WHERE 1 > 2" - - it "keeps constant-true results identical" $ do - sameResultAsUnoptimized "SELECT name FROM users WHERE 1 = 1" - - it "keeps mixed constant and column predicates identical" $ do - sameResultAsUnoptimized "SELECT name FROM users WHERE age > 18 AND 1 = 1" - - it "keeps a failing constant predicate identical" $ do - sameResultAsUnoptimized "SELECT name FROM users WHERE 'abc' > 1" - it "returns no rows for a constant-false predicate" $ do rowsOf (parseStatement "SELECT name FROM users WHERE 1 > 2" >>= runStatement testDB) `shouldBe` Right [] @@ -963,27 +1185,20 @@ main = hspec $ do , [("name", VStr "Carol")] ] - it "keeps the LIMIT node while pushing projection past it" $ do - let plan = Project ["name"] (Limit 2 (Scan Nothing "users")) - stripProjects (pushProject testDB ["*"] plan) `shouldBe` stripProjects plan - - it "keeps the sort keys while pushing projection past a sort" $ do - let plan = Project ["name"] (Sort [("age", Desc)] (Scan Nothing "users")) - stripProjects (pushProject testDB ["*"] plan) `shouldBe` stripProjects plan - it "keeps both sides of a join" $ do let plan = Project ["u.name"] ( Join - (Scan (Just "u") "users") - (Scan (Just "o") "orders") + InnerJoin + (Scan (Just "u") "users" Nothing) + (Scan (Just "o") "orders" Nothing) (Eq (Col "u.id") (Col "o.user_id")) ) stripProjects (pushProject testDB ["*"] plan) `shouldBe` stripProjects plan it "keeps nested projections in place" $ do - let plan = Project ["name"] (Project ["name", "age"] (Scan Nothing "users")) + let plan = Project ["name"] (Project ["name", "age"] (Scan Nothing "users" Nothing)) stripProjects (pushProject testDB ["*"] plan) `shouldBe` stripProjects plan it "keeps the whole spine of a full pipeline" $ do @@ -994,7 +1209,7 @@ main = hspec $ do 2 ( Sort [("age", Desc)] - (Filter (Gt (Col "age") (LitInt 18)) (Scan Nothing "users")) + (Filter (Gt (Col "age") (LitInt 18)) (Scan Nothing "users" Nothing)) ) ) stripProjects (pushProject testDB ["*"] plan) `shouldBe` stripProjects plan @@ -1005,15 +1220,17 @@ main = hspec $ do it "pushes projection into a single-table scan as one layer" $ do optimizedPlan "SELECT name FROM users" - `shouldBe` Right (Project ["name"] (Scan Nothing "users")) + `shouldBe` Right (Project ["name"] (Scan Nothing "users" (Just ["name"]))) - it "does not add a projection when the scan already supplies every column" $ do - optimizedPlan "SELECT id, name, age FROM users" - `shouldBe` Right (Project ["id", "name", "age"] (Scan Nothing "users")) + it "carries required columns in the scan plan" $ do + fmap renderPlan (optimizedPlan "SELECT name FROM users") + `shouldBe` Right "Project [\"name\"]\n Scan Nothing \"users\" columns=[\"name\"]" - it "prunes the columns nobody needs on each side of a join" $ do - fmap joinSideCols (projectedPlan "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id") - `shouldBe` Right (Just ["u.name", "u.id"], Just ["o.user_id"]) + it "preserves row count when an expression needs no columns" $ do + sameResultAsUnoptimized "SELECT 1 FROM users" + + it "preserves duplicate projection labels and ordering dependencies" $ do + sameResultAsUnoptimized "SELECT name, name FROM users ORDER BY age DESC LIMIT 2" it "does not prune anything when every column is taken" $ do fmap joinSideCols (projectedPlan "SELECT * FROM users u JOIN orders o ON u.id = o.user_id") @@ -1023,31 +1240,46 @@ main = hspec $ do fmap joinSideCols (optimizedPlan "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id") `shouldBe` Right (Just ["u.name", "u.id"], Just ["o.user_id"]) - it "keeps projection pushdown idempotent" $ do - case projectedPlan "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id" of - Left err -> expectationFailure err - Right plan -> pushProject testDB ["*"] plan `shouldBe` plan - - it "keeps a join without WHERE results identical" $ do - sameResultAsUnoptimized "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id" - it "keeps projection with ORDER BY and LIMIT results identical" $ do sameResultAsUnoptimized "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id ORDER BY o.product DESC LIMIT 2" + + it "returns projected columns in the order the SELECT list asks for" $ do + rowsOf (parseStatement "SELECT age, name FROM users WHERE id = 1" >>= runStatement testDB) + `shouldBe` Right [[("age", VInt 25), ("name", VStr "Alice")]] + rowsOf (parseStatement "SELECT u.age, u.name FROM users u WHERE u.id = 1" >>= runStatement testDB) + `shouldBe` Right [[("u.age", VInt 25), ("u.name", VStr "Alice")]] + rowsOf (parseStatement "SELECT * FROM orders WHERE id = 1" >>= runStatement testDB) + `shouldBe` Right [[("id", VInt 1), ("user_id", VInt 1), ("product", VStr "Book")]] it "rewrites Filter id = k into Lookup" $ do fmap planRoot (optimizedPlan "SELECT name FROM users WHERE id = 2") - `shouldBe` Right (Lookup Nothing "users" "id" 2) + `shouldBe` Right (Lookup Nothing "users" "id" (VInt 2)) it "keeps Lookup result identical to unoptimized" $ do sameResultAsUnoptimized "SELECT name FROM users WHERE id = 2" it "rewrites an equality on any column into Lookup" $ do fmap planRoot (optimizedPlan "SELECT name FROM users WHERE age = 25") - `shouldBe` Right (Lookup Nothing "users" "age" 25) + `shouldBe` Right (Lookup Nothing "users" "age" (VInt 25)) - it "keeps a point lookup on a non-indexed column identical to unoptimized" $ do - sameResultAsUnoptimized "SELECT name FROM users WHERE age = 25" + it "rewrites a string equality into Lookup too" $ do + fmap planRoot (optimizedPlan "SELECT name FROM users WHERE name = 'Alice'") + `shouldBe` Right (Lookup Nothing "users" "name" (VStr "Alice")) + sameResultAsUnoptimized "SELECT name FROM users WHERE name = 'Alice'" - it "keeps an aliased point lookup identical to unoptimized" $ do - sameResultAsUnoptimized "SELECT u.name FROM users u WHERE u.id = 1" + it "rewrites a one-sided range into Range" $ do + fmap planRoot (optimizedPlan "SELECT name FROM users WHERE age > 20") + `shouldBe` Right (Range Nothing "users" "age" (Just (VInt 20, False)) Nothing) + fmap planRoot (optimizedPlan "SELECT name FROM users WHERE age < 20") + `shouldBe` Right (Range Nothing "users" "age" Nothing (Just (VInt 20, False))) + sameResultAsUnoptimized "SELECT name FROM users WHERE age > 20" + + it "rewrites a two-sided range into Range" $ do + fmap planRoot (optimizedPlan "SELECT name FROM users WHERE age > 20 AND age < 30") + `shouldBe` Right (Range Nothing "users" "age" (Just (VInt 20, False)) (Just (VInt 30, False))) + sameResultAsUnoptimized "SELECT name FROM users WHERE age > 20 AND age < 30" + + it "keeps a range on two different columns as a filter" $ do + fmap anyFilter (optimizedPlan "SELECT name FROM users WHERE age > 20 AND id < 3") + `shouldBe` Right True it "returns the row for an aliased point lookup" $ do rowsOf (parseStatement "SELECT u.name FROM users u WHERE u.id = 1" >>= runStatement testDB) @@ -1082,14 +1314,6 @@ main = hspec $ do (parseStatement "SELECT name FROM users WHERE age" >>= runStatement emptyDB) `shouldSatisfy` isLeft - it "rejects comparing two different types with =" $ do - (parseStatement "SELECT name FROM users WHERE name = 18" >>= runStatement testDB) - `shouldSatisfy` isLeft - - it "rejects an unknown column in INSERT" $ do - (parseStatement "INSERT INTO users (nickname) VALUES (1)" >>= runStatement testDB) - `shouldSatisfy` isLeft - it "rejects a value whose type does not match the column" $ do (parseStatement "INSERT INTO users (name, age) VALUES ('Dave', 'abc')" >>= runStatement testDB) `shouldSatisfy` isLeft @@ -1181,18 +1405,33 @@ main = hspec $ do , " Scan Just \"o\" \"orders\"" ] - it "renders a sort and limit plan" $ do - case parseStatement "SELECT name FROM users ORDER BY age DESC LIMIT 2" >>= translate of - Left err -> expectationFailure err - Right plan -> - lines (renderPlan plan) - `shouldBe` [ "Project [\"name\"]" - , " Limit 2" - , " Sort [(\"age\",Desc)]" - , " Scan Nothing \"users\"" - ] - describe "ChuSQL.Storage.IPC" $ do + it "projects scan columns and preserves constant rows over the pipe" $ do + withTestServer $ \srv -> withServerEnv srv $ do + runIPCStorage (insertMany "projected" [[("id", VInt 1), ("name", VStr "one")], [("id", VInt 2), ("name", VNull)]]) `shouldReturn` Right () + runIPCStorage (scanColumns "projected" ["name"]) `shouldReturn` Right [[("name", VStr "one")], [("name", VNull)]] + runIPCStorage (scanColumns "projected" []) `shouldReturn` Right [[], []] + let query sql = case parseStatement sql of + Left err -> expectationFailure err >> pure (Left err) + Right stmt -> runIPCStorage (runStatementM stmt) + result <- query "SELECT 1 FROM projected" + fmap (map (map snd)) result `shouldBe` Right [[VInt 1], [VInt 1]] + query "SELECT p.name FROM projected p ORDER BY p.id DESC" `shouldReturn` Right [[("p.name", VNull)], [("p.name", VStr "one")]] + + it "recreates a dropped column without resurrecting old values over the pipe" $ do + withTestServer $ \srv -> withServerEnv srv $ do + let query sql = case parseStatement sql of + Left err -> expectationFailure err >> pure (Left err) + Right stmt -> runIPCStorage (runStatementM stmt) + mapM_ (\sql -> query sql `shouldReturn` Right []) + [ "CREATE TABLE reused (id int, secret str)" + , "INSERT INTO reused (id, secret) VALUES (1, 'old')" + , "ALTER TABLE reused DROP COLUMN secret" + , "ALTER TABLE reused ADD COLUMN secret str DEFAULT 'new'" + ] + query "SELECT secret FROM reused" `shouldReturn` Right [[("secret", VStr "new")]] + query "SELECT secret FROM reused WHERE id = 1" `shouldReturn` Right [[("secret", VStr "new")]] + it "scan returns rows with all three value types after insert" $ do withTestServer $ \srv -> withServerEnv srv $ do let row = [("id", VInt 7), ("name", VStr "Alice"), ("flag", VBool True)] @@ -1225,18 +1464,40 @@ main = hspec $ do withTestServer $ \srv -> withServerEnv srv $ do let row = [("id", VInt 7), ("name", VStr "Zoe")] runIPCStorage (insert "keyed" row) `shouldReturn` Right () - found <- runIPCStorage (lookupByColumn "keyed" "id" 7) + found <- runIPCStorage (lookupByColumn "keyed" "id" (VInt 7)) fmap sortRow (indexRow found) `shouldBe` Just (sortRow row) - missing <- runIPCStorage (lookupByColumn "keyed" "id" 8) - missing `shouldBe` Right (IndexRow Nothing) + missing <- runIPCStorage (lookupByColumn "keyed" "id" (VInt 8)) + missing `shouldBe` Right (IndexRows []) + + it "looks up a string index over the pipe" $ do + withTestServer $ \srv -> withServerEnv srv $ do + runIPCStorage (insert "names" [("id", VInt 1), ("name", VStr "alice")]) `shouldReturn` Right () + runIPCStorage (insert "names" [("id", VInt 2), ("name", VStr "bob")]) `shouldReturn` Right () + runIPCStorage (createIndex "names" "name") `shouldReturn` Right () + found <- runIPCStorage (lookupByColumn "names" "name" (VStr "bob")) + fmap (lookup "id") (indexRow found) `shouldBe` Just (Just (VInt 2)) + missing <- runIPCStorage (lookupByColumn "names" "name" (VStr "dave")) + missing `shouldBe` Right (IndexRows []) + + it "range scans an indexed column over the pipe" $ do + withTestServer $ \srv -> withServerEnv srv $ do + runIPCStorage (insert "rng" [("id", VInt 1), ("code", VInt 100)]) `shouldReturn` Right () + runIPCStorage (insert "rng" [("id", VInt 2), ("code", VInt 200)]) `shouldReturn` Right () + runIPCStorage (insert "rng" [("id", VInt 3), ("code", VInt 300)]) `shouldReturn` Right () + runIPCStorage (createIndex "rng" "code") `shouldReturn` Right () + hit <- runIPCStorage (scanRange "rng" "code" (Just (VInt 200, True)) (Just (VInt 300, True))) + fmap (fmap (map (lookup "id"))) hit `shouldBe` Right (Just [Just (VInt 2), Just (VInt 3)]) + open <- runIPCStorage (scanRange "rng" "code" (Just (VInt 200, False)) (Just (VInt 300, True))) + fmap (fmap (map (lookup "id"))) open `shouldBe` Right (Just [Just (VInt 3)]) + runIPCStorage (scanRange "rng" "nope" Nothing Nothing) `shouldReturn` Right Nothing it "replaceAll rebuilds the index over the pipe" $ do withTestServer $ \srv -> withServerEnv srv $ do runIPCStorage (insert "rb" [("id", VInt 1)]) `shouldReturn` Right () runIPCStorage (replaceAll "rb" [[("id", VInt 9), ("name", VStr "Zoe")]]) `shouldReturn` Right () - old <- runIPCStorage (lookupByColumn "rb" "id" 1) - old `shouldBe` Right (IndexRow Nothing) - new <- runIPCStorage (lookupByColumn "rb" "id" 9) + old <- runIPCStorage (lookupByColumn "rb" "id" (VInt 1)) + old `shouldBe` Right (IndexRows []) + new <- runIPCStorage (lookupByColumn "rb" "id" (VInt 9)) fmap sortRow (indexRow new) `shouldBe` Just (sortRow [("id", VInt 9), ("name", VStr "Zoe")]) @@ -1274,6 +1535,12 @@ main = hspec $ do withPipeName "chusql-no-such-server" $ do result <- runIPCStorage (scan "users") result `shouldSatisfy` isLeft +#if !defined(mingw32_HOST_OS) + it "an over-long socket path is reported instead of a raw connect failure" $ do + withPipeName (replicate 128 'x') $ do + result <- runIPCStorage (scan "users") + show result `shouldSatisfy` isInfixOf "socket path too long" +#endif it "INSERT then SELECT WHERE id = k via IPC" $ do withTestServer $ \srv -> withServerEnv srv $ do _ <- @@ -1293,12 +1560,17 @@ main = hspec $ do { selectCols = ["name"] , selectFrom = FromTable Nothing "ipc_idx" , selectWhere = Just (Eq (Col "id") (LitInt 42)) + , selectGroupBy = [] , selectOrderBy = [] , selectLimit = Nothing } ) ) result `shouldBe` Right [[("name", VStr "Zoe")]] + stopServer srv + logs <- readFile (dataDir srv "debug.log") + logs `shouldContain` "lookup_by_index table=ipc_idx column=id key=42" + logs `shouldNotContain` "scan table=" it "CREATE INDEX makes a query on that column use the index" $ do withTestServer $ \srv -> withServerEnv srv $ do @@ -1309,25 +1581,25 @@ main = hspec $ do (Insert "ix_t" ["id", "code"] [[LitInt 1, LitInt 500], [LitInt 2, LitInt 501]]) ) - withoutIndex <- runIPCStorage (lookupByColumn "ix_t" "code" 501) + withoutIndex <- runIPCStorage (lookupByColumn "ix_t" "code" (VInt 501)) withoutIndex `shouldBe` Right NoIndex scanned <- runIPCStorage (runStatementM (makeSelect ["code"] "ix_t" (Just (Eq (Col "code") (LitInt 501))))) scanned `shouldBe` Right [[("code", VInt 501)]] created <- runIPCStorage (runStatementM (CreateIndex "ix_t" "code")) created `shouldBe` Right [] - withIndex <- runIPCStorage (lookupByColumn "ix_t" "code" 501) + withIndex <- runIPCStorage (lookupByColumn "ix_t" "code" (VInt 501)) fmap sortRow (indexRow withIndex) `shouldBe` Just (sortRow [("id", VInt 2), ("code", VInt 501)]) indexed <- runIPCStorage (runStatementM (makeSelect ["code"] "ix_t" (Just (Eq (Col "code") (LitInt 501))))) indexed `shouldBe` Right [[("code", VInt 501)]] _ <- runIPCStorage (runStatementM (Insert "ix_t" ["id", "code"] [[LitInt 3, LitInt 502]])) - fresh <- runIPCStorage (lookupByColumn "ix_t" "code" 502) + fresh <- runIPCStorage (lookupByColumn "ix_t" "code" (VInt 502)) fmap sortRow (indexRow fresh) `shouldBe` Just (sortRow [("id", VInt 3), ("code", VInt 502)]) - it "CREATE INDEX over the pipe rejects a column with duplicates" $ do + it "CREATE INDEX over the pipe accepts duplicates and returns every matching row" $ do withTestServer $ \srv -> withServerEnv srv $ do _ <- runIPCStorage (runStatementM (CreateTable "dup_ix" [("id", TInt), ("age", TInt)])) _ <- @@ -1336,7 +1608,11 @@ main = hspec $ do (Insert "dup_ix" ["id", "age"] [[LitInt 1, LitInt 30], [LitInt 2, LitInt 30]]) ) res <- runIPCStorage (runStatementM (CreateIndex "dup_ix" "age")) - res `shouldSatisfy` isLeft + res `shouldBe` Right [] + found <- runIPCStorage (lookupByColumn "dup_ix" "age" (VInt 30)) + case found of + Right (IndexRows rows) -> map (lookup "id") rows `shouldBe` [Just (VInt 1), Just (VInt 2)] + _ -> expectationFailure "expected an indexed lookup with rows" it "DELETE removes only the matching rows over the pipe" $ do withTestServer $ \srv -> withServerEnv srv $ do @@ -1353,8 +1629,8 @@ main = hspec $ do _ <- runIPCStorage (runStatementM (Delete "del_t" (Just (Eq (Col "id") (LitInt 2))))) rows <- runIPCStorage (runStatementM (makeSelect ["name"] "del_t" Nothing)) rows `shouldBe` Right [[("name", VStr "A")], [("name", VStr "C")]] - gone <- runIPCStorage (lookupByColumn "del_t" "id" 2) - gone `shouldBe` Right (IndexRow Nothing) + gone <- runIPCStorage (lookupByColumn "del_t" "id" (VInt 2)) + gone `shouldBe` Right (IndexRows []) it "multi-row INSERT goes over the pipe in one statement" $ do withTestServer $ \srv -> withServerEnv srv $ do @@ -1370,10 +1646,6 @@ main = hspec $ do parseStatement "CREATE TABLE users (id INT, name TEXT)" `shouldBe` Right (CreateTable "users" [("id", TInt), ("name", TStr)]) - it "parses CREATE TABLE case-insensitively" $ do - parseStatement "create table users (id int, name text)" - `shouldBe` Right (CreateTable "users" [("id", TInt), ("name", TStr)]) - it "parses CREATE TABLE with BOOL column" $ do parseStatement "CREATE TABLE t (flag BOOL)" `shouldBe` Right (CreateTable "t" [("flag", TBool)]) @@ -1400,14 +1672,6 @@ main = hspec $ do parseStatement "CREATE TABLE t (name VARCHAR)" `shouldBe` Right (CreateTable "t" [("name", TStr)]) - it "parses CREATE TABLE with INTEGER" $ do - parseStatement "CREATE TABLE t (id INTEGER)" - `shouldBe` Right (CreateTable "t" [("id", TInt)]) - - it "rejects CREATE TABLE without columns" $ do - parseStatement "CREATE TABLE t ()" - `shouldSatisfy` isLeft - it "rejects CREATE TABLE with missing type" $ do parseStatement "CREATE TABLE t (id)" `shouldSatisfy` isLeft @@ -1445,6 +1709,162 @@ main = hspec $ do it "rejects dropping a nonexistent table" $ do runStatement testDB (DropTable "nope") `shouldSatisfy` isLeft + + describe "ChuSQL.Engine (NULL)" $ do + let rows db input = parseStatement input >>= rowsOf . runStatement db + vals db input = fmap (map (map snd)) (rows db input) + seeded = either (const testDB) fst (parseStatement "INSERT INTO users (id, name) VALUES (9, 'Nine')" >>= runStatement testDB) + + it "parses IS NULL and IS NOT NULL" $ do + parseStatement "SELECT name FROM users WHERE age IS NULL" + `shouldBe` Right (makeSelect ["name"] "users" (Just (IsNull (Col "age")))) + parseStatement "SELECT name FROM users WHERE age IS NOT NULL" + `shouldBe` Right (makeSelect ["name"] "users" (Just (IsNotNull (Col "age")))) + + it "fills omitted columns with NULL" $ do + rows seeded "SELECT name FROM users WHERE age IS NULL" + `shouldBe` Right [[("name", VStr "Nine")]] + + it "IS NOT NULL drops NULL rows" $ do + vals seeded "SELECT name FROM users WHERE age IS NOT NULL" + `shouldBe` Right [[VStr "Alice"], [VStr "Bob"], [VStr "Carol"]] + + it "compares nothing with NULL" $ do + vals seeded "SELECT name FROM users WHERE age = NULL" `shouldBe` Right [] + vals seeded "SELECT name FROM users WHERE NULL IS NULL" `shouldBe` Right [[VStr "Alice"], [VStr "Bob"], [VStr "Carol"], [VStr "Nine"]] + + it "keeps NULL through three-valued AND/OR" $ do + vals seeded "SELECT name FROM users WHERE age > 18 OR age IS NULL" + `shouldBe` Right [[VStr "Alice"], [VStr "Carol"], [VStr "Nine"]] + vals seeded "SELECT name FROM users WHERE age > 18 AND age < 30" + `shouldBe` Right [[VStr "Alice"]] + + it "propagates NULL through arithmetic and projection" $ do + vals seeded "SELECT age + 1 FROM users WHERE name = 'Nine'" `shouldBe` Right [[VNull]] + vals seeded "SELECT age * 2 FROM users WHERE name = 'Nine'" `shouldBe` Right [[VNull]] + + describe "ChuSQL.Engine (TYPES)" $ do + let vals db input = fmap (map (map snd)) (parseStatement input >>= rowsOf . runStatement db) + withTable ddl = either (const testDB) fst (parseStatement ddl >>= runStatement testDB) + + it "parses every column type" $ do + parseStatement "CREATE TABLE t (a bigint, b smallint, c varchar(5), d char(2), e float, f double, g decimal(6,2), h date, i timestamp, j blob, k bool)" + `shouldBe` Right + ( CreateTable + "t" + [ ("a", plainColumn CBigInt) + , ("b", plainColumn CSmallInt) + , ("c", plainColumn (CVarchar 5)) + , ("d", plainColumn (CChar 2)) + , ("e", plainColumn CFloat) + , ("f", plainColumn CDouble) + , ("g", plainColumn (CDecimal 6 2)) + , ("h", plainColumn CDate) + , ("i", plainColumn CTimestamp) + , ("j", plainColumn CBlob) + , ("k", plainColumn CBool) + ] + ) + parseStatement "CREATE TABLE t (a nope)" `shouldSatisfy` isLeft + + it "reads and writes float literals" $ do + vals testDB "SELECT 1.5 + 1" `shouldBe` Right [[VFloat 2.5]] + vals testDB "SELECT 1 / 2, 1.0 / 2" `shouldBe` Right [[VInt 0, VFloat 0.5]] + + it "stores dates, timestamps and blobs as validated strings" $ do + let db = withTable "CREATE TABLE typed (id int, d date, ts timestamp, p blob)" + case parseStatement "INSERT INTO typed (id, d, ts, p) VALUES (1, DATE '2024-01-02', TIMESTAMP '2024-01-02 03:04:05', X'4869')" >>= runStatement db of + Left e -> expectationFailure e + Right (db', _) -> do + vals db' "SELECT d FROM typed" `shouldBe` Right [[VStr "2024-01-02"]] + vals db' "SELECT p FROM typed" `shouldBe` Right [[VStr "4869"]] + runStatement db (Insert "typed" ["d"] [[LitStr "not-a-date"]]) + `shouldSatisfy` either (isInfixOf "date") (const False) + + it "rejects values that do not fit the column" $ do + let db = withTable "CREATE TABLE sized (id int, code varchar(3), d date)" + runStatement db (Insert "sized" ["code"] [[LitStr "toolong"]]) + `shouldSatisfy` either (isInfixOf "longer") (const False) + runStatement db (Insert "sized" ["d"] [[LitInt 5]]) + `shouldSatisfy` isLeft + + describe "ChuSQL.Engine (CONSTRAINTS)" $ do + let vals db input = fmap (map (map snd)) (parseStatement input >>= rowsOf . runStatement db) + withTable ddl = either (const testDB) fst (parseStatement ddl >>= runStatement testDB) + step db input = either (const db) fst (parseStatement input >>= runStatement db) + + it "rejects omitting a NOT NULL column without a default" $ do + let db = withTable "CREATE TABLE nn (id int NOT NULL, name str)" + runStatement db (Insert "nn" ["name"] [[LitStr "x"]]) + `shouldSatisfy` either (isInfixOf "NOT NULL") (const False) + runStatement db (Insert "nn" ["id"] [[LitNull]]) + `shouldSatisfy` either (isInfixOf "NOT NULL") (const False) + + it "fills the DEFAULT when a column is omitted" $ do + let db = withTable "CREATE TABLE dft (id int, tag str DEFAULT 'x')" + vals (step db "INSERT INTO dft (id) VALUES (1)") "SELECT tag FROM dft" `shouldBe` Right [[VStr "x"]] + + it "assigns AUTO_INCREMENT values" $ do + let db = step (withTable "CREATE TABLE ai (id int AUTO_INCREMENT PRIMARY KEY, name str)") "INSERT INTO ai (name) VALUES ('a'), ('b')" + vals db "SELECT id FROM ai" `shouldBe` Right [[VInt 1], [VInt 2]] + + it "rejects duplicates in a UNIQUE column" $ do + let db = step (withTable "CREATE TABLE uq (id int UNIQUE, name str)") "INSERT INTO uq (id, name) VALUES (1, 'a')" + runStatement db (Insert "uq" ["id", "name"] [[LitInt 1, LitStr "b"]]) + `shouldSatisfy` either (isInfixOf "UNIQUE") (const False) + + it "enforces CHECK constraints" $ do + let db = withTable "CREATE TABLE ck (age int CHECK (age > 0))" + runStatement db (Insert "ck" ["age"] [[LitInt (-1)]]) + `shouldSatisfy` either (isInfixOf "CHECK") (const False) + vals (step db "INSERT INTO ck (age) VALUES (5)") "SELECT age FROM ck" `shouldBe` Right [[VInt 5]] + + describe "ChuSQL.Engine (ALTER TABLE)" $ do + let vals db input = fmap (map (map snd)) (parseStatement input >>= rowsOf . runStatement db) + run db input = parseStatement input >>= runStatement db + withTable ddl = either (const testDB) fst (parseStatement ddl >>= runStatement testDB) + step db input = either (const db) fst (run db input) + + it "ADD COLUMN fills existing rows with the default or NULL" $ do + let seeded = step (withTable "CREATE TABLE ac (id int)") "INSERT INTO ac (id) VALUES (1)" + withDefault = step seeded "ALTER TABLE ac ADD COLUMN tag str DEFAULT 'n'" + withNull = step seeded "ALTER TABLE ac ADD COLUMN note str" + vals withDefault "SELECT tag FROM ac" `shouldBe` Right [[VStr "n"]] + vals withNull "SELECT note FROM ac" `shouldBe` Right [[VNull]] + + it "rejects ADD COLUMN NOT NULL without a default on a non-empty table" $ do + let seeded = step (withTable "CREATE TABLE ac2 (id int)") "INSERT INTO ac2 (id) VALUES (1)" + run seeded "ALTER TABLE ac2 ADD COLUMN req int NOT NULL" `shouldSatisfy` isLeft + vals (step (withTable "CREATE TABLE ac3 (id int)") "ALTER TABLE ac3 ADD COLUMN req int NOT NULL") "SELECT * FROM ac3" `shouldBe` Right [] + + it "RENAME COLUMN moves the data to the new name" $ do + let seeded = step (withTable "CREATE TABLE rn (id int, name str)") "INSERT INTO rn (id, name) VALUES (1, 'a')" + renamed = step seeded "ALTER TABLE rn RENAME COLUMN name TO label" + vals renamed "SELECT label FROM rn" `shouldBe` Right [[VStr "a"]] + run renamed "SELECT name FROM rn" `shouldSatisfy` isLeft + run seeded "ALTER TABLE rn RENAME COLUMN nope TO other" `shouldSatisfy` isLeft + + it "ALTER COLUMN TYPE re-reads the stored values" $ do + let seeded = step (withTable "CREATE TABLE ty (id int)") "INSERT INTO ty (id) VALUES (7)" + widened = step seeded "ALTER TABLE ty ALTER COLUMN id TYPE bigint" + vals widened "SELECT id FROM ty" `shouldBe` Right [[VInt 7]] + let texty = step (withTable "CREATE TABLE ty2 (name str)") "INSERT INTO ty2 (name) VALUES ('x')" + run texty "ALTER TABLE ty2 ALTER COLUMN name TYPE int" `shouldSatisfy` isLeft + + it "ALTER COLUMN SET/DROP DEFAULT changes what omitted columns get" $ do + let base = withTable "CREATE TABLE df (id int, tag str)" + withDefault = step base "ALTER TABLE df ALTER COLUMN tag SET DEFAULT 'y'" + dropped = step withDefault "ALTER TABLE df ALTER COLUMN tag DROP DEFAULT" + vals (step withDefault "INSERT INTO df (id) VALUES (1)") "SELECT tag FROM df" `shouldBe` Right [[VStr "y"]] + vals (step dropped "INSERT INTO df (id) VALUES (2)") "SELECT tag FROM df" `shouldBe` Right [[VNull]] + + it "ALTER COLUMN SET/DROP NOT NULL checks the stored rows" $ do + let nulls = step (withTable "CREATE TABLE nl (id int, tag str)") "INSERT INTO nl (id) VALUES (1)" + strict = step (withTable "CREATE TABLE nl2 (id int, tag str NOT NULL)") "INSERT INTO nl2 (id, tag) VALUES (1, 'a')" + run nulls "ALTER TABLE nl ALTER COLUMN tag SET NOT NULL" `shouldSatisfy` isLeft + run nulls "ALTER TABLE nl ALTER COLUMN tag DROP NOT NULL" `shouldSatisfy` isRight + run strict "ALTER TABLE nl2 ALTER COLUMN tag DROP NOT NULL" `shouldSatisfy` isRight + emptyDB :: Database emptyDB = [(n, t{tableRows = []}) | (n, t) <- testDB] @@ -1470,17 +1890,25 @@ locateServer = do case ec of ExitFailure n -> pure (Left ("cargo build --bin chusql-storage failed with exit code " ++ show n)) ExitSuccess -> do - found <- firstExisting [dir "target" "debug" "chusql-storage.exe", dir "target" "debug" "chusql-storage"] - pure (maybe (Left "cargo build finished but no storage executable was found") Right found) + -- 二进制名跟平台走:Linux 上没有 .exe,而 target/debug 里可能 + -- 残留一份别处交叉编译出来的 .exe。误选它会以 Windows 的视角 + -- 读配置(/tmp/x.toml 变成 C:\tmp\x.toml),报 config file not found。 +#if defined(mingw32_HOST_OS) + let candidate = dir "target" "debug" "chusql-storage.exe" +#else + let candidate = dir "target" "debug" "chusql-storage" +#endif + ok <- doesFileExist candidate + pure + ( if ok + then Right candidate + else Left ("cargo build finished but no storage executable was found at " ++ candidate) + ) where firstDir [] = pure Nothing firstDir (d : ds) = do ok <- doesDirectoryExist d if ok then pure (Just d) else firstDir ds - firstExisting [] = pure Nothing - firstExisting (p : ps) = do - ok <- doesFileExist p - if ok then pure (Just p) else firstExisting ps startServer :: FilePath -> IO IPCServer startServer bin = do @@ -1494,18 +1922,36 @@ startServerAt :: FilePath -> FilePath -> IO IPCServer startServerAt bin dir = do stamp <- uniqueStamp let name = "chusql-hs-test-" ++ stamp - parentEnv <- getEnvironment - (_, _, _, ph) <- + cfgPath <- writeServerConfig dir name + (_, _, _, ph) <- withFile (dir "debug.log") WriteMode $ \logHandle -> createProcess - (proc bin []) - { env = Just (("CHUSQL_PIPE", name) : ("CHUSQL_DATA_DIR", dir) : parentEnv) - , std_out = NoStream - , std_err = NoStream + (proc bin ["--config", cfgPath]) + { std_out = UseHandle logHandle + , std_err = UseHandle logHandle } let srv = IPCServer name dir ph waitUntilReady srv 100 pure srv +-- | 给存储进程写一份顺手可用的配置:管名、数据目录与日志级别都从文件走 +writeServerConfig :: FilePath -> String -> IO FilePath +writeServerConfig dir name = do + let cfgPath = dir ++ ".toml" + -- TOML 的普通字符串会吃反斜杠,路径统一用正斜杠 + slashed = map (\c -> if c == '\\' then '/' else c) dir + writeFile + cfgPath + ( unlines + [ "[storage]" + , "data_dir = \"" ++ slashed ++ "\"" + , "[server]" + , "pipe_name = \"" ++ name ++ "\"" + , "[log]" + , "level = \"debug\"" + ] + ) + pure cfgPath + -- | 每次调用都不同的后缀:CPU 时间加唯一编号 uniqueStamp :: IO String uniqueStamp = do @@ -1519,7 +1965,7 @@ waitUntilReady srv tries = do case probe of Right RespPong -> pure () _ | tries > 0 -> threadDelay 50000 >> waitUntilReady srv (tries - 1) - Right _ -> ioError (userError "server is up but ping did not answer pong") + Right _ -> ioError (userError "server is up but the ping answer was not pong") Left e -> ioError e stopServer :: IPCServer -> IO () @@ -1531,6 +1977,7 @@ stopServer srv = do cleanServerDir :: IPCServer -> IO () cleanServerDir srv = do _ <- try (removePathForcibly (dataDir srv)) :: IO (Either IOException ()) + _ <- try (removePathForcibly (dataDir srv ++ ".toml")) :: IO (Either IOException ()) pure () withTestServer :: (IPCServer -> IO ()) -> IO () @@ -1544,22 +1991,16 @@ withTestServer act = do withServerEnv :: IPCServer -> IO a -> IO a withServerEnv srv act = do - oldPipe <- lookupEnv "CHUSQL_PIPE" - oldDir <- lookupEnv "CHUSQL_DATA_DIR" - setEnv "CHUSQL_PIPE" (pipeName srv) - setEnv "CHUSQL_DATA_DIR" (dataDir srv) - act `finally` do - restore "CHUSQL_PIPE" oldPipe - restore "CHUSQL_DATA_DIR" oldDir - where - restore k = maybe (unsetEnv k) (setEnv k) + old <- getPipeName + setPipeName (pipeName srv) + act `finally` setPipeName old withPipeName :: String -> IO a -> IO a withPipeName name act = do - old <- lookupEnv "CHUSQL_PIPE" - setEnv "CHUSQL_PIPE" name + old <- getPipeName + setPipeName name r <- act - maybe (unsetEnv "CHUSQL_PIPE") (setEnv "CHUSQL_PIPE") old + setPipeName old pure r sortRow :: Row -> Row @@ -1567,7 +2008,7 @@ sortRow = sortOn fst -- | 取出索引点查返回的那一行,取不到给 Nothing indexRow :: Either String IndexResult -> Maybe Row -indexRow (Right (IndexRow r)) = r +indexRow (Right (IndexRows (r : _))) = Just r indexRow _ = Nothing isLeft :: Either a b -> Bool @@ -1580,7 +2021,7 @@ isRight (Left _) = False sameResultAsUnoptimized :: String -> Expectation sameResultAsUnoptimized sql = - case parseStatement sql of + case parseStatement sql >>= prepare testDB of Left err -> expectationFailure err Right q -> case translate q of @@ -1589,42 +2030,72 @@ sameResultAsUnoptimized sql = evalRelOp testDB (optimize testDB relOp) `shouldBe` evalRelOp testDB relOp filterPushedIntoLeft :: RelOp -> Bool -filterPushedIntoLeft (Project _ (Join (Filter _ _) _ _)) = True +filterPushedIntoLeft (Project _ (Join _ l _ _)) = pushedIntoLeft l filterPushedIntoLeft _ = False +-- | 左侧子树里出现了下推下来的谓词(点查 / 范围 / 过滤) +pushedIntoLeft :: RelOp -> Bool +pushedIntoLeft (Filter _ _) = True +pushedIntoLeft (Range _ _ _ _ _) = True +pushedIntoLeft (Project _ x) = pushedIntoLeft x +pushedIntoLeft _ = False + optimizedPlan :: String -> Either String RelOp optimizedPlan sql = do q <- parseStatement sql optimize testDB <$> translate q +-- | 计划里每个 Join 节点是不是 LEFT JOIN(先序) +leftJoinKinds :: RelOp -> [Bool] +leftJoinKinds (Join kind l r _) = (kind == LeftJoin) : (leftJoinKinds l ++ leftJoinKinds r) +leftJoinKinds (Project _ x) = leftJoinKinds x +leftJoinKinds (Compute _ x) = leftJoinKinds x +leftJoinKinds (Aggregate _ _ x) = leftJoinKinds x +leftJoinKinds (Filter _ x) = leftJoinKinds x +leftJoinKinds (Sort _ x) = leftJoinKinds x +leftJoinKinds (Limit _ x) = leftJoinKinds x +leftJoinKinds _ = [] + firstFilterCond :: RelOp -> Maybe Expr firstFilterCond (Filter p _) = Just p firstFilterCond (Project _ x) = firstFilterCond x +firstFilterCond (Compute _ x) = firstFilterCond x +firstFilterCond (Aggregate _ _ x) = firstFilterCond x +firstFilterCond Unit = Nothing firstFilterCond (Sort _ x) = firstFilterCond x firstFilterCond (Limit _ x) = firstFilterCond x -firstFilterCond (Join l r _) = case firstFilterCond l of +firstFilterCond (Join _ l r _) = case firstFilterCond l of Just p -> Just p Nothing -> firstFilterCond r -firstFilterCond (Scan _ _) = Nothing +firstFilterCond (Scan _ _ _) = Nothing firstFilterCond (Lookup _ _ _ _) = Nothing +firstFilterCond (Range _ _ _ _ _) = Nothing anyFilter :: RelOp -> Bool anyFilter (Filter _ _) = True anyFilter (Project _ x) = anyFilter x +anyFilter (Compute _ x) = anyFilter x +anyFilter (Aggregate _ _ x) = anyFilter x +anyFilter Unit = False anyFilter (Sort _ x) = anyFilter x anyFilter (Limit _ x) = anyFilter x -anyFilter (Join l r _) = anyFilter l || anyFilter r -anyFilter (Scan _ _) = False +anyFilter (Join _ l r _) = anyFilter l || anyFilter r +anyFilter (Scan _ _ _) = False anyFilter (Lookup _ _ _ _) = False +anyFilter (Range _ _ _ _ _) = False stripProjects :: RelOp -> RelOp stripProjects (Project _ x) = stripProjects x +stripProjects (Compute items x) = Compute items (stripProjects x) +stripProjects (Aggregate keys aggs x) = Aggregate keys aggs (stripProjects x) +stripProjects Unit = Unit stripProjects (Filter p x) = Filter p (stripProjects x) stripProjects (Sort spec x) = Sort spec (stripProjects x) stripProjects (Limit n x) = Limit n (stripProjects x) -stripProjects (Join l r c) = Join (stripProjects l) (stripProjects r) c -stripProjects (Scan a t) = Scan a t +stripProjects (Join k l r c) = Join k (stripProjects l) (stripProjects r) c +stripProjects (Scan a t _) = Scan a t Nothing stripProjects (Lookup a t c k) = Lookup a t c k +stripProjects (Range a t c lo hi) = Range a t c lo hi projectedPlan :: String -> Either String RelOp projectedPlan sql = do @@ -1634,10 +2105,11 @@ projectedPlan sql = do sideCols :: RelOp -> Maybe [String] sideCols (Project cols _) = Just cols +sideCols (Scan a _ cols) = fmap (map (qualify a)) cols sideCols _ = Nothing joinSideCols :: RelOp -> (Maybe [String], Maybe [String]) -joinSideCols (Project _ (Join l r _)) = (sideCols l, sideCols r) +joinSideCols (Project _ (Join _ l r _)) = (sideCols l, sideCols r) joinSideCols _ = (Nothing, Nothing) countFilters :: RelOp -> Int @@ -1645,7 +2117,7 @@ countFilters (Filter _ x) = 1 + countFilters x countFilters _ = 0 leftSideFilters :: RelOp -> Maybe Int -leftSideFilters (Project _ (Join l _ _)) = Just (countFilters l) +leftSideFilters (Project _ (Join _ l _ _)) = Just (countFilters l) leftSideFilters _ = Nothing planRoot :: RelOp -> RelOp diff --git a/chusql-storage/chusql-storage.toml.example b/chusql-storage/chusql-storage.toml.example index 80df6ab..a45fb40 100644 --- a/chusql-storage/chusql-storage.toml.example +++ b/chusql-storage/chusql-storage.toml.example @@ -1,40 +1,42 @@ # ChuSQL 存储服务配置示例 # -# 用法:把本文件复制成 chusql-storage.toml(放在启动 server 时的当前目录下), -# 或者让环境变量 CHUSQL_CONFIG 指向任意路径的这个文件。 +# 用法:安装时把本文件放到固定的用户配置目录(Windows 是 %APPDATA%\ChuSQL\chusql.toml, +# 其它平台是 ${XDG_CONFIG_HOME:-$HOME/.config}/ChuSQL/chusql.toml); +# 临时换一份就用命令行参数 --config (没有环境变量通道了)。 # # 每一项都可以删掉:没写的项回退到内置默认值(下面注释里的值就是默认值)。 -# 优先级:环境变量 > 本文件 > 内置默认值。 +# 优先级:本文件 > 内置默认值(没有配置键走环境变量了)。 # 启动时 server 会把"读了哪个文件、每个选项从哪来"打进日志([INFO] 开头那几行)。 [page] # 页大小(字节):512 ~ 65536 之间的 2 的幂。 # 注意:改它会让已有数据文件(.db)读不出来(文件长度对不上页大小会直接报错)。 -# 环境变量:CHUSQL_PAGE_SIZE size = 4096 [btree] # B+ 树一个节点最多几个孩子(>= 3,且必须能放进一页:12 + (order-1)*8 + order*8 <= size)。 # 注意:改它会让已有索引文件(.idx)对不上文件头里记的值,打开时报错。 -# 环境变量:CHUSQL_BTREE_ORDER order = 4 +[buffer] +# 缓冲池里最多放几页(>= 1)。 +pool_size = 1024 + [storage] # 数据目录:一张表一个 <表名>.db,一个索引一个 <表名>.idx。 -# 环境变量:CHUSQL_DATA_DIR -data_dir = "../localdata" +# 不写就跟随平台惯例(Windows %LOCALAPPDATA%\ChuSQL\data, +# Unix ${XDG_DATA_HOME:-$HOME/.local/share}/chusql/data,跟安装脚本装的位置一致); +# 要固定就写绝对路径,例如: +# data_dir = "/srv/chusql/data" [server] # 命名管道名(Windows 上实际是 \\.\pipe\<这个名字>)。 -# 环境变量:CHUSQL_PIPE -pipe_name = "chusql-storage" +pipe_name = "chusql-joint" [log] # 日志等级:off / error / warn / info / debug。debug 会多打每个请求和连接。 -# 环境变量:CHUSQL_LOG level = "info" -# 颜色不用写在文件里,用环境变量控制:CHUSQL_LOG_COLOR = auto(默认,stderr 是终端才着色) -# / always / never +# 颜色没有配置项也没有环境变量:要写的那条流是终端就着色,重定向到文件就不着色。 # 行格式:[等级 本地日期时间] [来源] 消息;来源 core=服务自身、pipe=由管道请求触发 # ERROR 加粗红、WARN 加粗橙、DEBUG 加粗不变色、INFO 不加样式 diff --git a/chusql-storage/src/bin/chusql-storage.rs b/chusql-storage/src/bin/chusql-storage.rs index 81cbbeb..6764567 100644 --- a/chusql-storage/src/bin/chusql-storage.rs +++ b/chusql-storage/src/bin/chusql-storage.rs @@ -1,23 +1,26 @@ -use std::collections::{BTreeSet, HashMap}; +use std::collections::HashMap; use std::io::{BufRead, BufReader, Write}; use std::path::PathBuf; use std::sync::{Arc, Mutex}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::thread; use chusql_storage::catalog::Catalog; use chusql_storage::config::{self, Config, Loaded}; +use chusql_storage::endpoint; use chusql_storage::heap::HeapTable; +use chusql_storage::initsql::{self, InitColumn, InitTable}; use chusql_storage::log; use chusql_storage::protocol::{ - ColumnStatWire, IndexWire, Request, Response, Row, SchemaColumn, TableSchemaWire, + reserved_table, Account, ColumnStatWire, IndexWire, Request, Response, Row, SchemaColumn, TableSchemaWire, USERS_TABLE, }; use chusql_storage::wal::{Wal, WalOp}; use chusql_storage::{log_debug, log_error, log_info, log_warn}; -use interprocess::local_socket::{prelude::*, GenericNamespaced, ListenerOptions}; +use interprocess::local_socket::prelude::*; use interprocess::TryClone; -// 命名管道服务:按行读写 JSON,Windows 上映射成 \\.\pipe\。 -// 启动时读配置并把每项来源打进日志;表句柄按名字缓存复用。 +// 本地套接字服务:按行读写 JSON。端点地址由 endpoint 模块给出: +// Windows 是具名管道 \\.\pipe\,Unix 是文件系统套接字 /.sock。 struct Server { cfg: Config, @@ -25,8 +28,11 @@ struct Server { write_lock: Mutex<()>, catalog: Mutex, wal: Wal, + recovery_required: AtomicBool, } +type TableDescription = (Vec, u64, Vec, Vec); + impl Server { /// 新建服务器状态 fn new(cfg: Config) -> std::io::Result { @@ -39,6 +45,7 @@ impl Server { write_lock: Mutex::new(()), catalog: Mutex::new(catalog), wal, + recovery_required: AtomicBool::new(false), }) } @@ -63,19 +70,10 @@ impl Server { self.cfg.data_dir.join("catalog.json") } - /// 表文件存在且非空 - fn table_exists(&self, table: &str) -> bool { - if self.tables.lock().unwrap().contains_key(table) { - return true; - } - if let Ok(c) = Catalog::load(self.catalog_path()) { - if c.describe(table).is_some() { - return true; - } - } - std::fs::metadata(self.table_path(table)) - .map(|m| m.len() > 0) - .unwrap_or(false) + /// 仅数据字典决定表是否存在 + fn table_exists(&self, table: &str) -> std::io::Result { + let catalog = self.catalog.lock().map_err(|_| std::io::Error::other("catalog lock poisoned"))?; + Ok(catalog.describe(table).is_some()) } /// 一张表该开哪些索引 @@ -98,6 +96,8 @@ impl Server { return Ok(h.clone()); } let columns = self.index_columns(table); + let hidden = self.catalog.lock().map_err(|_| std::io::Error::other("catalog lock poisoned"))? + .describe(table).map(|s| s.dropped_columns.iter().cloned().collect()).unwrap_or_default(); let paths: Vec<(String, PathBuf)> = columns .iter() .map(|c| (c.clone(), self.index_path(table, c))) @@ -108,13 +108,14 @@ impl Server { return Ok(h.clone()); } std::fs::create_dir_all(&self.cfg.data_dir)?; - let t = HeapTable::open_with_indexes( + let mut t = HeapTable::open_with_indexes( self.table_path(table), &paths, self.cfg.page_size, self.cfg.btree_order, self.cfg.pool_size, )?; + t.set_hidden_columns(hidden); let h = Arc::new(Mutex::new(t)); map.insert(table.to_string(), h.clone()); Ok(h) @@ -137,8 +138,7 @@ impl Server { table: &str, f: impl FnOnce(&mut HeapTable) -> std::io::Result, ) -> std::io::Result { - let cached = self.tables.lock().unwrap().contains_key(table); - if !cached && !self.table_exists(table) { + if !self.table_exists(table)? { return Err(std::io::Error::new( std::io::ErrorKind::NotFound, format!("unknown table: {}", table), @@ -150,6 +150,41 @@ impl Server { /// 把一条 WAL 操作落到磁盘 fn apply_op(&self, op: &WalOp) -> std::io::Result<()> { match op { + WalOp::HideColumn { table, column } => { + let mut c = self.catalog.lock().map_err(|_| std::io::Error::other("catalog lock poisoned"))?; + c.remove_column(table, column)?; + c.save(self.catalog_path())?; + drop(c); + if let Some(handle) = self.tables.lock().map_err(|_| std::io::Error::other("table lock poisoned"))?.get(table) { + handle.lock().map_err(|_| std::io::Error::other("heap lock poisoned"))?.hide_column(column); + } + match std::fs::remove_file(self.index_path(table, column)) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + } + } + WalOp::Compact { table, rows } => { + self.with_table(table, |t| { + t.replace_all(rows)?; + t.flush() + })?; + let mut c = self.catalog.lock().unwrap(); + c.mark_compacted(table)?; + c.rebuild_stats(table, rows); + c.save(self.catalog_path()) + } + WalOp::Accounts { accounts } => { + let rows = account_rows(accounts)?; + self.tables.lock().map_err(|_| std::io::Error::other("table lock poisoned"))?.remove(USERS_TABLE); + for path in [self.table_path(USERS_TABLE), self.index_path(USERS_TABLE, "id")] { + std::fs::File::create(path)?.sync_all()?; + } + self.with_table(USERS_TABLE, |t| { t.replace_all(&rows)?; t.flush() })?; + let mut c = self.catalog.lock().map_err(|_| std::io::Error::other("catalog lock poisoned"))?; + declare_system_schema(&mut c, &rows)?; + c.save(self.catalog_path()) + } WalOp::Insert { table, row } => { self.with_table(table, |t| t.insert_row(row))?; let mut c = self.catalog.lock().unwrap(); @@ -197,12 +232,59 @@ impl Server { let _ = std::fs::remove_file(self.index_path(table, column)); Ok(()) } + WalOp::ReplaceSchema { table, columns, rows } => { + let old_indexes: Vec = { + let c = self.catalog.lock().map_err(|_| std::io::Error::other("catalog lock poisoned"))?; + c.describe(table) + .map(|s| s.indexes.iter().map(|i| i.column.clone()).collect()) + .unwrap_or_default() + }; + self.with_table(table, |t| { + for col in &old_indexes { + t.detach_index(col); + } + t.set_hidden_columns(Default::default()); + t.replace_all(rows) + })?; + let mut c = self.catalog.lock().unwrap(); + c.set_columns(table, columns.clone())?; + c.rebuild_stats(table, rows); + c.save(self.catalog_path())?; + drop(c); + for col in old_indexes { + let _ = std::fs::remove_file(self.index_path(table, &col)); + } + Ok(()) + } } } /// 先写 WAL 再改数据 fn write_via_wal(&self, op: &WalOp) -> std::io::Result<()> { let _guard = self.write_lock.lock().unwrap(); + if self.recovery_required.load(Ordering::Acquire) { + return Err(std::io::Error::other("storage recovery required")); + } + { + let c = self.catalog.lock().map_err(|_| std::io::Error::other("catalog lock poisoned"))?; + let validate = |table: &str, rows: &[Row]| -> std::io::Result<()> { + let Some(schema) = c.describe(table) else { return Ok(()); }; + if let Some(name) = rows.iter().flat_map(|r| r.keys()).find(|k| schema.dropped_columns.contains(*k)) { + return Err(std::io::Error::other(format!("unknown column: {name}"))); + } + Ok(()) + }; + match op { + WalOp::Insert { table, row } => validate(table, std::slice::from_ref(row))?, + WalOp::InsertBatch { table, rows } | WalOp::ReplaceAll { table, rows } => validate(table, rows)?, + WalOp::HideColumn { table, column } + if !c.describe(table).is_some_and(|s| s.columns.iter().any(|col| &col.name == column)) => + { + return Err(std::io::Error::other(format!("unknown column: {column}"))); + } + _ => {} + } + } std::fs::create_dir_all(&self.cfg.data_dir)?; let wal = &self.wal; @@ -212,10 +294,13 @@ impl Server { let result = self.apply_op(op); + if result.is_err() && matches!(op, WalOp::HideColumn { .. }) { + self.recovery_required.store(true, Ordering::Release); + return result; + } + if result.is_ok() { - if let Err(e) = self.flush_op_tables(op) { - return Err(e); - } + self.flush_op_tables(op)?; } wal.clear()?; @@ -225,11 +310,15 @@ impl Server { /// 把涉及的表写回磁盘 fn flush_op_tables(&self, op: &WalOp) -> std::io::Result<()> { let table = match op { + WalOp::HideColumn { .. } => return Ok(()), + WalOp::Accounts { .. } => USERS_TABLE, WalOp::Insert { table, .. } | WalOp::InsertBatch { table, .. } | WalOp::DeleteKeys { table, .. } | WalOp::ReplaceAll { table, .. } - | WalOp::DropColumn { table, .. } => table, + | WalOp::DropColumn { table, .. } + | WalOp::Compact { table, .. } + | WalOp::ReplaceSchema { table, .. } => table, }; self.with_table(table, |t| t.flush()) } @@ -243,6 +332,7 @@ impl Server { Some(op) => { log_warn!(core, "replaying incomplete WAL entry"); self.apply_op(&op)?; + self.flush_op_tables(&op)?; wal.clear()?; log_info!(core, "WAL replay complete"); Ok(()) @@ -270,9 +360,55 @@ impl Server { Ok(()) } + /// 旧类型系统留下的数据升级到新格式:列类型名规范化、缺列的行补 null + fn migrate_data(&self) -> std::io::Result<()> { + let names: Vec = self + .catalog + .lock() + .map_err(|_| std::io::Error::other("catalog lock poisoned"))? + .table_names(); + for table in names { + if !self.table_path(&table).exists() { + continue; + } + let columns: Vec = match self + .catalog + .lock() + .map_err(|_| std::io::Error::other("catalog lock poisoned"))? + .describe(&table) + { + Some(schema) => schema.columns.clone(), + None => continue, + }; + let rows = self.with_existing_table(&table, |t| t.scan())?; + if rows.iter().all(|row| columns.iter().all(|c| row.contains_key(&c.name))) { + continue; + } + let filled: Vec = rows + .into_iter() + .map(|mut row| { + for column in &columns { + row.entry(column.name.clone()).or_insert(serde_json::Value::Null); + } + row + }) + .collect(); + log_info!(core, "migrating {}: {} rows", table, filled.len()); + let op = WalOp::ReplaceAll { table, rows: filled }; + self.write_via_wal(&op)?; + } + let mut catalog = self + .catalog + .lock() + .map_err(|_| std::io::Error::other("catalog lock poisoned"))?; + if catalog.normalize_types() { + catalog.save(self.catalog_path())?; + } + Ok(()) + } + /// 给一列建索引 - fn create_index(&self, table: &str, column: &str) -> std::io::Result<()> { - if column == "id" { + fn create_index(&self, table: &str, column: &str) -> std::io::Result<()> { if column == "id" { return Err(std::io::Error::new( std::io::ErrorKind::AlreadyExists, "id is the built-in index and cannot be created twice", @@ -303,7 +439,8 @@ impl Server { let path = self.index_path(table, column); let (page_size, order, pool) = (self.cfg.page_size, self.cfg.btree_order, self.cfg.pool_size); self.with_table(table, |t| { - t.build_index(column, &path, page_size, order, pool) + t.build_index(column, &path, page_size, order, pool)?; + t.flush() })?; let mut c = self.catalog.lock().unwrap(); @@ -342,7 +479,7 @@ impl Server { Ok(()) } - /// 删一列:定义、索引、行里的字段 + /// 删除列定义并隐藏旧字段,不改写堆页。 fn drop_column(&self, table: &str, column: &str) -> std::io::Result<()> { if column == "id" { return Err(std::io::Error::new( @@ -366,36 +503,169 @@ impl Server { } } - let rows: Vec = self - .with_existing_table(table, |t| t.scan())? - .into_iter() - .map(|row| { - row.into_iter() - .filter(|(k, _)| k != column) - .collect::() - }) - .collect(); - - let op = WalOp::DropColumn { + let op = WalOp::HideColumn { table: table.to_string(), column: column.to_string(), + }; + self.write_via_wal(&op) + } + + /// 整理一张表:按当前列定义重写堆页,回收隐藏列占的空间 + fn compact_table(&self, table: &str) -> std::io::Result<()> { + let rows = self.with_existing_table(table, |t| t.scan())?; + let op = WalOp::Compact { + table: table.to_string(), rows, }; self.write_via_wal(&op) } + /// 启动时整理:把还留着隐藏列的表逐张重写 + fn compact_tables(&self) -> std::io::Result<()> { + let tables = { + let c = self.catalog.lock().map_err(|_| std::io::Error::other("catalog lock poisoned"))?; + c.dropped_tables() + }; + for table in tables { + log_info!(core, "compacting table {}", table); + self.compact_table(&table)?; + } + Ok(()) + } + + /// 账号读写共享串行持久化边界 + fn accounts_request(&self, request: Request) -> std::io::Result> { + use std::io::{Error, ErrorKind}; + let _guard = self.write_lock.lock().map_err(|_| Error::other("write lock poisoned"))?; + if self.recovery_required.load(Ordering::Acquire) { + return Err(Error::other("storage recovery required")); + } + let initialized = { + let c = self.catalog.lock().map_err(|_| Error::other("catalog lock poisoned"))?; + match c.describe(USERS_TABLE) { + Some(schema) if schema.system => true, + Some(_) => return Err(Error::new(ErrorKind::AlreadyExists, "reserved account table collision")), + None => false, + } + }; + if !initialized && (self.table_path(USERS_TABLE).exists() || self.index_path(USERS_TABLE, "id").exists()) { + return Err(Error::new(ErrorKind::AlreadyExists, "reserved account file collision")); + } + let mut accounts: Vec = if initialized { + if !self.table_path(USERS_TABLE).exists() { + return Err(Error::other("missing account data")); + } + let mut loaded: Vec = self + .with_existing_table(USERS_TABLE, |t| t.scan())? + .iter() + .map(decode_account_row) + .collect::>()?; + for account in loaded.iter_mut() { + normalize_account(account); + } + loaded + } else { Vec::new() }; + match request { + Request::AccountsList => return Ok(accounts), + Request::AccountCreate { user, password_hash } => { + let name = account_name(&user)?; + if accounts.iter().any(|a| a.user == name) { + return Err(Error::new(ErrorKind::AlreadyExists, "account already exists")); + } + let id = accounts.iter().map(|a| a.id).max().unwrap_or(0) + .checked_add(1).ok_or_else(|| Error::other("account id exhausted"))?; + accounts.push(new_account(id, &name, password_hash)?); + } + Request::AccountReset { user, password_hash } => { + let name = account_name(&user)?; + validate_account_hash(&password_hash)?; + let account = accounts.iter_mut().find(|a| a.user == name) + .ok_or_else(|| Error::new(ErrorKind::NotFound, "unknown account"))?; + account.revision = account.revision.checked_add(1).ok_or_else(|| Error::other("account revision exhausted"))?; + account.password_hash = password_hash; + } + Request::AccountLogin { user, at } => { + let name = account_name(&user)?; + let stamp = at.unwrap_or_else(now_stamp); + let account = accounts.iter_mut().find(|a| a.user == name) + .ok_or_else(|| Error::new(ErrorKind::NotFound, "unknown account"))?; + account.last_login_at = Some(stamp); + } + Request::AccountDrop { user } => { + let name = account_name(&user)?; + let before = accounts.len(); + accounts.retain(|a| a.user != name); + if accounts.len() == before { + return Err(Error::new(ErrorKind::NotFound, "unknown account")); + } + } + _ => return Err(Error::other("unexpected account request")), + } + for row in account_rows(&accounts)? { + HeapTable::encode_row(&row, self.cfg.page_size)?; + } + let op = WalOp::Accounts { accounts: accounts.clone() }; + self.recovery_required.store(true, Ordering::Release); + self.wal.truncate()?; + self.wal.append(&op)?; + self.apply_op(&op)?; + self.wal.clear()?; + self.recovery_required.store(false, Ordering::Release); + Ok(accounts) + } + /// 一条请求翻成一条响应 fn handle_request(&self, req: Request) -> Response { + let _ddl_guard = if matches!(req, Request::CreateTable { .. } | Request::DropTable { .. } + | Request::CreateIndex { .. } | Request::DropIndex { .. }) { + match self.write_lock.lock() { + Ok(guard) => Some(guard), + Err(_) => return Response::Error { message: "write lock poisoned".into() }, + } + } else { None }; + if self.recovery_required.load(Ordering::Acquire) && !matches!(req, Request::Ping) { + return Response::Error { message: "storage recovery required".into() }; + } + if !req.valid_columns() { + return Response::Error { message: "invalid column name".into() }; + } + if let Some(table) = req.table() { + if reserved_table(table) { + return Response::Error { message: "reserved system table".into() }; + } + if table.is_empty() || !table.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return Response::Error { message: "invalid table name".into() }; + } + } match req { + request @ (Request::AccountsList | Request::AccountCreate { .. } + | Request::AccountReset { .. } | Request::AccountLogin { .. } + | Request::AccountDrop { .. }) => { + match self.accounts_request(request) { + Ok(accounts) => Response::Accounts { accounts }, + Err(e) => Response::Error { message: e.to_string() }, + } + } Request::Ping => { log_debug!(pipe, "ping"); Response::Pong } - Request::Scan { table } => { + Request::Scan { table, columns } => { log_debug!(pipe, "scan table={}", table); - let result = self.with_existing_table(&table, |t| t.scan()); + let result = (|| { + if let Some(cols) = &columns { + let c = self.catalog.lock().map_err(|_| std::io::Error::other("catalog lock poisoned"))?; + let schema = c.describe(&table).ok_or_else(|| std::io::Error::other(format!("unknown table: {table}")))?; + for col in cols { + if !schema.columns.iter().any(|c| &c.name == col) { + return Err(std::io::Error::other(format!("unknown column: {col}"))); + } + } + } + self.with_existing_table(&table, |t| t.scan_columns(columns.as_deref())) + })(); match result { Ok(rows) => Response::Rows { rows }, @@ -468,9 +738,19 @@ impl Server { if !indexed { return Response::NoIndex; } - match self.with_table(&table, |t| t.get_by_column_key(&column, key)) { - Ok(Some(row)) => Response::Rows { rows: vec![row] }, - Ok(None) => Response::Rows { rows: vec![] }, + let found = match key.as_i64() { + Some(k) => self.with_table(&table, |t| t.get_all_by_column_key(&column, k)), + None => match key.as_str() { + Some(s) => self.with_table(&table, |t| t.get_all_by_string_key(&column, s)), + None => { + return Response::Error { + message: "lookup_by_index: key must be a number or a string".into(), + }; + } + }, + }; + match found { + Ok(rows) => Response::Rows { rows }, Err(e) => { log_warn!(pipe, "lookup_by_index {}: {}", table, e); Response::Error { @@ -480,6 +760,35 @@ impl Server { } } + Request::RangeByIndex { table, column, lo, lo_inclusive, hi, hi_inclusive } => { + log_debug!(pipe, "range_by_index table={} column={}", table, column); + let indexed = + match self.with_existing_table(&table, |t| Ok(t.has_index(&column))) { + Ok(v) => v, + Err(e) => { + log_warn!(pipe, "range_by_index {}: {}", table, e); + return Response::Error { + message: format!("range_by_index {}: {}", table, e), + }; + } + }; + if !indexed { + return Response::NoIndex; + } + let lo = lo.as_ref().map(|v| (v, lo_inclusive)); + let hi = hi.as_ref().map(|v| (v, hi_inclusive)); + match self.with_table(&table, |t| t.scan_range(&column, lo, hi)) { + Ok(Some(rows)) => Response::Rows { rows }, + Ok(None) => Response::NoIndex, + Err(e) => { + log_warn!(pipe, "range_by_index {}: {}", table, e); + Response::Error { + message: format!("range_by_index {}: {}", table, e), + } + } + } + } + Request::ReplaceAll { table, rows } => { log_debug!(pipe, "replace_all table={} rows={}", table, rows.len()); let tname = table.clone(); @@ -514,6 +823,7 @@ impl Server { let schemas: Vec = c .all_tables() .into_iter() + .filter(|(name, _)| !reserved_table(name)) .map(|(name, ts)| TableSchemaWire { table: name.to_string(), columns: ts.columns.clone(), @@ -646,36 +956,48 @@ impl Server { } } } - } - } - /// 表名:catalog 与目录取并集 - fn list_tables(&self) -> std::io::Result> { - let mut names = BTreeSet::new(); - { - let c = self.catalog.lock().unwrap(); - for t in c.table_names() { - names.insert(t); + Request::Compact { table } => { + log_debug!(pipe, "compact table={}", table); + match self.compact_table(&table) { + Ok(()) => Response::Ok, + Err(e) => { + log_warn!(pipe, "compact {}: {}", table, e); + Response::Error { + message: format!("compact {}: {}", table, e), + } + } + } } - } - let dir = &self.cfg.data_dir; - if dir.exists() { - for entry in std::fs::read_dir(dir)? { - let entry = entry?; - let name = entry.file_name().to_string_lossy().to_string(); - if let Some(stem) = name.strip_suffix(".db") { - names.insert(stem.to_string()); + + Request::ReplaceSchema { table, columns, rows } => { + log_debug!(pipe, "replace_schema table={} cols={}", table, columns.len()); + let tname = table.clone(); + let op = WalOp::ReplaceSchema { table, columns, rows }; + match self.write_via_wal(&op) { + Ok(()) => Response::Ok, + Err(e) => { + log_warn!(pipe, "replace_schema {}: {}", tname, e); + Response::Error { + message: format!("replace_schema {}: {}", tname, e), + } + } } } } - Ok(names.into_iter().collect()) + } + + /// 只列出数据字典中的业务表 + fn list_tables(&self) -> std::io::Result> { + let c = self.catalog.lock().map_err(|_| std::io::Error::other("catalog lock poisoned"))?; + Ok(c.table_names().into_iter().filter(|t| !reserved_table(t)).collect()) } /// 查一张表的 schema fn describe_table( &self, table: &str, - ) -> std::io::Result<(Vec, u64, Vec, Vec)> { + ) -> std::io::Result { let c = self.catalog.lock().unwrap(); if let Some(schema) = c.describe(table) { return Ok(( @@ -686,9 +1008,6 @@ impl Server { )); } drop(c); - if std::fs::metadata(self.table_path(table)).is_ok() { - return Ok((Vec::new(), 0, Vec::new(), Vec::new())); - } Err(std::io::Error::new( std::io::ErrorKind::NotFound, format!("unknown table: {}", table), @@ -696,6 +1015,123 @@ impl Server { } } +fn account_rows(accounts: &[Account]) -> std::io::Result> { + accounts.iter().map(|a| Ok(HashMap::from([ + ("id".into(), serde_json::json!(a.id)), + ("user".into(), serde_json::Value::String(a.user.clone())), + ("password_hash".into(), serde_json::Value::String(a.password_hash.clone())), + ("registered_at".into(), serde_json::Value::String(a.registered_at.clone())), + ("last_login_at".into(), match &a.last_login_at { + Some(stamp) => serde_json::Value::String(stamp.clone()), + None => serde_json::Value::Null, + }), + ("revision".into(), serde_json::json!(a.revision)), + ]))).collect() +} + +/// 系统表:列定义与索引都按 scripts/init.sql 写进数据字典 +fn declare_system_schema(catalog: &mut Catalog, rows: &[Row]) -> std::io::Result<()> { + for table in system_schema() { + let stats: &[Row] = if table.name == USERS_TABLE { rows } else { &[] }; + catalog.rebuild_stats(&table.name, stats); + catalog.set_columns(&table.name, table.columns.iter().map(InitColumn::to_schema).collect())?; + catalog.rebuild_stats(&table.name, stats); + catalog.mark_system(&table.name)?; + for index in &table.indexes { + catalog.add_index(&table.name, index)?; + } + } + Ok(()) +} + +/// 读一行账号:新格式是平铺的列,老格式是 {id, account:""} +fn decode_account_row(row: &Row) -> std::io::Result { + if let Some(encoded) = row.get("account").and_then(serde_json::Value::as_str) { + return serde_json::from_str(encoded).map_err(|_| std::io::Error::other("invalid account record")); + } + let id = row.get("id").and_then(serde_json::Value::as_i64) + .ok_or_else(|| std::io::Error::other("invalid account record"))?; + let user = row.get("user").and_then(serde_json::Value::as_str) + .ok_or_else(|| std::io::Error::other("invalid account record"))?; + let password_hash = row.get("password_hash").and_then(serde_json::Value::as_str) + .ok_or_else(|| std::io::Error::other("invalid account record"))?; + Ok(Account { + id, + user: user.to_string(), + password_hash: password_hash.to_string(), + revision: row.get("revision").and_then(serde_json::Value::as_u64).unwrap_or(1), + registered_at: row.get("registered_at").and_then(serde_json::Value::as_str).unwrap_or("").to_string(), + last_login_at: row.get("last_login_at").and_then(serde_json::Value::as_str).map(str::to_string), + }) +} + +/// 老记录没有注册时间就补当前时间 +fn normalize_account(account: &mut Account) { + if account.registered_at.is_empty() { + account.registered_at = now_stamp(); + } +} + +/// 当前 UTC 时间,写法与引擎的 timestamp 一致 +fn now_stamp() -> String { + let seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + format_timestamp(seconds) +} + +/// Unix 秒 → YYYY-MM-DD HH:MM:SS(UTC) +fn format_timestamp(seconds: i64) -> String { + let days = seconds.div_euclid(86_400); + let rest = seconds.rem_euclid(86_400); + let (year, month, day) = civil_from_days(days); + format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02}", + year, month, day, rest / 3600, (rest % 3600) / 60, rest % 60 + ) +} + +/// 天数 → (年, 月, 日) +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = (doy - (153 * mp + 2) / 5 + 1) as u32; + let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if month <= 2 { year + 1 } else { year }, month, day) +} + +fn account_name(user: &str) -> std::io::Result { + if user.is_empty() || user.len() > 64 || !user.bytes().all(|c| c.is_ascii_alphanumeric() || b"_.-".contains(&c)) { + return Err(std::io::Error::other("invalid account name")); + } + Ok(user.to_ascii_lowercase()) +} + +fn validate_account_hash(hash: &str) -> std::io::Result<()> { + if hash.is_empty() || hash.len() > 256 { + return Err(std::io::Error::other("invalid account credential")); + } + Ok(()) +} + +fn new_account(id: i64, user: &str, password_hash: String) -> std::io::Result { + validate_account_hash(&password_hash)?; + Ok(Account { + id, + user: account_name(user)?, + password_hash, + revision: 1, + registered_at: now_stamp(), + last_login_at: None, + }) +} + /// 一张表的索引定义转线上格式 fn index_wires(schema: &chusql_storage::catalog::TableSchema) -> Vec { let mut out = vec![IndexWire { @@ -732,8 +1168,179 @@ impl Drop for Server { } } +/// 系统库名:账号表住在这里,其中的表都是系统表。服务启动时只建它一个, +/// 别的库一律要显式建(没有默认工作库)。 +const SYSTEM_DATABASE: &str = "system"; + +/// 系统库的表结构来自这份脚本(编译期嵌进来) +const SYSTEM_SCHEMA_SQL: &str = include_str!("../../../scripts/init.sql"); + +/// 系统库 schema:脚本缺表或多表都按它来 +fn system_schema() -> &'static [InitTable] { + static TABLES: std::sync::OnceLock> = std::sync::OnceLock::new(); + TABLES.get_or_init(|| initsql::parse(SYSTEM_SCHEMA_SQL).unwrap_or_else(|e| panic!("scripts/init.sql: {e}"))) +} + +/// 保留库:不能建、不能删。 +fn reserved_database(name: &str) -> bool { + name == SYSTEM_DATABASE +} + +/// 每个库拥有独立的目录、字典、句柄和 WAL。 +struct Databases { + root: Config, + system: Server, + named: Mutex>, +} + +impl Databases { + fn new(cfg: Config) -> std::io::Result { + // 系统库按需创建:目录、字典与系统表缺失时自动补齐。 + // 其余库只在 data/databases// 下已存在时才装载,启动不会凭空建库。 + let system = Self::open(&cfg, SYSTEM_DATABASE)?; + let root = cfg.data_dir.join("databases"); + std::fs::create_dir_all(&root)?; + let canonical_root = std::fs::canonicalize(&root)?; + if canonical_root.parent() != Some(std::fs::canonicalize(&cfg.data_dir)?.as_path()) { + return Err(std::io::Error::other("database root escapes data directory")); + } + let mut named = HashMap::new(); + for entry in std::fs::read_dir(root)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_string(); + if entry.file_type()?.is_dir() && valid_database(&name) && !reserved_database(&name) + && name == name.to_ascii_lowercase() && entry.path().join("catalog.json").is_file() { + if std::fs::canonicalize(entry.path())?.parent() != Some(canonical_root.as_path()) { + return Err(std::io::Error::other("database directory escapes database root")); + } + let mut config = cfg.clone(); + config.data_dir = entry.path(); + let server = Server::new(config)?; + server.recover_wal()?; + server.rebuild_stats()?; + server.migrate_data()?; + server.compact_tables()?; + named.insert(name, server); + } + } + Ok(Self { root: cfg, system, named: Mutex::new(named) }) + } + + /// 在 data_dir// 下开一个保留库(目录与字典、WAL 一起建起来) + fn open(cfg: &Config, name: &str) -> std::io::Result { + let mut config = cfg.clone(); + config.data_dir = cfg.data_dir.join(name); + let server = Server::new(config)?; + if !server.catalog_path().is_file() { + server.catalog.lock().map_err(|_| std::io::Error::other("catalog lock poisoned"))?.save(server.catalog_path())?; + } + server.recover_wal()?; + server.rebuild_stats()?; + server.migrate_data()?; + server.compact_tables()?; + Ok(server) + } + + fn request(&self, mut value: serde_json::Value) -> std::io::Result { + use std::io::Error; + let method = value.get("method").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let name = match value.get("database") { + None => String::new(), + Some(serde_json::Value::String(s)) => s.to_ascii_lowercase(), + _ => return Err(Error::other("invalid database name")), + }; + if !name.is_empty() && !valid_database(&name) { return Err(Error::other("invalid database name")); } + let mut named = self.named.lock().map_err(|_| Error::other("database lock poisoned"))?; + match method.as_str() { + "all_catalogs" => { + let mut schemas = Vec::new(); + for (database, server) in std::iter::once((SYSTEM_DATABASE, &self.system)) + .chain(named.iter().map(|(name, server)| (name.as_str(), server))) { + match server.handle_request(Request::ListCatalog) { + Response::Catalog { schemas: entries } => { + for mut entry in entries { + entry.table = format!("{database}.{}", entry.table); + schemas.push(entry); + } + } + other => return Ok(other), + } + } + return Ok(Response::Catalog { schemas }); + } + "list_databases" => { + let mut databases: Vec = named.keys().cloned().collect(); + databases.sort(); + databases.push(SYSTEM_DATABASE.into()); + return Ok(Response::Tables { tables: databases }); + } + "create_database" => { + if !valid_database(&name) { return Err(Error::other("invalid database name")); } + if reserved_database(&name) || named.contains_key(&name) { return Err(Error::other("database already exists")); } + let mut cfg = self.root.clone(); + cfg.data_dir = cfg.data_dir.join("databases").join(&name); + std::fs::create_dir(&cfg.data_dir)?; + let server = Server::new(cfg)?; + server.catalog.lock().map_err(|_| Error::other("catalog lock poisoned"))?.save(server.catalog_path())?; + named.insert(name, server); + return Ok(Response::Ok); + } + "drop_database" => { + if name == SYSTEM_DATABASE { return Err(Error::other("cannot drop the system database")); } + let server = named.get(&name).ok_or_else(|| Error::other("unknown database"))?; + let root = std::fs::canonicalize(self.root.data_dir.join("databases"))?; + let target = std::fs::canonicalize(&server.cfg.data_dir)?; + if target.parent() != Some(root.as_path()) || !target.join("catalog.json").is_file() { + return Err(Error::other("invalid database directory")); + } + let config = server.cfg.clone(); + drop(named.remove(&name)); + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(Error::other)?.as_nanos(); + let tombstone = root.join(format!(".dropped-{name}-{stamp}")); + if let Err(error) = std::fs::rename(&target, &tombstone) { + named.insert(name, Server::new(config)?); + return Err(error); + } + std::fs::remove_dir_all(&tombstone).map_err(|e| Error::other(format!("database dropped; directory cleanup failed: {e}")))?; + return Ok(Response::Ok); + } + "use_database" => { + return if reserved_database(&name) || named.contains_key(&name) { Ok(Response::Ok) } + else { Err(Error::other("unknown database")) }; + } + _ => {} + } + let mut selected = name; + let qualified = value.get("table").and_then(|v| v.as_str()).map(str::to_owned); + if let Some((database, table)) = qualified.as_deref().and_then(|t| t.split_once('.')) { + if !valid_database(database) { return Err(Error::other("invalid database name")); } + selected = database.to_ascii_lowercase(); + value["table"] = serde_json::Value::String(table.to_string()); + } + let request: Request = serde_json::from_value(value).map_err(Error::other)?; + let global = matches!(request, Request::AccountsList | Request::AccountCreate { .. } + | Request::AccountReset { .. } | Request::AccountLogin { .. } + | Request::AccountDrop { .. } | Request::Ping); + let server = if global { &self.system } + else if selected.is_empty() { return Err(Error::other("no database selected")); } + else if selected == SYSTEM_DATABASE { &self.system } + else { named.get(&selected).ok_or_else(|| Error::other("unknown database"))? }; + Ok(server.handle_request(request)) + } +} + +fn valid_database(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + let device = matches!(lower.as_str(), "con" | "prn" | "aux" | "nul") + || (lower.len() == 4 && (lower.starts_with("com") || lower.starts_with("lpt")) + && lower.as_bytes()[3].is_ascii_digit()); + !name.is_empty() && name.len() <= 64 && !device && !reserved_table(name) + && name.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_') + && name.as_bytes().first().is_some_and(|c| c.is_ascii_alphabetic() || *c == b'_') +} + /// 处理一条连接 -fn handle(server: &Server, conn: LocalSocketStream) -> std::io::Result<()> { +fn handle(server: &Databases, conn: LocalSocketStream) -> std::io::Result<()> { let mut writer = conn.try_clone()?; let reader = BufReader::new(conn); @@ -743,8 +1350,11 @@ fn handle(server: &Server, conn: LocalSocketStream) -> std::io::Result<()> { continue; } - let resp = match serde_json::from_str::(&line) { - Ok(req) => server.handle_request(req), + let resp = match serde_json::from_str::(&line) { + Ok(req) => match server.request(req) { + Ok(response) => response, + Err(e) => Response::Error { message: e.to_string() }, + }, Err(e) => { log_warn!(pipe, "bad request line: {}", e); Response::Error { @@ -771,9 +1381,61 @@ fn log_config(loaded: &Loaded) { } } +/// 命令行用法 +fn print_usage() { + println!("chusql-storage: ChuSQL 存储服务"); + println!(); + println!("用法: chusql-storage [--config ]"); + println!(); + println!("配置文件固定位置(安装时确定,按系统惯例):"); + println!(" {}", config::default_config_path().display()); + println!("该文件存在才读,不存在就用内置默认值(不是错误)。"); + println!("--config 是唯一的显式覆盖手段;文件不存在或读不了会报错退出。"); + println!("配置分区: [page] [btree] [buffer] [storage] [server] [log]"); + println!("--help, -h 显示本帮助。"); +} + +/// 解析命令行:只认 --config / --config= / --help / -h +fn parse_args(args: &[String]) -> Result, String> { + let mut explicit = None; + let mut i = 0; + while i < args.len() { + let arg = args[i].as_str(); + if arg == "--config" { + let value = args.get(i + 1).ok_or("--config needs a path")?; + if value.trim().is_empty() { + return Err("--config needs a path".to_string()); + } + explicit = Some(value.clone()); + i += 2; + } else if let Some(value) = arg.strip_prefix("--config=") { + if value.trim().is_empty() { + return Err("--config needs a path".to_string()); + } + explicit = Some(value.to_string()); + i += 1; + } else if arg == "--help" || arg == "-h" { + print_usage(); + std::process::exit(0); + } else { + return Err(format!("unknown argument: {}", arg)); + } + } + Ok(explicit) +} + /// 进程入口 fn main() -> std::io::Result<()> { - let loaded = match config::load() { + let args: Vec = std::env::args().skip(1).collect(); + let explicit = match parse_args(&args) { + Ok(p) => p, + Err(e) => { + eprintln!("chusql-storage: {}", e); + eprintln!("用法: chusql-storage [--config ],--help 看帮助"); + std::process::exit(2); + } + }; + let loaded = match config::load(explicit.as_deref()) { Ok(l) => l, Err(e) => { log_error!(core, "config error: {}", e); @@ -786,30 +1448,14 @@ fn main() -> std::io::Result<()> { log_info!(core, "chusql-storage {} starting", env!("CARGO_PKG_VERSION")); log_config(&loaded); - let server = Arc::new(Server::new(loaded.config)?); + let server = Arc::new(Databases::new(loaded.config)?); - if let Err(e) = server.recover_wal() { - log_error!(core, "WAL recovery failed: {}", e); - return Err(e); - } - - if let Err(e) = server.rebuild_stats() { - log_warn!(core, "stats rebuild skipped: {}", e); - } - - let ns_name = server - .cfg - .pipe_name - .as_str() - .to_ns_name::() - .map_err(|e| std::io::Error::other(e.to_string()))?; - let listener = ListenerOptions::new().name(ns_name).create_sync()?; + let listener = endpoint::listen(&server.root.pipe_name)?; log_info!( core, - "listening on pipe {} (\\\\.\\pipe\\{})", - server.cfg.pipe_name, - server.cfg.pipe_name + "listening on {}", + endpoint::display(&server.root.pipe_name) ); for conn in listener.incoming() { diff --git a/chusql-storage/src/btree.rs b/chusql-storage/src/btree.rs index b1c07e1..2bf7131 100644 --- a/chusql-storage/src/btree.rs +++ b/chusql-storage/src/btree.rs @@ -3,84 +3,77 @@ use std::path::Path; use crate::page::{Page, PageFile, PageId}; -// 磁盘 B+ 树:节点占一页,文件头记页大小与 order。 +// 磁盘 B+ 树:键是变长字节串,允许重复键,叶子链支持范围扫描。 const META_PAGE: PageId = 0; -const META_MAGIC: &[u8; 4] = b"CBTR"; -const OFF_META_PAGE_SIZE: usize = 4; -const OFF_META_ORDER: usize = 8; -const OFF_META_ROOT: usize = 10; +const META_MAGIC: &[u8; 4] = b"CBK2"; +const OFF_PAGE_SIZE: usize = 4; +const OFF_MAX_KEY: usize = 8; +const OFF_ROOT: usize = 10; +const OFF_ORDER: usize = 18; const NODE_LEAF: u8 = 0; const NODE_INTERNAL: u8 = 1; -const OFF_TYPE: usize = 0; +const OFF_KIND: usize = 0; const OFF_COUNT: usize = 1; const OFF_NEXT: usize = 3; -const OFF_KEYS: usize = 12; +const HEADER: usize = 11; +const SLOT: usize = 2; +const CELL_FIXED: usize = 2 + 8; -/// 一个节点占多少字节 -fn node_bytes(order: usize) -> usize { - OFF_KEYS + max_keys(order) * 8 + order * 8 -} +/// 键长上限:节点里的格子必须放得进一页 +pub const MAX_KEY_BYTES: usize = 64; -/// 最多几个键 +/// 一个节点最多几个键 fn max_keys(order: usize) -> usize { order - 1 } -/// 值区域的起点 -fn off_payload(order: usize) -> usize { - OFF_KEYS + max_keys(order) * 8 +/// 一个格子最多占多少字节 +fn max_cell_bytes() -> usize { + CELL_FIXED + MAX_KEY_BYTES } /// 这个 order 放得进一页吗 pub fn fits_in_page(page_size: usize, order: usize) -> bool { - order >= 2 && node_bytes(order) <= page_size + order >= 2 && HEADER + max_keys(order) * (SLOT + max_cell_bytes()) <= page_size } /// 一页最多几个孩子 pub fn max_order(page_size: usize) -> usize { - page_size.saturating_sub(4) / 16 + page_size.saturating_sub(HEADER) / (SLOT + max_cell_bytes()) + 1 } -/// 读一个 u8 fn read_u8(p: &Page, off: usize) -> u8 { p.data[off] } -/// 写一个 u8 fn write_u8(p: &mut Page, off: usize, v: u8) { p.data[off] = v; } -/// 读一个小端 u16 fn read_u16(p: &Page, off: usize) -> u16 { u16::from_le_bytes([p.data[off], p.data[off + 1]]) } -/// 写一个小端 u16 fn write_u16(p: &mut Page, off: usize, v: u16) { p.data[off..off + 2].copy_from_slice(&v.to_le_bytes()); } -/// 读一个小端 u32 fn read_u32(p: &Page, off: usize) -> u32 { u32::from_le_bytes([p.data[off], p.data[off + 1], p.data[off + 2], p.data[off + 3]]) } -/// 写一个小端 u32 fn write_u32(p: &mut Page, off: usize, v: u32) { p.data[off..off + 4].copy_from_slice(&v.to_le_bytes()); } -/// 读一个小端 u64 fn read_u64(p: &Page, off: usize) -> u64 { let mut b = [0u8; 8]; b.copy_from_slice(&p.data[off..off + 8]); u64::from_le_bytes(b) } -/// 写一个小端 u64 fn write_u64(p: &mut Page, off: usize, v: u64) { p.data[off..off + 8].copy_from_slice(&v.to_le_bytes()); } @@ -89,9 +82,10 @@ fn write_u64(p: &mut Page, off: usize, v: u64) { fn write_meta(page: &mut Page, page_size: usize, order: usize, root: PageId) { page.zero(); page.data[0..4].copy_from_slice(META_MAGIC); - write_u32(page, OFF_META_PAGE_SIZE, page_size as u32); - write_u16(page, OFF_META_ORDER, order as u16); - write_u64(page, OFF_META_ROOT, root); + write_u32(page, OFF_PAGE_SIZE, page_size as u32); + write_u16(page, OFF_MAX_KEY, MAX_KEY_BYTES as u16); + write_u64(page, OFF_ROOT, root); + write_u16(page, OFF_ORDER, order as u16); } /// 校验文件头与配置 @@ -102,8 +96,8 @@ fn check_meta(page: &Page, page_size: usize, order: usize) -> io::Result<()> { "not a chusql index file (bad magic)", )); } - let stored_page = read_u32(page, OFF_META_PAGE_SIZE) as usize; - let stored_order = read_u16(page, OFF_META_ORDER) as usize; + let stored_page = read_u32(page, OFF_PAGE_SIZE) as usize; + let stored_order = read_u16(page, OFF_ORDER) as usize; if stored_page != page_size || stored_order != order { return Err(io::Error::new( io::ErrorKind::InvalidData, @@ -116,89 +110,114 @@ fn check_meta(page: &Page, page_size: usize, order: usize) -> io::Result<()> { Ok(()) } -enum NodeData { - Leaf { - keys: Vec, - values: Vec, - next: PageId, - }, - Internal { - keys: Vec, - children: Vec, - }, +/// 一个节点:叶子存行位置,内部节点存孩子页号 +struct Node { + kind: u8, + next: PageId, + keys: Vec>, + values: Vec, +} + +/// 一个格子占多少字节 +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::() +} + +/// 把节点写回一页(格子从页尾往前摆,槽位从头部往后长) +fn put_node(p: &mut Page, node: &Node) -> io::Result<()> { + if node.keys.len() > u16::MAX as usize || node_bytes(node) > p.data.len() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "index node does not fit in a page", + )); + } + p.zero(); + write_u8(p, OFF_KIND, node.kind); + write_u16(p, OFF_COUNT, node.keys.len() as u16); + // 内部节点比键多一个孩子,最后一个放在 next 槽位里 + let trailer = if node.kind == NODE_INTERNAL { + node.values.last().copied().unwrap_or(0) + } else { + node.next + }; + write_u64(p, OFF_NEXT, trailer); + let mut cursor = p.data.len(); + for (i, key) in node.keys.iter().enumerate() { + let size = cell_bytes(key); + cursor -= size; + write_u16(p, cursor, key.len() as u16); + p.data[cursor + 2..cursor + 2 + key.len()].copy_from_slice(key); + write_u64(p, cursor + 2 + key.len(), node.values[i]); + write_u16(p, HEADER + i * SLOT, cursor as u16); + } + Ok(()) } /// 从一页读出节点 -fn read_node(p: &Page, order: usize) -> NodeData { - let ty = read_u8(p, OFF_TYPE); - let n = read_u16(p, OFF_COUNT) as usize; - let payload = off_payload(order); - if ty == NODE_LEAF { - let next = read_u64(p, OFF_NEXT); - let mut keys = Vec::with_capacity(n); - let mut values = Vec::with_capacity(n); - for i in 0..n { - keys.push(read_u64(p, OFF_KEYS + i * 8) as i64); - values.push(read_u64(p, payload + i * 8)); - } - NodeData::Leaf { keys, values, next } +fn get_node(p: &Page) -> Node { + let kind = read_u8(p, OFF_KIND); + let count = read_u16(p, OFF_COUNT) as usize; + let trailer = read_u64(p, OFF_NEXT); + let mut keys = Vec::with_capacity(count); + let mut values = Vec::with_capacity(count + 1); + for i in 0..count { + let off = read_u16(p, HEADER + i * SLOT) as usize; + let klen = read_u16(p, off) as usize; + keys.push(p.data[off + 2..off + 2 + klen].to_vec()); + values.push(read_u64(p, off + 2 + klen)); + } + if kind == NODE_INTERNAL { + values.push(trailer); + Node { kind, next: 0, keys, values } } else { - let mut keys = Vec::with_capacity(n); - let mut children = Vec::with_capacity(n + 1); - for i in 0..n { - keys.push(read_u64(p, OFF_KEYS + i * 8) as i64); - } - for i in 0..(n + 1) { - children.push(read_u64(p, payload + i * 8)); - } - NodeData::Internal { keys, children } + Node { kind, next: trailer, keys, values } } } -/// 把节点写回一页 -fn write_node(p: &mut Page, node: &NodeData, order: usize) { - p.zero(); - let payload = off_payload(order); - match node { - NodeData::Leaf { keys, values, next } => { - write_u8(p, OFF_TYPE, NODE_LEAF); - write_u16(p, OFF_COUNT, keys.len() as u16); - write_u64(p, OFF_NEXT, *next); - for (i, k) in keys.iter().enumerate() { - write_u64(p, OFF_KEYS + i * 8, *k as u64); - } - for (i, v) in values.iter().enumerate() { - write_u64(p, payload + i * 8, *v); - } - } - NodeData::Internal { keys, children } => { - write_u8(p, OFF_TYPE, NODE_INTERNAL); - write_u16(p, OFF_COUNT, keys.len() as u16); - for (i, k) in keys.iter().enumerate() { - write_u64(p, OFF_KEYS + i * 8, *k as u64); - } - for (i, c) in children.iter().enumerate() { - write_u64(p, payload + i * 8, *c); - } - } +/// 叶子里的插入位置:同键按 value 排,保证顺序稳定 +fn leaf_slot(keys: &[Vec], values: &[u64], key: &[u8], value: u64) -> usize { + let mut i = keys.partition_point(|k| k.as_slice() < key); + while i < keys.len() && keys[i].as_slice() == key && values[i] < value { + i += 1; + } + i +} + +/// 内部节点里选哪个孩子 +fn child_slot(keys: &[Vec], key: &[u8]) -> usize { + keys.partition_point(|k| k.as_slice() <= key) +} + +/// 切开一个节点:返回上提的键与右半节点 +fn split_node(node: &mut Node) -> (Vec, Node) { + let mid = node.keys.len() / 2; + if node.kind == NODE_LEAF { + let sep = node.keys[mid].clone(); + let right = Node { + kind: NODE_LEAF, + next: node.next, + keys: node.keys.split_off(mid), + values: node.values.split_off(mid), + }; + (sep, right) + } else { + let sep = node.keys[mid].clone(); + let right_keys = node.keys.split_off(mid + 1); + let right_values = node.values.split_off(mid + 1); + node.keys.pop(); + ( + sep, + Node { kind: NODE_INTERNAL, next: 0, keys: right_keys, values: right_values }, + ) } } -/// 二分选孩子 -fn child_index(keys: &[i64], key: i64) -> usize { - let mut lo = 0; - let mut hi = keys.len(); - while lo < hi { - let mid = (lo + hi) / 2; - if keys[mid] <= key { - lo = mid + 1; - } else { - hi = mid; - } - } - lo -} - +/// 磁盘 B+ 树 pub struct DiskBTree { file: PageFile, order: usize, @@ -207,11 +226,11 @@ pub struct DiskBTree { impl DiskBTree { /// 打开或新建索引文件 pub fn open>( - path: P, - page_size: usize, - order: usize, - pool_size: usize, - ) -> io::Result { + path: P, + page_size: usize, + order: usize, + pool_size: usize, + ) -> io::Result { if !fits_in_page(page_size, order) { return Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -235,21 +254,44 @@ impl DiskBTree { Ok(DiskBTree { file, order }) } - /// 读根页号 + /// 键太长直接报错,避免写坏节点 + fn check_key(&self, key: &[u8]) -> io::Result<()> { + if key.len() > MAX_KEY_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "index key is {} bytes, at most {} are supported", + key.len(), + MAX_KEY_BYTES + ), + )); + } + Ok(()) + } + fn root(&mut self) -> io::Result> { - let r = self.file.with_page(META_PAGE, |p| read_u64(p, OFF_META_ROOT))?; + let r = self.file.with_page(META_PAGE, |p| read_u64(p, OFF_ROOT))?; Ok(if r == 0 { None } else { Some(r) }) } - /// 写根页号 fn set_root(&mut self, r: Option) -> io::Result<()> { self.file - .update_page(META_PAGE, |p| write_u64(p, OFF_META_ROOT, r.unwrap_or(0))) + .update_page(META_PAGE, |p| write_u64(p, OFF_ROOT, r.unwrap_or(0))) } - /// 分配新页 - fn alloc(&mut self) -> io::Result { - Ok(self.file.append_page()?.id) + fn alloc(&mut self, node: &Node) -> io::Result { + let page = self.file.append_page()?; + let id = page.id; + self.file.update_page(id, |p| put_node(p, node))??; + Ok(id) + } + + fn load(&mut self, id: PageId) -> io::Result { + self.file.with_page(id, get_node) + } + + fn store(&mut self, id: PageId, node: &Node) -> io::Result<()> { + self.file.update_page(id, |p| put_node(p, node))? } /// 清空整棵树 @@ -262,221 +304,190 @@ impl DiskBTree { Ok(()) } - /// 空页,用来拼节点 - fn new_page(&self, id: PageId) -> Page { - Page::new(id, self.file.page_size()) - } - - /// 按 key 找 value - pub fn get(&mut self, key: i64) -> io::Result> { - match self.root()? { - None => Ok(None), - Some(r) => self.get_rec(r, key), + /// 树是空的吗(只看最左叶子,不用整树遍历) + pub fn is_empty(&mut self) -> io::Result { + let Some(mut id) = self.root()? else { + return Ok(true); + }; + loop { + let node = self.load(id)?; + if node.kind == NODE_LEAF { + return Ok(node.keys.is_empty()); + } + id = node.values[0]; } } - /// 递归查找 - fn get_rec(&mut self, page_id: PageId, key: i64) -> io::Result> { - let order = self.order; - match self.file.with_page(page_id, |p| read_node(p, order))? { - NodeData::Leaf { keys, values, .. } => { - Ok(keys.binary_search(&key).ok().map(|i| values[i])) - } - NodeData::Internal { keys, children } => { - let i = child_index(&keys, key); - self.get_rec(children[i], key) + /// 按 key 取第一个位置 + pub fn get(&mut self, key: &[u8]) -> io::Result> { + Ok(self.get_all(key)?.into_iter().next()) + } + + /// 按 key 取全部位置(重复键按值升序) + pub fn get_all(&mut self, key: &[u8]) -> io::Result> { + let Some(mut id) = self.root()? else { + return Ok(Vec::new()); + }; + loop { + let node = self.load(id)?; + if node.kind == NODE_LEAF { + let start = node.keys.partition_point(|k| k.as_slice() < key); + let mut out = Vec::new(); + for i in start..node.keys.len() { + if node.keys[i].as_slice() != key { + break; + } + out.push(node.values[i]); + } + return Ok(out); } + id = node.values[child_slot(&node.keys, key)]; } } - /// 插入或覆盖 - pub fn insert(&mut self, key: i64, value: u64) -> io::Result<()> { + /// 插入一个键值对(键可重复) + pub fn insert(&mut self, key: &[u8], value: u64) -> io::Result<()> { + self.check_key(key)?; match self.root()? { None => { - let id = self.alloc()?; - let node = NodeData::Leaf { - keys: vec![key], - values: vec![value], + let node = Node { + kind: NODE_LEAF, next: 0, + keys: vec![key.to_vec()], + values: vec![value], }; - let mut p = self.new_page(id); - write_node(&mut p, &node, self.order); - self.file.write_page(&p)?; + let id = self.alloc(&node)?; self.set_root(Some(id)) } - Some(r) => { - let (_, split) = self.insert_rec(r, key, value)?; - if let Some((sep, right_id)) = split { - let root_id = self.alloc()?; - let node = NodeData::Internal { + Some(root) => match self.insert_rec(root, key, value)? { + None => Ok(()), + Some((sep, right_id)) => { + let node = Node { + kind: NODE_INTERNAL, + next: 0, keys: vec![sep], - children: vec![r, right_id], + values: vec![root, right_id], }; - let mut p = self.new_page(root_id); - write_node(&mut p, &node, self.order); - self.file.write_page(&p)?; - self.set_root(Some(root_id)) - } else { - Ok(()) - } - } - } - } - - /// 递归插入,可能上提 - fn insert_rec( - &mut self, - page_id: PageId, - key: i64, - value: u64, - ) -> io::Result<(PageId, Option<(i64, PageId)>)> { - let order = self.order; - let node = self.file.with_page(page_id, |p| read_node(p, order))?; - let limit = max_keys(order); - match node { - NodeData::Leaf { mut keys, mut values, next } => { - match keys.binary_search(&key) { - Ok(i) => values[i] = value, - Err(i) => { - keys.insert(i, key); - values.insert(i, value); - } - } - if keys.len() <= limit { - self.file.update_page(page_id, |p| { - write_node(p, &NodeData::Leaf { keys, values, next }, order) - })?; - Ok((page_id, None)) - } else { - let mid = keys.len() / 2; - let rkeys = keys.split_off(mid); - let rvalues = values.split_off(mid); - let sep = rkeys[0]; - - let right_id = self.alloc()?; - let mut rp = self.new_page(right_id); - write_node( - &mut rp, - &NodeData::Leaf { keys: rkeys, values: rvalues, next }, - order, - ); - self.file.write_page(&rp)?; - - self.file.update_page(page_id, |p| { - write_node(p, &NodeData::Leaf { keys, values, next: right_id }, order) - })?; - - Ok((page_id, Some((sep, right_id)))) - } - } - - NodeData::Internal { mut keys, mut children } => { - let i = child_index(&keys, key); - let (new_child, split) = self.insert_rec(children[i], key, value)?; - children[i] = new_child; - match split { - None => { - self.file.update_page(page_id, |p| { - write_node(p, &NodeData::Internal { keys, children }, order) - })?; - Ok((page_id, None)) - } - Some((sep, right_id)) => { - keys.insert(i, sep); - children.insert(i + 1, right_id); - if keys.len() <= limit { - self.file.update_page(page_id, |p| { - write_node(p, &NodeData::Internal { keys, children }, order) - })?; - Ok((page_id, None)) - } else { - let mid = keys.len() / 2; - let up = keys[mid]; - let rkeys = keys.split_off(mid + 1); - keys.truncate(mid); - let rchildren = children.split_off(mid + 1); - - let right_id = self.alloc()?; - let mut rp = self.new_page(right_id); - write_node( - &mut rp, - &NodeData::Internal { keys: rkeys, children: rchildren }, - order, - ); - self.file.write_page(&rp)?; - - self.file.update_page(page_id, |p| { - write_node(p, &NodeData::Internal { keys, children }, order) - })?; - - Ok((page_id, Some((up, right_id)))) - } - } - } - } - } - } - - /// 删掉一个键,键不存在返回 false - pub fn delete(&mut self, key: i64) -> io::Result { - 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 { - let order = self.order; - match self.file.with_page(page_id, |p| read_node(p, order))? { - NodeData::Leaf { mut keys, mut values, next } => match keys.binary_search(&key) { - Err(_) => Ok(false), - Ok(i) => { - keys.remove(i); - values.remove(i); - self.file.update_page(page_id, |p| { - write_node(p, &NodeData::Leaf { keys, values, next }, order) - })?; - Ok(true) + let id = self.alloc(&node)?; + self.set_root(Some(id)) } }, - NodeData::Internal { keys, children } => { - let i = child_index(&keys, key); - self.delete_rec(children[i], key) - } } } - /// 按键升序返回全部 - pub fn iter_all(&mut self) -> io::Result> { + /// 递归插入,溢出就把右半节点与上提键交给上层 + fn insert_rec( + &mut self, + id: PageId, + key: &[u8], + value: u64, + ) -> io::Result, 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 { + 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 { + 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, u64)>> { + self.scan(None, None) + } + + /// 范围扫描:lo / hi 都是闭区间,None 表示这一侧不限 + pub fn scan( + &mut self, + lo: Option<&[u8]>, + hi: Option<&[u8]>, + ) -> io::Result, u64)>> { + let Some(mut id) = self.root()? else { + return Ok(Vec::new()); + }; + loop { + let node = self.load(id)?; + if node.kind == NODE_LEAF { + id = match lo { + None => id, + Some(start) => { + let at = node.keys.partition_point(|k| k.as_slice() < start); + if at < node.keys.len() { id } else { node.next } + } + }; + break; + } + id = match lo { + None => node.values[0], + Some(start) => node.values[child_slot(&node.keys, start)], + }; + } let mut out = Vec::new(); - if let Some(r) = self.root()? { - self.walk(r, &mut out)?; + while id != 0 { + let node = self.load(id)?; + for i in 0..node.keys.len() { + if lo.is_some_and(|start| node.keys[i].as_slice() < start) { + continue; + } + if hi.is_some_and(|end| node.keys[i].as_slice() > end) { + return Ok(out); + } + out.push((node.keys[i].clone(), node.values[i])); + } + id = node.next; } Ok(out) } - /// 中序遍历 - fn walk(&mut self, page_id: PageId, out: &mut Vec<(i64, u64)>) -> io::Result<()> { - let order = self.order; - match self.file.with_page(page_id, |p| read_node(p, order))? { - NodeData::Leaf { keys, values, .. } => { - for i in 0..keys.len() { - out.push((keys[i], values[i])); - } - } - NodeData::Internal { children, .. } => { - for c in children { - self.walk(c, out)?; - } - } - } - Ok(()) - } - - /// 把脏页写回 + /// 脏页写回 pub fn flush(&mut self) -> io::Result<()> { self.file.flush() } + /// 当前缓存页数 + pub fn cached_pages(&self) -> usize { + self.file.cached_pages() + } } diff --git a/chusql-storage/src/catalog.rs b/chusql-storage/src/catalog.rs index c87852c..fbe952d 100644 --- a/chusql-storage/src/catalog.rs +++ b/chusql-storage/src/catalog.rs @@ -1,12 +1,13 @@ use std::collections::hash_map::DefaultHasher; -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::hash::{Hash, Hasher}; use std::io; +use std::io::Write; use std::path::Path; use serde::{Deserialize, Serialize}; -use crate::protocol::{ColumnType, Row, SchemaColumn}; +use crate::protocol::{Row, SchemaColumn}; // 数据字典:记每张表有哪些列、什么类型、多少行、有哪些索引,以及列级统计。 @@ -26,8 +27,14 @@ pub struct ColumnStat { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct TableSchema { + #[serde(default)] + pub system: bool, pub columns: Vec, #[serde(default)] + pub dropped_columns: BTreeSet, + #[serde(default)] + pub compacted: bool, + #[serde(default)] pub row_count: u64, #[serde(default)] pub indexes: Vec, @@ -63,7 +70,13 @@ impl Catalog { /// 写回文件 pub fn save>(&self, path: P) -> io::Result<()> { let bytes = serde_json::to_vec_pretty(self).map_err(io::Error::other)?; - std::fs::write(path, bytes) + let pending = path.as_ref().with_extension("json.pending"); + let mut file = std::fs::File::create(&pending)?; + file.write_all(&bytes)?; + file.sync_all()?; + drop(file); + std::fs::rename(pending, &path)?; + std::fs::OpenOptions::new().read(true).write(true).open(path)?.sync_all() } /// 一张表的 schema @@ -93,7 +106,10 @@ impl Catalog { self.tables.insert( table.to_string(), TableSchema { + system: false, columns, + dropped_columns: BTreeSet::new(), + compacted: false, row_count: 0, indexes: Vec::new(), stats: BTreeMap::new(), @@ -102,6 +118,14 @@ impl Catalog { Ok(()) } + /// 标记内部账号表 + pub fn mark_system(&mut self, table: &str) -> io::Result<()> { + let entry = self.tables.get_mut(table) + .ok_or_else(|| io::Error::other("missing system table"))?; + entry.system = true; + Ok(()) + } + /// 记一次插入,返回列有没有变化 pub fn record_insert(&mut self, table: &str, row: &Row) -> bool { let changed = { @@ -140,8 +164,11 @@ impl Catalog { entry.stats.clear(); entry.row_count = 0; } - rows.iter() - .fold(false, |changed, r| self.record_insert(table, r) || changed) + let mut changed = false; + for row in rows { + changed = self.record_insert(table, row) || changed; + } + changed } /// 这一列第一次见到这个值就加一 @@ -164,7 +191,8 @@ impl Catalog { } /// 删表 - pub fn drop_table(&mut self, table: &str) -> io::Result<()> { if self.tables.remove(table).is_none() { + pub fn drop_table(&mut self, table: &str) -> io::Result<()> { + if self.tables.remove(table).is_none() { return Err(io::Error::new( io::ErrorKind::NotFound, format!("unknown table: {}", table), @@ -174,6 +202,35 @@ impl Catalog { Ok(()) } + /// ALTER:整列定义换成新的,并清掉二级索引与统计 + pub fn set_columns(&mut self, table: &str, columns: Vec) -> 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 { let entry = self @@ -186,11 +243,33 @@ impl Catalog { entry.columns.len() != before }; entry.stats.remove(column); + entry.dropped_columns.insert(column.to_string()); + entry.compacted = false; entry.indexes.retain(|i| i.column != column); self.seen.remove(table); Ok(had_column) } + /// 整理完成:记住这一版已经重写过(隐藏列名要留住,防止被重新写进来) + pub fn mark_compacted(&mut self, table: &str) -> io::Result<()> { + let entry = self + .tables + .get_mut(table) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("unknown table: {}", table)))?; + entry.compacted = true; + self.seen.remove(table); + Ok(()) + } + + /// 还有隐藏列字节待整理的表 + pub fn dropped_tables(&self) -> Vec { + self.tables + .iter() + .filter(|(_, e)| !e.dropped_columns.is_empty() && !e.compacted) + .map(|(t, _)| t.clone()) + .collect() + } + /// 记下一条索引定义 pub fn add_index(&mut self, table: &str, column: &str) -> io::Result<()> { let entry = self @@ -232,13 +311,14 @@ impl Catalog { fn add_missing_columns(entry: &mut TableSchema, row: &Row) -> bool { let mut changed = false; for (k, v) in row { - if entry.columns.iter().any(|c| &c.name == k) { + if entry.dropped_columns.contains(k) || entry.columns.iter().any(|c| &c.name == k) { continue; } if let Some(ty) = infer_type(v) { entry.columns.push(SchemaColumn { name: k.clone(), ty, + ..Default::default() }); changed = true; } @@ -247,15 +327,45 @@ fn add_missing_columns(entry: &mut TableSchema, row: &Row) -> bool { } /// 由 JSON 值猜列类型 -fn infer_type(v: &serde_json::Value) -> Option { +fn infer_type(v: &serde_json::Value) -> Option { match v { - serde_json::Value::Number(_) => Some(ColumnType::Int), - serde_json::Value::String(_) => Some(ColumnType::Str), - serde_json::Value::Bool(_) => Some(ColumnType::Bool), + serde_json::Value::Number(_) => Some("int".to_string()), + serde_json::Value::String(_) => Some("str".to_string()), + serde_json::Value::Bool(_) => Some("bool".to_string()), _ => None, } } +/// 旧写法与新别名都并到规范名字,参数(varchar(20))原样保留;不认识的原样返回 +pub fn normalize_type(ty: &str) -> String { + let trimmed = ty.trim(); + let (base, args) = match trimmed.find('(') { + Some(i) => (&trimmed[..i], &trimmed[i..]), + None => (trimmed, ""), + }; + match base.trim().to_ascii_lowercase().as_str() { + "integer" => format!("int{args}"), + "int" => format!("int{args}"), + "bigint" => format!("bigint{args}"), + "smallint" => format!("smallint{args}"), + "str" => format!("str{args}"), + "text" => format!("str{args}"), + "varchar" => format!("varchar{args}"), + "char" => format!("char{args}"), + "bool" => format!("bool{args}"), + "boolean" => format!("bool{args}"), + "float" => format!("float{args}"), + "real" => format!("float{args}"), + "double" => format!("double{args}"), + "decimal" => format!("decimal{args}"), + "numeric" => format!("decimal{args}"), + "date" => format!("date{args}"), + "timestamp" => format!("timestamp{args}"), + "blob" => format!("blob{args}"), + _ => trimmed.to_string(), + } +} + /// 一个值的哈希,统计不同值个数用 fn value_hash(v: &serde_json::Value) -> u64 { let mut h = DefaultHasher::new(); diff --git a/chusql-storage/src/config.rs b/chusql-storage/src/config.rs index 9018765..56d2e5b 100644 --- a/chusql-storage/src/config.rs +++ b/chusql-storage/src/config.rs @@ -4,21 +4,103 @@ use serde::Deserialize; use crate::log::Level; -// 配置:环境变量优先于 TOML,最后回退内置默认值。 +// 全局配置:只从 TOML 文件读,没写的项回退内置默认值。 +// +// 这个文件是整条链路的唯一配置文件,按分区各管各的: +// 存储只看 [page]/[btree]/[buffer]/[storage]/[server]/[log], +// 其他分区(例如 web 的 [web])由对应的层自己读,这里既不校验也不关心。 +// 位置是安装时定下的固定路径,只有命令行 --config 能覆盖;没有任何环境变量通道。 +// (唯一跟环境变量有关的是路径的默认值:配置文件和默认数据目录都按平台惯例算, +// 见 default_config_path / default_data_dir,它们只是算默认位置,不是配置通道。) +// 读一次就关,不长期占着文件句柄。 -pub const DEFAULT_CONFIG_PATH: &str = "chusql-storage.toml"; +/// 配置文件名 +pub const CONFIG_FILE_NAME: &str = "chusql.toml"; +/// 配置目录名(随系统惯例放在用户配置目录下) +pub const APP_DIR_NAME: &str = "ChuSQL"; +/// Unix 下的数据目录名(XDG 惯例小写,跟安装脚本的默认安装目录一致) +pub const UNIX_APP_DIR_NAME: &str = "chusql"; +/// 数据子目录名 +pub const DATA_DIR_NAME: &str = "data"; pub const DEFAULT_POOL_SIZE: usize = 1024; pub const DEFAULT_PAGE_SIZE: usize = 4096; pub const DEFAULT_BTREE_ORDER: usize = 4; -pub const DEFAULT_DATA_DIR: &str = "../localdata"; -pub const DEFAULT_PIPE_NAME: &str = "chusql-storage"; +pub const DEFAULT_PIPE_NAME: &str = "chusql-joint"; pub const DEFAULT_LOG_LEVEL: &str = "info"; const MIN_PAGE_SIZE: usize = 512; const MAX_PAGE_SIZE: usize = 65536; const MIN_BTREE_ORDER: usize = 3; +/// 安装后的固定配置路径:Windows 用 %APPDATA%,其它平台用 XDG 惯例。 +/// 环境变量缺失时退回相对路径 ChuSQL/chusql.toml。 +pub fn default_config_path() -> PathBuf { + #[cfg(windows)] + { + let appdata = std::env::var_os("APPDATA").filter(|v| !v.is_empty()); + if let Some(appdata) = appdata { + return PathBuf::from(appdata).join(APP_DIR_NAME).join(CONFIG_FILE_NAME); + } + } + #[cfg(not(windows))] + { + let xdg = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()); + if let Some(xdg) = xdg { + return PathBuf::from(xdg).join(APP_DIR_NAME).join(CONFIG_FILE_NAME); + } + let home = std::env::var_os("HOME").filter(|v| !v.is_empty()); + if let Some(home) = home { + return PathBuf::from(home) + .join(".config") + .join(APP_DIR_NAME) + .join(CONFIG_FILE_NAME); + } + } + PathBuf::from(APP_DIR_NAME).join(CONFIG_FILE_NAME) +} + +/// [storage] data_dir 没写时的默认数据目录,按各平台惯例自动切换: +/// +/// Windows %LOCALAPPDATA%\ChuSQL\data(没设就退 %APPDATA%) +/// Unix $XDG_DATA_HOME/chusql/data(没设就是 ~/.local/share/chusql/data) +/// +/// 跟安装脚本的默认数据目录是同一处:install.ps1 默认装到 %LOCALAPPDATA%\ChuSQL、 +/// 数据放它下面的 data\;install.sh 默认装到 ~/.local/share/chusql、数据放它下面的 data/。 +/// 环境变量都缺时才退回相对路径(跟 default_config_path 的兜底风格一致)。 +#[cfg(windows)] +pub fn default_data_dir() -> PathBuf { + let local = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()); + if let Some(local) = local { + return PathBuf::from(local).join(APP_DIR_NAME).join(DATA_DIR_NAME); + } + let roaming = std::env::var_os("APPDATA").filter(|v| !v.is_empty()); + if let Some(roaming) = roaming { + return PathBuf::from(roaming).join(APP_DIR_NAME).join(DATA_DIR_NAME); + } + PathBuf::from(APP_DIR_NAME).join(DATA_DIR_NAME) +} + +/// 见 Windows 版本的注释:这里走 XDG 惯例。 +#[cfg(not(windows))] +pub fn default_data_dir() -> PathBuf { + let xdg = std::env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty()); + if let Some(xdg) = xdg { + return PathBuf::from(xdg) + .join(UNIX_APP_DIR_NAME) + .join(DATA_DIR_NAME); + } + let home = std::env::var_os("HOME").filter(|v| !v.is_empty()); + if let Some(home) = home { + return PathBuf::from(home) + .join(".local") + .join("share") + .join(UNIX_APP_DIR_NAME) + .join(DATA_DIR_NAME); + } + PathBuf::from(UNIX_APP_DIR_NAME).join(DATA_DIR_NAME) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct Config { pub page_size: usize, @@ -35,7 +117,7 @@ impl Default for Config { page_size: DEFAULT_PAGE_SIZE, btree_order: DEFAULT_BTREE_ORDER, pool_size: DEFAULT_POOL_SIZE, - data_dir: PathBuf::from(DEFAULT_DATA_DIR), + data_dir: default_data_dir(), pipe_name: DEFAULT_PIPE_NAME.to_string(), log_level: Level::Info, } @@ -46,7 +128,6 @@ impl Default for Config { pub enum Origin { Default, File(PathBuf), - Env(&'static str), } impl Origin { @@ -55,7 +136,6 @@ impl Origin { match self { Origin::Default => "default".to_string(), Origin::File(p) => format!("file {}", p.display()), - Origin::Env(k) => format!("env {}", k), } } } @@ -68,7 +148,6 @@ pub struct Loaded { } #[derive(Debug, Default, Deserialize)] -#[serde(deny_unknown_fields)] struct FileConfig { page: Option, btree: Option, @@ -114,12 +193,19 @@ struct FileLog { level: Option, } -/// 按真实环境加载 -pub fn load() -> Result { - let path = match std::env::var("CHUSQL_CONFIG") { - Ok(p) if !p.trim().is_empty() => Some(PathBuf::from(p)), - _ => { - let p = PathBuf::from(DEFAULT_CONFIG_PATH); +/// 加载配置:explicit 有值就用它(读不到要报错),否则用固定安装路径(存在才读) +pub fn load(explicit: Option<&str>) -> Result { + let explicit = explicit.map(str::trim).filter(|p| !p.is_empty()); + let path = match explicit { + Some(p) => { + let p = PathBuf::from(p); + if !p.is_file() { + return Err(format!("config file not found: {}", p.display())); + } + Some(p) + } + None => { + let p = default_config_path(); if p.is_file() { Some(p) } else { None } } }; @@ -130,15 +216,11 @@ pub fn load() -> Result { ), None => None, }; - resolve(text.as_deref(), path, &|k| std::env::var(k).ok()) + resolve(text.as_deref(), path) } -/// 解析文本并套环境变量 -pub fn resolve( - text: Option<&str>, - path: Option, - env: &dyn Fn(&str) -> Option, -) -> Result { +/// 解析文本:文件 -> 默认 +pub fn resolve(text: Option<&str>, path: Option) -> Result { let file: FileConfig = match text { None => FileConfig::default(), Some(t) => toml::from_str(t).map_err(|e| match &path { @@ -148,70 +230,44 @@ pub fn resolve( }; let file_origin = Origin::File( - path.clone() - .unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_PATH)), + path.clone().unwrap_or_else(default_config_path), ); let (page_size, page_origin) = pick( - "CHUSQL_PAGE_SIZE", file.page.and_then(|p| p.size), DEFAULT_PAGE_SIZE, &file_origin, - env, - |s| { - s.trim() - .parse::() - .map_err(|_| format!("not a number: {}", s)) - }, - )?; + ); let (btree_order, order_origin) = pick( - "CHUSQL_BTREE_ORDER", file.btree.and_then(|b| b.order), DEFAULT_BTREE_ORDER, &file_origin, - env, - |s| { - s.trim() - .parse::() - .map_err(|_| format!("not a number: {}", s)) - }, - )?; + ); let (pool_size, pool_origin) = pick( - "CHUSQL_POOL_SIZE", file.buffer.and_then(|b| b.pool_size), DEFAULT_POOL_SIZE, &file_origin, - env, - |s| { - s.trim() - .parse::() - .map_err(|_| format!("not a number: {}", s)) - }, - )?; + ); let (data_dir, dir_origin) = pick( - "CHUSQL_DATA_DIR", file.storage.and_then(|s| s.data_dir), - DEFAULT_DATA_DIR.to_string(), + default_data_dir().to_string_lossy().into_owned(), &file_origin, - env, - |s| Ok(s.trim().to_string()), - )?; + ); let (pipe_name, pipe_origin) = pick( - "CHUSQL_PIPE", file.server.and_then(|s| s.pipe_name), DEFAULT_PIPE_NAME.to_string(), &file_origin, - env, - |s| Ok(s.trim().to_string()), - )?; + ); let (log_level, log_origin) = pick( - "CHUSQL_LOG", file.log.and_then(|l| l.level), DEFAULT_LOG_LEVEL.to_string(), &file_origin, - env, - |s| Ok(s.trim().to_string()), - )?; + ); + + let data_dir = data_dir.trim().to_string(); + let pipe_name = pipe_name.trim().to_string(); + let log_level = log_level.trim().to_string(); + let log_level = Level::parse(&log_level) .ok_or_else(|| format!("log.level is not one of off/error/warn/info/debug: {}", log_level))?; @@ -248,23 +304,12 @@ pub fn resolve( }) } -/// 取一项:env -> 文件 -> 默认 -fn pick( - env_key: &'static str, - file_value: Option, - default: T, - file_origin: &Origin, - env: &dyn Fn(&str) -> Option, - parse: impl Fn(&str) -> Result, -) -> Result<(T, Origin), String> { - if let Some(raw) = env(env_key) { - let value = parse(&raw).map_err(|e| format!("{}: {}", env_key, e))?; - return Ok((value, Origin::Env(env_key))); - } +/// 取一项:文件 -> 默认 +fn pick(file_value: Option, default: T, file_origin: &Origin) -> (T, Origin) { if let Some(value) = file_value { - return Ok((value, file_origin.clone())); + return (value, file_origin.clone()); } - Ok((default, Origin::Default)) + (default, Origin::Default) } /// 检查选项是否自洽 @@ -291,3 +336,34 @@ fn validate_layout(page_size: usize, btree_order: usize) -> Result<(), String> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// 全局配置里别的层(例如 web)的分区不能被存储层当成错误 + #[test] + fn ignores_sections_owned_by_other_layers() { + let text = r#" +[page] +size = 8192 + +[storage] +data_dir = "../data" + +[web] +port = 7777 +user = "root" +"#; + let loaded = resolve(Some(text), Some(PathBuf::from("chusql.toml"))).unwrap(); + assert_eq!(loaded.config.page_size, 8192); + assert_eq!(loaded.config.data_dir, PathBuf::from("../data")); + } + + /// 自己分区里的拼写错误仍然要报错 + #[test] + fn still_rejects_typos_inside_its_own_sections() { + let text = "[page]\npage_size = 8192\n"; + assert!(resolve(Some(text), None).is_err()); + } +} diff --git a/chusql-storage/src/endpoint.rs b/chusql-storage/src/endpoint.rs new file mode 100644 index 0000000..7c9927b --- /dev/null +++ b/chusql-storage/src/endpoint.rs @@ -0,0 +1,99 @@ +// 本地端点:Windows 上是具名管道 `\\.\pipe\`,Unix/macOS 上是文件系统套接字 +// `/.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\`,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`,文件名固定 `.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 { + #[cfg(windows)] + { + let name = pipe_name + .to_ns_name::() + .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::() + .map_err(|e| std::io::Error::other(e.to_string()))?; + LocalSocketStream::connect(name) + } +} + +/// 监听端点:Windows 直接建具名管道;Unix 先探活,再清掉上次崩溃留下的残留套接字文件。 +pub fn listen(pipe_name: &str) -> std::io::Result { + #[cfg(windows)] + { + let name = pipe_name + .to_ns_name::() + .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::() + .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(()) + } + } +} diff --git a/chusql-storage/src/heap.rs b/chusql-storage/src/heap.rs index a2491ca..887d443 100644 --- a/chusql-storage/src/heap.rs +++ b/chusql-storage/src/heap.rs @@ -1,6 +1,8 @@ use std::collections::HashSet; use std::io; use std::path::Path; +use serde::de::{IgnoredAny, MapAccess, Visitor}; +use serde::Deserializer; use crate::btree::DiskBTree; use crate::page::PageId; @@ -13,6 +15,32 @@ const HEADER_SIZE: usize = 2; const SLOT_SIZE: usize = 8; const SLOT_DIR_START: usize = HEADER_SIZE; +/// 未选字段只校验并跳过,不分配值。 +fn decode_row(bytes: &[u8], columns: Option<&[String]>, hidden: &HashSet) -> io::Result { + struct RowVisitor<'a>(Option<&'a [String]>, &'a HashSet); + 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>(self, mut map: M) -> Result { + let mut row = Row::new(); + while let Some(key) = map.next_key::()? { + if !self.1.contains(&key) && self.0.is_none_or(|cols| cols.contains(&key)) { + row.insert(key, map.next_value()?); + } else { + map.next_value::()?; + } + } + Ok(row) + } + } + let mut decoder = serde_json::Deserializer::from_slice(bytes); + let row = decoder.deserialize_map(RowVisitor(columns, hidden)).map_err(io::Error::other)?; + decoder.end().map_err(io::Error::other)?; + Ok(row) +} + /// 读一个小端 u16 fn read_u16(buf: &[u8], off: usize) -> u16 { u16::from_le_bytes([buf[off], buf[off + 1]]) @@ -51,6 +79,26 @@ fn row_int(row: &Row, column: &str) -> Option { } } +/// i64 键 → 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> { + 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> { + row.get(column).and_then(value_key) +} + pub struct HeapPage; impl HeapPage { @@ -146,6 +194,7 @@ struct NamedIndex { } pub struct HeapTable { + hidden_columns: HashSet, file: PageFile, indexes: Vec, } @@ -154,6 +203,7 @@ impl HeapTable { /// 打开表(不带索引) pub fn open>(path: P, page_size: usize, pool_size: usize) -> io::Result { Ok(HeapTable { + hidden_columns: HashSet::new(), file: PageFile::with_options(path, page_size, pool_size)?, indexes: Vec::new(), }) @@ -167,12 +217,13 @@ impl HeapTable { btree_order: usize, pool_size: usize, ) -> io::Result { - let mut t = HeapTable { - file: PageFile::with_options(path, page_size, pool_size)?, - indexes: Vec::new(), - }; - t.attach_index("id", index_path, page_size, btree_order, pool_size)?; - Ok(t) + Self::open_with_indexes( + path, + &[("id".to_string(), index_path.as_ref().to_path_buf())], + page_size, + btree_order, + pool_size, + ) } /// 打开表并挂上多组索引 @@ -184,11 +235,29 @@ impl HeapTable { pool_size: usize, ) -> io::Result { let mut t = HeapTable { + hidden_columns: HashSet::new(), file: PageFile::with_options(path, page_size, pool_size)?, indexes: Vec::new(), }; + let mut filled = false; for (col, p) in indexes { - t.attach_index(col, p, page_size, btree_order, pool_size)?; + match t.attach_index(col, p, page_size, btree_order, pool_size) { + Ok(()) => {} + Err(err) if err.kind() == io::ErrorKind::InvalidData => { + // 格式不兼容(例如上一版落的索引文件):删掉重开 + let _ = std::fs::remove_file(p); + t.attach_index(col, p, page_size, btree_order, pool_size)?; + } + Err(err) => return Err(err), + } + // 空索引(新文件、被截断、强杀留下)都按堆数据补齐 + if t.index_empty(col)? { + t.fill_index(col)?; + filled = true; + } + } + if filled { + t.flush()?; } Ok(t) } @@ -231,19 +300,17 @@ impl HeapTable { )); } - let mut seen: HashSet = HashSet::new(); - let mut entries: Vec<(i64, u64)> = Vec::new(); + let mut seen: HashSet> = HashSet::new(); + let mut entries: Vec<(Vec, u64)> = Vec::new(); for (page_id, slot, r) in self.scan_with_positions()? { - let Some(k) = row_int(&r, column) else { + let Some(k) = row_key(&r, column) else { continue; }; - if !seen.insert(k) { + // 行身份索引仍然唯一;其余列允许重复值 + if column == "id" && !seen.insert(k.clone()) { return Err(io::Error::new( io::ErrorKind::InvalidInput, - format!( - "column \"{}\" has duplicate value {} -- non-unique indexes are not supported yet", - column, k - ), + format!("column \"{}\" has duplicate values", column), )); } entries.push((k, pack_position(page_id, slot))); @@ -253,7 +320,31 @@ impl HeapTable { let i = self.indexes.len() - 1; self.indexes[i].tree.clear()?; for (k, pos) in entries { - self.indexes[i].tree.insert(k, pos)?; + self.indexes[i].tree.insert(&k, pos)?; + } + Ok(()) + } + + /// 这一列的索引是空的吗 + fn index_empty(&mut self, column: &str) -> io::Result { + match self.indexes.iter_mut().find(|i| i.column == column) { + Some(idx) => idx.tree.is_empty(), + None => Ok(false), + } + } + + /// 按堆里的行把某一列索引补齐 + fn fill_index(&mut self, column: &str) -> io::Result<()> { + let rows = self.scan_with_positions()?; + let Some(i) = self.indexes.iter().position(|idx| idx.column == column) else { + return Ok(()); + }; + for (page_id, slot, row) in rows { + if let Some(k) = row_key(&row, column) { + self.indexes[i] + .tree + .insert(&k, pack_position(page_id, slot))?; + } } Ok(()) } @@ -269,6 +360,15 @@ impl HeapTable { } } + pub fn set_hidden_columns(&mut self, columns: HashSet) { + self.hidden_columns = columns; + } + + pub fn hide_column(&mut self, column: &str) { + self.detach_index(column); + self.hidden_columns.insert(column.to_string()); + } + /// 插入一行,并顺手写各索引 pub fn insert_row(&mut self, row: &Row) -> io::Result<()> { self.insert_row_returning_position(row).map(|_| ()) @@ -276,15 +376,15 @@ impl HeapTable { /// 插入一行并返回它的位置 pub fn insert_row_returning_position(&mut self, row: &Row) -> io::Result<(PageId, u16)> { - let mut pending: Vec<(usize, i64)> = Vec::new(); + let mut pending: Vec<(usize, Vec)> = Vec::new(); for (i, idx) in self.indexes.iter_mut().enumerate() { - let Some(k) = row_int(row, &idx.column) else { + let Some(k) = row_key(row, &idx.column) else { continue; }; - if idx.tree.get(k)?.is_some() { + if idx.column == "id" && idx.tree.get(&k)?.is_some() { return Err(io::Error::new( io::ErrorKind::InvalidInput, - format!("duplicate key {} on indexed column \"{}\"", k, idx.column), + "duplicate value on the id index".to_string(), )); } pending.push((i, k)); @@ -293,7 +393,7 @@ impl HeapTable { let (page_id, slot) = self.insert_returning_position(row)?; let pos = pack_position(page_id, slot); for (i, k) in pending { - self.indexes[i].tree.insert(k, pos)?; + self.indexes[i].tree.insert(&k, pos)?; } Ok((page_id, slot)) } @@ -306,17 +406,22 @@ impl HeapTable { Ok(()) } - /// 插入一行,只进堆表、不碰索引 - fn insert_returning_position(&mut self, row: &Row) -> io::Result<(PageId, u16)> { + /// 编码并验证一行能放进页 + pub fn encode_row(row: &Row, page_size: usize) -> io::Result> { let tuple = serde_json::to_vec(row).map_err(io::Error::other)?; - if HEADER_SIZE + SLOT_SIZE + tuple.len() > self.file.page_size() { + if HEADER_SIZE + SLOT_SIZE + tuple.len() > page_size { return Err(io::Error::other(format!( "tuple too large: {} bytes", tuple.len() ))); } + Ok(tuple) + } + /// 插入一行,只进堆表、不碰索引 + fn insert_returning_position(&mut self, row: &Row) -> io::Result<(PageId, u16)> { + let tuple = Self::encode_row(row, self.file.page_size())?; let n = self.file.num_pages()?; if n > 0 { let last_id = n - 1; @@ -341,16 +446,84 @@ impl HeapTable { self.get_by_column_key("id", key) } - /// 按某一列的索引取一行 + /// 按某一列的整数索引取一行 pub fn get_by_column_key(&mut self, column: &str, key: i64) -> io::Result> { + self.get_by_bytes(column, &key_of(key)) + } + + /// 按某一列的字符串索引取一行 + pub fn get_by_string_key(&mut self, column: &str, key: &str) -> io::Result> { + self.get_by_bytes(column, key.as_bytes()) + } + + /// 按某一列的整数索引取全部行 + pub fn get_all_by_column_key(&mut self, column: &str, key: i64) -> io::Result> { + self.get_all_by_bytes(column, &key_of(key)) + } + + /// 按某一列的字符串索引取全部行 + pub fn get_all_by_string_key(&mut self, column: &str, key: &str) -> io::Result> { + self.get_all_by_bytes(column, key.as_bytes()) + } + + /// 按索引键的字节取第一行 + fn get_by_bytes(&mut self, column: &str, key: &[u8]) -> io::Result> { + Ok(self.get_all_by_bytes(column, key)?.into_iter().next()) + } + + /// 按索引键的字节取全部行(重复值都给出来) + fn get_all_by_bytes(&mut self, column: &str, key: &[u8]) -> io::Result> { let Some(idx) = self.indexes.iter_mut().find(|i| i.column == column) else { + return Ok(Vec::new()); + }; + let positions = idx.tree.get_all(key)?; + let mut rows = Vec::new(); + for pos in positions { + let (page_id, slot) = unpack_position(pos); + if let Some(row) = self.read_at(page_id, slot)? { + rows.push(row); + } + } + Ok(rows) + } + + /// 范围扫描:某一列有索引就走叶子链,没有索引返回 None(上层退回全表扫描) + pub fn scan_range( + &mut self, + column: &str, + lo: Option<(&serde_json::Value, bool)>, + hi: Option<(&serde_json::Value, bool)>, + ) -> io::Result>> { + let Some(i) = self.indexes.iter().position(|idx| idx.column == column) else { return Ok(None); }; - let Some(pos) = idx.tree.get(key)? else { - return Ok(None); - }; - let (page_id, slot) = unpack_position(pos); - self.read_at(page_id, slot) + let lo_bound = lo.and_then(|(v, inclusive)| value_key(v).map(|k| (k, inclusive))); + let hi_bound = hi.and_then(|(v, inclusive)| value_key(v).map(|k| (k, inclusive))); + // 树按闭区间取,排他端在取回来之后再筛 + let pairs = self.indexes[i].tree.scan( + lo_bound.as_ref().map(|(k, _)| k.as_slice()), + hi_bound.as_ref().map(|(k, _)| k.as_slice()), + )?; + let mut rows = Vec::new(); + for (key, pos) in pairs { + if let Some((bound, inclusive)) = &lo_bound { + let outside = if *inclusive { &key < bound } else { &key <= bound }; + if outside { + continue; + } + } + if let Some((bound, inclusive)) = &hi_bound { + let outside = if *inclusive { &key > bound } else { &key >= bound }; + if outside { + continue; + } + } + let (page_id, slot) = unpack_position(pos); + if let Some(row) = self.read_at(page_id, slot)? { + rows.push(row); + } + } + Ok(Some(rows)) } /// 按位置读一行 @@ -359,7 +532,7 @@ impl HeapTable { match bytes { None => Ok(None), Some(bytes) => { - let row: Row = serde_json::from_slice(&bytes).map_err(io::Error::other)?; + let row = decode_row(&bytes, None, &self.hidden_columns)?; Ok(Some(row)) } } @@ -373,9 +546,10 @@ impl HeapTable { return Ok(None); } if let Some(r) = &row { + let pos = pack_position(page_id, slot); for idx in &mut self.indexes { - if let Some(k) = row_int(r, &idx.column) { - idx.tree.delete(k)?; + if let Some(k) = row_key(r, &idx.column) { + idx.tree.delete(&k, pos)?; } } } @@ -392,7 +566,7 @@ impl HeapTable { .indexes .iter_mut() .find(|i| i.column == "id") - .and_then(|i| i.tree.get(k).ok().flatten()); + .and_then(|i| i.tree.get(&key_of(k)).ok().flatten()); if let Some(p) = pos { positions.push(unpack_position(p)); } @@ -401,10 +575,8 @@ impl HeapTable { let mut wanted: Vec = keys.to_vec(); wanted.sort_unstable(); for (page_id, slot, r) in self.scan_with_positions()? { - if let Some(k) = row_int(&r, "id") { - if wanted.binary_search(&k).is_ok() { - positions.push((page_id, slot)); - } + if row_int(&r, "id").is_some_and(|k| wanted.binary_search(&k).is_ok()) { + positions.push((page_id, slot)); } } } @@ -420,8 +592,12 @@ impl HeapTable { /// 全表扫描 pub fn scan(&mut self) -> io::Result> { + self.scan_columns(None) + } + + pub fn scan_columns(&mut self, columns: Option<&[String]>) -> io::Result> { Ok(self - .scan_with_positions()? + .scan_projected_with_positions(columns)? .into_iter() .map(|(_, _, r)| r) .collect()) @@ -429,6 +605,10 @@ impl HeapTable { /// 全表扫描,同时给出每行的位置(建索引用) pub fn scan_with_positions(&mut self) -> io::Result> { + self.scan_projected_with_positions(None) + } + + fn scan_projected_with_positions(&mut self, columns: Option<&[String]>) -> io::Result> { let n = self.file.num_pages()?; let mut out = Vec::new(); for i in 0..n { @@ -442,7 +622,7 @@ impl HeapTable { v })?; for (slot, bytes) in tuples { - let row: Row = serde_json::from_slice(&bytes).map_err(io::Error::other)?; + let row = decode_row(&bytes, columns, &self.hidden_columns)?; out.push((i, slot, row)); } } @@ -459,8 +639,8 @@ impl HeapTable { let (page_id, slot) = self.insert_returning_position(r)?; let pos = pack_position(page_id, slot); for idx in &mut self.indexes { - if let Some(k) = row_int(r, &idx.column) { - idx.tree.insert(k, pos)?; + if let Some(k) = row_key(r, &idx.column) { + idx.tree.insert(&k, pos)?; } } } diff --git a/chusql-storage/src/initsql.rs b/chusql-storage/src/initsql.rs new file mode 100644 index 0000000..035b903 --- /dev/null +++ b/chusql-storage/src/initsql.rs @@ -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, + pub indexes: Vec, +} + +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, String> { + let mut tables: Vec = Vec::new(); + for statement in statements(text) { + let words = statement.split_whitespace().collect::>(); + 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, String> { + let mut out = Vec::new(); + for part in split_top_level(body) { + let words = part.split_whitespace().collect::>(); + 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 { + 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 { + 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); + } +} diff --git a/chusql-storage/src/lib.rs b/chusql-storage/src/lib.rs index 1d412bd..a459a57 100644 --- a/chusql-storage/src/lib.rs +++ b/chusql-storage/src/lib.rs @@ -2,7 +2,9 @@ pub mod btree; pub mod config; +pub mod endpoint; pub mod heap; +pub mod initsql; pub mod log; pub mod page; pub mod protocol; diff --git a/chusql-storage/src/log.rs b/chusql-storage/src/log.rs index 5f8e1ac..7013fab 100644 --- a/chusql-storage/src/log.rs +++ b/chusql-storage/src/log.rs @@ -82,7 +82,9 @@ impl Level { static LEVEL: AtomicU8 = AtomicU8::new(Level::Info as u8); -static COLOR: OnceLock = OnceLock::new(); +static COLOR_STDERR: OnceLock = OnceLock::new(); + +static COLOR_STDOUT: OnceLock = OnceLock::new(); /// 设置全局等级 pub fn set_level(level: Level) { @@ -100,22 +102,29 @@ pub fn init() { enable_vt(); } -/// 写出一行 +/// 写出一行:warn / error 走 stderr,info / debug 走 stdout。 pub fn write(channel: Channel, level: Level, args: fmt::Arguments) { let msg = args.to_string(); - let line = render(level, channel, color_enabled(), &now(), &msg); - let stderr = std::io::stderr(); - let mut out = stderr.lock(); - let _ = writeln!(out, "{}", line); + let to_stderr = streams_to_stderr(level); + let line = render(level, channel, color_enabled(to_stderr), &now(), &msg); + if to_stderr { + let mut out = std::io::stderr().lock(); + let _ = writeln!(out, "{}", line); + } else { + let mut out = std::io::stdout().lock(); + let _ = writeln!(out, "{}", line); + } } -/// 是否着色(终端或强制) -fn color_enabled() -> bool { - *COLOR.get_or_init(|| match std::env::var("CHUSQL_LOG_COLOR").as_deref().map(str::trim) { - Ok("always") | Ok("yes") | Ok("1") => true, - Ok("never") | Ok("no") | Ok("0") => false, - _ => std::io::stderr().is_terminal(), - }) +/// 这个等级写 stderr 吗:warn / error 是,info / debug 写 stdout +fn streams_to_stderr(level: Level) -> bool { + matches!(level, Level::Error | Level::Warn) +} + +/// 是否着色:只按要写的那条流是不是终端判断,没有环境变量开关 +fn color_enabled(to_stderr: bool) -> bool { + let cached = if to_stderr { &COLOR_STDERR } else { &COLOR_STDOUT }; + *cached.get_or_init(|| if to_stderr { std::io::stderr().is_terminal() } else { std::io::stdout().is_terminal() }) } /// 拼一行(纯函数,可测) @@ -203,8 +212,8 @@ fn now() -> Stamp { let (year, month, day) = civil_from_days((secs / 86_400) as i64); Stamp { year: year as u16, - month: month as u16, - day: day as u16, + month, + day, hour: ((secs % 86_400) / 3600) as u16, minute: ((secs % 3_600) / 60) as u16, second: (secs % 60) as u16, @@ -346,19 +355,10 @@ mod tests { } #[test] - fn render_without_color_is_plain() { - let stamp = Stamp { - year: 2026, - month: 9, - day: 24, - hour: 1, - minute: 2, - second: 3, - milli: 4, - }; - assert_eq!( - render(Level::Error, Channel::Core, false, &stamp, "boom"), - "[ERROR 2026-09-24 01:02:03.004] [core] boom" - ); + fn warnings_and_errors_go_to_stderr() { + assert!(streams_to_stderr(Level::Error)); + assert!(streams_to_stderr(Level::Warn)); + assert!(!streams_to_stderr(Level::Info)); + assert!(!streams_to_stderr(Level::Debug)); } } diff --git a/chusql-storage/src/protocol.rs b/chusql-storage/src/protocol.rs index 187bcea..6ea5c24 100644 --- a/chusql-storage/src/protocol.rs +++ b/chusql-storage/src/protocol.rs @@ -5,18 +5,55 @@ use std::collections::HashMap; pub type Row = HashMap; -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum ColumnType { - Int, - Str, - Bool, +pub const USERS_TABLE: &str = "__chusql_users"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Account { + pub id: i64, + pub user: String, + pub password_hash: String, + pub revision: u64, + #[serde(default)] + pub registered_at: String, + #[serde(default)] + pub last_login_at: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct SchemaColumn { pub name: String, - pub ty: ColumnType, + pub ty: String, + #[serde(default = "default_true")] + pub nullable: bool, + #[serde(default)] + pub default: Option, + #[serde(default)] + pub auto_increment: bool, + #[serde(default)] + pub primary_key: bool, + #[serde(default)] + pub unique: bool, + #[serde(default)] + pub check: Option, +} + +impl Default for SchemaColumn { + fn default() -> Self { + SchemaColumn { + name: String::new(), + ty: String::new(), + nullable: true, + default: None, + auto_increment: false, + primary_key: false, + unique: false, + check: None, + } + } +} + +fn default_true() -> bool { + true } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -35,9 +72,20 @@ pub struct IndexWire { #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "method", rename_all = "snake_case")] pub enum Request { + AccountsList, + AccountCreate { user: String, password_hash: String }, + AccountReset { user: String, password_hash: String }, + AccountLogin { + user: String, + #[serde(default)] + at: Option, + }, + AccountDrop { user: String }, Ping, Scan { table: String, + #[serde(default)] + columns: Option>, }, Insert { table: String, @@ -55,7 +103,19 @@ pub enum Request { table: String, #[serde(default = "default_id_column")] column: String, - key: i64, + key: serde_json::Value, + }, + RangeByIndex { + table: String, + column: String, + #[serde(default)] + lo: Option, + #[serde(default = "default_true")] + lo_inclusive: bool, + #[serde(default)] + hi: Option, + #[serde(default = "default_true")] + hi_inclusive: bool, }, ReplaceAll { table: String, @@ -85,6 +145,51 @@ pub enum Request { table: String, column: String, }, + Compact { + table: String, + }, + ReplaceSchema { + table: String, + columns: Vec, + rows: Vec, + }, +} + +impl Request { + /// 拒绝可能影响文件路径的列名 + pub fn valid_columns(&self) -> bool { + let valid = |name: &str| !name.is_empty() + && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'); + match self { + Self::CreateIndex { column, .. } | Self::DropIndex { column, .. } + | Self::DropColumn { column, .. } | Self::LookupByIndex { column, .. } + | Self::RangeByIndex { column, .. } => valid(column), + Self::CreateTable { columns, .. } => columns.iter().all(|c| valid(&c.name)), + Self::ReplaceSchema { columns, .. } => columns.iter().all(|c| valid(&c.name)), + _ => true, + } + } + /// 普通表操作的统一访问边界 + pub fn table(&self) -> Option<&str> { + match self { + Self::Scan { table, .. } | Self::Insert { table, .. } + | Self::InsertBatch { table, .. } | Self::DeleteKeys { table, .. } + | Self::LookupByIndex { table, .. } | Self::ReplaceAll { table, .. } + | Self::RangeByIndex { table, .. } + | Self::DescribeTable { table } | Self::CreateTable { table, .. } + | Self::DropTable { table } | Self::CreateIndex { table, .. } + | Self::DropIndex { table, .. } | Self::DropColumn { table, .. } + | Self::Compact { table } + | Self::ReplaceSchema { table, .. } => Some(table), + Self::Ping | Self::ListTables | Self::ListCatalog | Self::AccountsList + | Self::AccountCreate { .. } | Self::AccountReset { .. } | Self::AccountLogin { .. } + | Self::AccountDrop { .. } => None, + } + } +} + +pub fn reserved_table(table: &str) -> bool { + table.to_ascii_lowercase().starts_with("__chusql_") } /// 没写 column 时按 `id` 算 @@ -95,6 +200,7 @@ fn default_id_column() -> String { #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "status", rename_all = "snake_case")] pub enum Response { + Accounts { accounts: Vec }, Pong, Rows { rows: Vec }, Tables { tables: Vec }, diff --git a/chusql-storage/src/wal.rs b/chusql-storage/src/wal.rs index 429d8be..ecae033 100644 --- a/chusql-storage/src/wal.rs +++ b/chusql-storage/src/wal.rs @@ -5,7 +5,7 @@ use std::sync::Mutex; use serde::{Deserialize, Serialize}; -use crate::protocol::Row; +use crate::protocol::{Account, Row, SchemaColumn, USERS_TABLE}; // 预写日志:先记操作并 fsync,再改数据;重启重放残留。 @@ -14,9 +14,16 @@ const OP_REPLACE_ALL: u8 = 2; const OP_INSERT_BATCH: u8 = 3; const OP_DELETE_KEYS: u8 = 4; const OP_DROP_COLUMN: u8 = 5; +const OP_ACCOUNTS: u8 = 6; +const OP_REPLACE_SCHEMA: u8 = 7; +const OP_HIDE_COLUMN: u8 = 8; +const OP_COMPACT: u8 = 9; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum WalOp { + HideColumn { table: String, column: String }, + Compact { table: String, rows: Vec }, + Accounts { accounts: Vec }, Insert { table: String, row: Row, @@ -38,6 +45,11 @@ pub enum WalOp { column: String, rows: Vec, }, + ReplaceSchema { + table: String, + columns: Vec, + rows: Vec, + }, } #[derive(Debug, Serialize, Deserialize)] @@ -46,6 +58,12 @@ struct DropColumnPayload { rows: Vec, } +#[derive(Debug, Serialize, Deserialize)] +struct ReplaceSchemaPayload { + columns: Vec, + rows: Vec, +} + pub struct Wal { path: PathBuf, handle: Mutex>, @@ -134,6 +152,14 @@ fn truncate(f: &mut File) -> io::Result<()> { /// 把操作编成字节 fn encode(op: &WalOp) -> io::Result> { let (op_type, table, payload) = match op { + WalOp::HideColumn { table, column } => (OP_HIDE_COLUMN, table.clone(), + serde_json::to_vec(column).map_err(io::Error::other)?), + WalOp::Compact { table, rows } => { + let p = serde_json::to_vec(rows).map_err(io::Error::other)?; + (OP_COMPACT, table.clone(), p) + } + WalOp::Accounts { accounts } => (OP_ACCOUNTS, USERS_TABLE.to_string(), + serde_json::to_vec(accounts).map_err(io::Error::other)?), WalOp::Insert { table, row } => { let p = serde_json::to_vec(row).map_err(io::Error::other)?; (OP_INSERT, table.clone(), p) @@ -158,6 +184,14 @@ fn encode(op: &WalOp) -> io::Result> { .map_err(io::Error::other)?; (OP_DROP_COLUMN, table.clone(), p) } + WalOp::ReplaceSchema { table, columns, rows } => { + let p = serde_json::to_vec(&ReplaceSchemaPayload { + columns: columns.clone(), + rows: rows.clone(), + }) + .map_err(io::Error::other)?; + (OP_REPLACE_SCHEMA, table.clone(), p) + } }; let table_bytes = table.as_bytes(); @@ -202,6 +236,22 @@ fn decode(buf: &[u8]) -> Result, String> { let payload = &body[11 + table_len..]; let op = match op_type { + OP_HIDE_COLUMN => { + let column = serde_json::from_slice(payload) + .map_err(|e| format!("bad hide_column payload: {e}"))?; + WalOp::HideColumn { table, column } + } + OP_COMPACT => { + let rows: Vec = serde_json::from_slice(payload) + .map_err(|e| format!("bad compact payload: {e}"))?; + WalOp::Compact { table, rows } + } + OP_ACCOUNTS => { + if table != USERS_TABLE { return Err("invalid account WAL table".into()); } + let accounts = serde_json::from_slice(payload) + .map_err(|e| format!("bad account payload: {e}"))?; + WalOp::Accounts { accounts } + } OP_INSERT => { let row: Row = serde_json::from_slice(payload).map_err(|e| format!("bad insert payload: {}", e))?; @@ -231,6 +281,15 @@ fn decode(buf: &[u8]) -> Result, String> { rows: p.rows, } } + OP_REPLACE_SCHEMA => { + let p: ReplaceSchemaPayload = serde_json::from_slice(payload) + .map_err(|e| format!("bad replace_schema payload: {}", e))?; + WalOp::ReplaceSchema { + table, + columns: p.columns, + rows: p.rows, + } + } other => return Err(format!("unknown op type: {}", other)), }; Ok(Some(op)) diff --git a/chusql-storage/tests/btree_test.rs b/chusql-storage/tests/btree_test.rs index 7eac2ab..7f5fa52 100644 --- a/chusql-storage/tests/btree_test.rs +++ b/chusql-storage/tests/btree_test.rs @@ -1,21 +1,24 @@ -use chusql_storage::btree::DiskBTree; +use chusql_storage::btree::{fits_in_page, DiskBTree, MAX_KEY_BYTES}; use chusql_storage::config::{DEFAULT_BTREE_ORDER, DEFAULT_PAGE_SIZE}; use chusql_storage::page::DEFAULT_POOL_SIZE; -// B+ 树测试:插查、重开、分裂、页大小与 order 校验。 +// B+ 树测试:字节键、重复键、范围扫描、重开与配置校验。 + +fn open(dir: &std::path::Path, name: &str) -> DiskBTree { + DiskBTree::open(dir.join(name), DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, DEFAULT_POOL_SIZE).unwrap() +} + +/// 整数键编码成保序的 8 字节(与堆表一致) +fn key(n: i64) -> [u8; 8] { + (n as u64 ^ (1u64 << 63)).to_be_bytes() +} /// 空树查不到,遍历为空 #[test] fn empty_tree_get() { let dir = tempfile::tempdir().unwrap(); - let mut t = DiskBTree::open( - dir.path().join("t.idx"), - DEFAULT_PAGE_SIZE, - DEFAULT_BTREE_ORDER, - DEFAULT_POOL_SIZE, - ) - .unwrap(); - assert_eq!(t.get(1).unwrap(), None); + let mut t = open(dir.path(), "t.idx"); + assert_eq!(t.get(&key(1)).unwrap(), None); assert_eq!(t.iter_all().unwrap(), vec![]); } @@ -23,18 +26,12 @@ fn empty_tree_get() { #[test] fn insert_then_get() { let dir = tempfile::tempdir().unwrap(); - let mut t = DiskBTree::open( - dir.path().join("t.idx"), - DEFAULT_PAGE_SIZE, - DEFAULT_BTREE_ORDER, - DEFAULT_POOL_SIZE, - ) - .unwrap(); + let mut t = open(dir.path(), "t.idx"); for i in 0..100 { - t.insert(i, i as u64 * 10).unwrap(); + t.insert(&key(i), i as u64 * 10).unwrap(); } for i in 0..100 { - assert_eq!(t.get(i).unwrap(), Some(i as u64 * 10)); + assert_eq!(t.get(&key(i)).unwrap(), Some(i as u64 * 10)); } } @@ -46,81 +43,42 @@ fn reopen_persists() { { let mut t = DiskBTree::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, DEFAULT_POOL_SIZE).unwrap(); for i in 0..50 { - t.insert(i, i as u64).unwrap(); + t.insert(&key(i), i as u64).unwrap(); } } let mut t = DiskBTree::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, DEFAULT_POOL_SIZE).unwrap(); for i in 0..50 { - assert_eq!(t.get(i).unwrap(), Some(i as u64)); + assert_eq!(t.get(&key(i)).unwrap(), Some(i as u64)); } } -/// 同一个键后写的值生效 +/// 同一个键可以挂多个位置,按值升序返回 #[test] -fn update_existing() { +fn duplicate_keys_keep_every_value() { let dir = tempfile::tempdir().unwrap(); - let mut t = DiskBTree::open( - dir.path().join("t.idx"), - DEFAULT_PAGE_SIZE, - DEFAULT_BTREE_ORDER, - DEFAULT_POOL_SIZE, - ) - .unwrap(); - t.insert(5, 100).unwrap(); - t.insert(5, 200).unwrap(); - assert_eq!(t.get(5).unwrap(), Some(200)); + let mut t = open(dir.path(), "dup.idx"); + t.insert(b"same", 30).unwrap(); + t.insert(b"same", 10).unwrap(); + t.insert(b"same", 20).unwrap(); + assert_eq!(t.get_all(b"same").unwrap(), vec![10, 20, 30]); + assert_eq!(t.get(b"same").unwrap(), Some(10)); + assert!(t.delete(b"same", 10).unwrap()); + assert_eq!(t.get_all(b"same").unwrap(), vec![20, 30]); + assert!(!t.delete(b"same", 10).unwrap()); } /// 倒序插入后遍历仍升序 #[test] fn iter_all_sorted() { let dir = tempfile::tempdir().unwrap(); - let mut t = DiskBTree::open( - dir.path().join("t.idx"), - DEFAULT_PAGE_SIZE, - DEFAULT_BTREE_ORDER, - DEFAULT_POOL_SIZE, - ) - .unwrap(); + let mut t = open(dir.path(), "t.idx"); for i in (0..50).rev() { - t.insert(i, i as u64).unwrap(); + t.insert(&key(i), i as u64).unwrap(); } let all = t.iter_all().unwrap(); assert_eq!(all.len(), 50); for (i, (k, _)) in all.iter().enumerate() { - assert_eq!(*k, i as i64); - } -} - -/// 倒序插 500 个键都能查到 -#[test] -fn reverse_order_insert() { - let dir = tempfile::tempdir().unwrap(); - let mut t = DiskBTree::open( - dir.path().join("t.idx"), - DEFAULT_PAGE_SIZE, - DEFAULT_BTREE_ORDER, - DEFAULT_POOL_SIZE, - ) - .unwrap(); - for i in (0..500).rev() { - t.insert(i, i as u64).unwrap(); - } - for i in 0..500 { - assert_eq!(t.get(i).unwrap(), Some(i as u64)); - } -} - -/// 512 字节页 + 8 个孩子可用 -#[test] -fn custom_page_size_and_order() { - let dir = tempfile::tempdir().unwrap(); - let mut t = DiskBTree::open(dir.path().join("t.idx"), 512, 8, DEFAULT_POOL_SIZE).unwrap(); - for i in 0..200 { - t.insert(i, i as u64 + 1).unwrap(); - } - for i in 0..200 { - assert_eq!(t.get(i).unwrap(), Some(i as u64 + 1)); + assert_eq!(k.as_slice(), key(i as i64).as_slice()); } } @@ -131,7 +89,7 @@ fn config_mismatch_errors() { let path = dir.path().join("t.idx"); { let mut t = DiskBTree::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, DEFAULT_POOL_SIZE).unwrap(); - t.insert(1, 1).unwrap(); + t.insert(&key(1), 1).unwrap(); } assert!(DiskBTree::open(&path, DEFAULT_PAGE_SIZE, 8, DEFAULT_POOL_SIZE).is_err()); assert!(DiskBTree::open(&path, 8192, DEFAULT_BTREE_ORDER, DEFAULT_POOL_SIZE).is_err()); @@ -152,60 +110,147 @@ fn not_an_index_file_errors() { fn order_too_large_for_page_errors() { let dir = tempfile::tempdir().unwrap(); assert!(DiskBTree::open(dir.path().join("t.idx"), 512, 300, DEFAULT_POOL_SIZE).is_err()); + assert!(!fits_in_page(64, DEFAULT_BTREE_ORDER)); } -/// 删掉键之后查不到,别人不受影响 +/// 删掉一个键值之后查不到,别人不受影响 #[test] fn delete_removes_only_that_key() { let dir = tempfile::tempdir().unwrap(); - let mut t = DiskBTree::open( - dir.path().join("t.idx"), - DEFAULT_PAGE_SIZE, - DEFAULT_BTREE_ORDER, - DEFAULT_POOL_SIZE, - ) - .unwrap(); + let mut t = open(dir.path(), "t.idx"); for i in 0..200 { - t.insert(i, i as u64 + 1).unwrap(); + t.insert(&key(i), i as u64 + 1).unwrap(); } - assert!(t.delete(50).unwrap(), "deleting an existing key should return true"); - assert_eq!(t.get(50).unwrap(), None); - assert_eq!(t.get(49).unwrap(), Some(50)); - assert_eq!(t.get(51).unwrap(), Some(52)); + assert!(t.delete(&key(50), 51).unwrap(), "deleting an existing entry should return true"); + assert_eq!(t.get(&key(50)).unwrap(), None); + assert_eq!(t.get(&key(49)).unwrap(), Some(50)); + assert_eq!(t.get(&key(51)).unwrap(), Some(52)); - assert!(!t.delete(50).unwrap()); + assert!(!t.delete(&key(50), 51).unwrap()); let all = t.iter_all().unwrap(); assert_eq!(all.len(), 199); - assert!(all.iter().all(|(k, _)| *k != 50)); + assert!(all.iter().all(|(k, _)| k.as_slice() != key(50).as_slice())); } /// 删过之后树还能继续插 #[test] fn insert_after_delete_still_works() { let dir = tempfile::tempdir().unwrap(); - let mut t = DiskBTree::open( - dir.path().join("t.idx"), - DEFAULT_PAGE_SIZE, - DEFAULT_BTREE_ORDER, - DEFAULT_POOL_SIZE, - ) - .unwrap(); + let mut t = open(dir.path(), "t.idx"); for i in 0..300 { - t.insert(i, i as u64).unwrap(); + t.insert(&key(i), i as u64).unwrap(); } for i in (0..300).step_by(2) { - assert!(t.delete(i).unwrap()); + assert!(t.delete(&key(i), i as u64).unwrap()); } for i in 1000..1100 { - t.insert(i, i as u64).unwrap(); + t.insert(&key(i), i as u64).unwrap(); } for i in 0..300 { let want = if i % 2 == 0 { None } else { Some(i as u64) }; - assert_eq!(t.get(i).unwrap(), want, "key {}", i); + assert_eq!(t.get(&key(i)).unwrap(), want, "key {}", i); } for i in 1000..1100 { - assert_eq!(t.get(i).unwrap(), Some(i as u64)); + assert_eq!(t.get(&key(i)).unwrap(), Some(i as u64)); } } + +/// 变长键能插能查,最长键也支持 +#[test] +fn variable_length_keys_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let mut t = open(dir.path(), "var.idx"); + t.insert(b"a", 1).unwrap(); + t.insert(b"alice", 2).unwrap(); + t.insert(&[b'z'; MAX_KEY_BYTES], 3).unwrap(); + t.flush().unwrap(); + + assert_eq!(t.get(b"a").unwrap(), Some(1)); + assert_eq!(t.get(b"alice").unwrap(), Some(2)); + assert_eq!(t.get(b"bob").unwrap(), None); + assert_eq!(t.get(&[b'z'; MAX_KEY_BYTES]).unwrap(), Some(3)); + assert_eq!(t.get(&[b'z'; MAX_KEY_BYTES - 1]).unwrap(), None); +} + +/// 超出键长上限直接报错,不写坏节点 +#[test] +fn oversized_key_is_rejected() { + let dir = tempfile::tempdir().unwrap(); + let mut t = open(dir.path(), "long.idx"); + let too_long = vec![b'k'; MAX_KEY_BYTES + 1]; + assert!(t.insert(&too_long, 1).is_err()); + assert_eq!(t.get(&too_long).unwrap(), None); +} + +/// 插满一页后会分裂,顺序仍然正确 +#[test] +fn splits_keep_keys_sorted() { + let dir = tempfile::tempdir().unwrap(); + let mut t = open(dir.path(), "split.idx"); + + let mut expected: Vec> = Vec::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> = 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 = 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 = 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 = 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)); +} diff --git a/chusql-storage/tests/catalog_test.rs b/chusql-storage/tests/catalog_test.rs index 662af90..d83decf 100644 --- a/chusql-storage/tests/catalog_test.rs +++ b/chusql-storage/tests/catalog_test.rs @@ -1,5 +1,5 @@ use chusql_storage::catalog::Catalog; -use chusql_storage::protocol::{ColumnType, Row}; +use chusql_storage::protocol::Row; use serde_json::json; // 数据字典测试:补列、去重、未知表。 @@ -14,13 +14,13 @@ fn row(pairs: &[(&str, serde_json::Value)]) -> Row { m } -/// 空文件得到空字典 +/// 空文件得到空字典,未知表返回 None #[test] -fn empty_file_gives_empty_catalog() { - let dir = tempfile::tempdir().unwrap(); +fn empty_file_gives_empty_catalog_and_unknown_table_returns_none() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("catalog.json"); let c = Catalog::load(&path).unwrap(); assert_eq!(c.table_names().len(), 0); + assert!(c.describe("nope").is_none()); } /// 补列之后能查到 schema @@ -40,9 +40,9 @@ fn ensure_then_describe() { assert!(names.contains(&"id")); assert!(names.contains(&"name")); let id_col = s.columns.iter().find(|c| c.name == "id").unwrap(); - assert_eq!(id_col.ty, ColumnType::Int); + assert_eq!(id_col.ty, "int"); let name_col = s.columns.iter().find(|c| c.name == "name").unwrap(); - assert_eq!(name_col.ty, ColumnType::Str); + assert_eq!(name_col.ty, "str"); } /// 再插入不会重复列 @@ -57,15 +57,6 @@ fn second_insert_does_not_duplicate_columns() { assert_eq!(c.describe("users").unwrap().columns.len(), 1); } -/// 未知表返回 None -#[test] -fn describe_unknown_returns_none() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("catalog.json"); - let c = Catalog::load(&path).unwrap(); - assert!(c.describe("nope").is_none()); -} - /// 删列同时清掉统计与索引 #[test] fn remove_column_drops_definition_stats_and_index() { @@ -83,10 +74,44 @@ fn remove_column_drops_definition_stats_and_index() { assert_eq!(s.columns.len(), 1); assert_eq!(s.columns[0].name, "id"); assert!(s.indexes.is_empty(), "index on code should be gone"); - assert!(s.stats.get("code").is_none(), "stats for code should be gone"); + assert!(!s.stats.contains_key("code"), "stats for code should be gone"); let again = c.remove_column("t", "code").unwrap(); assert!(!again); assert!(c.remove_column("nope", "code").is_err()); } + +/// 老类型名并到新写法,参数保留,未知类型原样 +#[test] +fn normalize_type_maps_legacy_names() { + use chusql_storage::catalog::normalize_type; + assert_eq!(normalize_type("integer"), "int"); + assert_eq!(normalize_type("TEXT"), "str"); + assert_eq!(normalize_type("BOOLEAN"), "bool"); + assert_eq!(normalize_type("varchar(20)"), "varchar(20)"); + assert_eq!(normalize_type("numeric(8,2)"), "decimal(8,2)"); + assert_eq!(normalize_type("real"), "float"); + assert_eq!(normalize_type("weird"), "weird"); +} + +/// 老格式字典(列里没有新字段)能读进来,规范化后写回新写法 +#[test] +fn legacy_catalog_loads_and_normalizes() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("catalog.json"); + std::fs::write( + &path, + r#"{"tables":{"legacy":{"columns":[{"name":"id","ty":"integer"},{"name":"name","ty":"text"}]}}}"#, + ) + .unwrap(); + + let mut c = Catalog::load(&path).unwrap(); + let s = c.describe("legacy").unwrap(); + assert_eq!(s.columns.len(), 2); + assert!(s.columns[0].nullable, "老列默认可空"); + assert!(c.normalize_types()); + let types: Vec<&str> = c.describe("legacy").unwrap().columns.iter().map(|col| col.ty.as_str()).collect(); + assert_eq!(types, vec!["int", "str"]); + assert!(!c.normalize_types(), "第二次没有可改的"); +} diff --git a/chusql-storage/tests/config_test.rs b/chusql-storage/tests/config_test.rs index 457be22..5b8900a 100644 --- a/chusql-storage/tests/config_test.rs +++ b/chusql-storage/tests/config_test.rs @@ -1,23 +1,32 @@ use std::path::PathBuf; +use std::sync::Mutex; use chusql_storage::config::{ - self, Config, DEFAULT_BTREE_ORDER, DEFAULT_DATA_DIR, DEFAULT_PAGE_SIZE, DEFAULT_PIPE_NAME, - Origin, + self, Config, DEFAULT_BTREE_ORDER, DEFAULT_PAGE_SIZE, DEFAULT_PIPE_NAME, Origin, }; use chusql_storage::log::Level; -// 配置测试:默认值、文件覆盖、环境变量优先、非法值报错。 +// 配置测试:默认值、文件覆盖、环境变量彻底无效、非法值报错。 + +/// set_var 改的是进程环境,用例之间要串行,免得互相污染 +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +/// 拿锁(前面的用例 panic 过也照样拿) +fn env_guard() -> std::sync::MutexGuard<'static, ()> { + ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()) +} /// 无文件时全默认 #[test] fn defaults_without_file() { - let loaded = config::resolve(None, None, &|_| None).unwrap(); + let _guard = env_guard(); + let loaded = config::resolve(None, None).unwrap(); assert_eq!(loaded.config, Config::default()); assert!(loaded.config_path.is_none()); assert!(loaded.origins.iter().all(|(_, _, o)| *o == Origin::Default)); assert_eq!(loaded.config.page_size, DEFAULT_PAGE_SIZE); assert_eq!(loaded.config.btree_order, DEFAULT_BTREE_ORDER); - assert_eq!(loaded.config.data_dir, PathBuf::from(DEFAULT_DATA_DIR)); + assert_eq!(loaded.config.data_dir, config::default_data_dir()); assert_eq!(loaded.config.pipe_name, DEFAULT_PIPE_NAME); assert_eq!(loaded.config.log_level, Level::Info); } @@ -25,13 +34,14 @@ fn defaults_without_file() { /// 只覆盖文件里写了的项 #[test] fn file_overrides_only_written_keys() { + let _guard = env_guard(); let path = PathBuf::from("t.toml"); - let loaded = config::resolve(Some("[page]\nsize = 8192\n"), Some(path.clone()), &|_| None) + let loaded = config::resolve(Some("[page]\nsize = 8192\n"), Some(path.clone())) .unwrap(); assert_eq!(loaded.config.page_size, 8192); assert_eq!(loaded.config.btree_order, DEFAULT_BTREE_ORDER); - assert_eq!(loaded.config.data_dir, PathBuf::from(DEFAULT_DATA_DIR)); + assert_eq!(loaded.config.data_dir, config::default_data_dir()); assert_eq!(loaded.config_path, Some(path.clone())); let page = loaded.origins.iter().find(|(n, _, _)| *n == "page.size").unwrap(); @@ -43,9 +53,10 @@ fn file_overrides_only_written_keys() { /// 六项全写都生效 #[test] fn file_all_keys() { + let _guard = env_guard(); let text = "[page]\nsize = 8192\n[btree]\norder = 32\n[buffer]\npool_size = 128\n[storage]\ndata_dir = \"mydata\"\n[server]\npipe_name = \"mypipe\"\n[log]\nlevel = \"debug\"\n"; let path = PathBuf::from("t.toml"); - let loaded = config::resolve(Some(text), Some(path.clone()), &|_| None).unwrap(); + let loaded = config::resolve(Some(text), Some(path.clone())).unwrap(); assert_eq!(loaded.config.page_size, 8192); assert_eq!(loaded.config.btree_order, 32); @@ -61,71 +72,161 @@ fn file_all_keys() { ); } -/// 环境变量优先于文件 +/// 环境变量完全无效:这条通道已经删掉,设了 CHUSQL_* 也只认文件/内置默认 #[test] -fn env_beats_file() { - let text = "[storage]\ndata_dir = \"from-file\"\n"; - let env = |k: &str| (k == "CHUSQL_DATA_DIR").then(|| "from-env".to_string()); - let loaded = config::resolve(Some(text), Some(PathBuf::from("t.toml")), &env).unwrap(); +fn env_vars_are_ignored_entirely() { + let _guard = env_guard(); + unsafe { + std::env::set_var("CHUSQL_DATA_DIR", "from-env"); + std::env::set_var("CHUSQL_PAGE_SIZE", "8192"); + } - assert_eq!(loaded.config.data_dir, PathBuf::from("from-env")); + let text = "[storage]\ndata_dir = \"from-file\"\n"; + let loaded = config::resolve(Some(text), Some(PathBuf::from("t.toml"))).unwrap(); + assert_eq!(loaded.config.data_dir, PathBuf::from("from-file")); + assert_eq!(loaded.config.page_size, DEFAULT_PAGE_SIZE); let dir = loaded .origins .iter() .find(|(n, _, _)| *n == "storage.data_dir") .unwrap(); - assert_eq!(dir.2, Origin::Env("CHUSQL_DATA_DIR")); + assert_eq!(dir.2, Origin::File(PathBuf::from("t.toml"))); + + let builtin = config::resolve(None, None).unwrap(); + assert_eq!(builtin.config.data_dir, config::default_data_dir()); + assert_eq!(builtin.config.page_size, DEFAULT_PAGE_SIZE); + + unsafe { + std::env::remove_var("CHUSQL_DATA_DIR"); + std::env::remove_var("CHUSQL_PAGE_SIZE"); + } +} + +/// 固定路径按系统惯例:<配置目录>/ChuSQL/chusql.toml +#[test] +fn default_config_path_follows_system_convention() { + let _guard = env_guard(); + let path = config::default_config_path(); + assert_eq!(path.file_name().unwrap(), "chusql.toml"); + assert_eq!(path.parent().unwrap().file_name().unwrap(), "ChuSQL"); + assert!(path.ends_with(format!("ChuSQL{}chusql.toml", std::path::MAIN_SEPARATOR)), "got {}", path.display()); + + // 本机 APPDATA 存在时必须是 %APPDATA%\ChuSQL\chusql.toml + #[cfg(windows)] + if let Some(appdata) = std::env::var_os("APPDATA") { + assert_eq!( + path, + PathBuf::from(appdata).join("ChuSQL").join("chusql.toml") + ); + } +} + +/// 默认数据目录也按系统惯例:Windows 落 %LOCALAPPDATA%\ChuSQL\data,Unix 落 XDG 数据目录 +#[test] +fn default_data_dir_follows_system_convention() { + let _guard = env_guard(); + let dir = config::default_data_dir(); + let expected_parent = if cfg!(windows) { "ChuSQL" } else { "chusql" }; + assert_eq!(dir.file_name().unwrap(), "data"); + assert_eq!(dir.parent().unwrap().file_name().unwrap(), expected_parent); + // 老的内置默认是仓库里的相对路径 ../localdata,现在不能再指回仓库 + assert_ne!(dir, PathBuf::from("../localdata")); + + #[cfg(windows)] + if let Some(local) = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()) { + assert_eq!(dir, PathBuf::from(local).join("ChuSQL").join("data")); + } else if let Some(roaming) = std::env::var_os("APPDATA").filter(|v| !v.is_empty()) { + assert_eq!(dir, PathBuf::from(roaming).join("ChuSQL").join("data")); + } + + #[cfg(not(windows))] + if let Some(xdg) = std::env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty()) { + assert_eq!(dir, PathBuf::from(xdg).join("chusql").join("data")); + } else if let Some(home) = std::env::var_os("HOME").filter(|v| !v.is_empty()) { + assert_eq!( + dir, + PathBuf::from(home) + .join(".local") + .join("share") + .join("chusql") + .join("data") + ); + } +} + +/// 显式给了一个不存在的路径:必须报错,不能静默回退默认 +#[test] +fn explicit_missing_path_is_an_error() { + let _guard = env_guard(); + let missing = std::env::temp_dir().join("chusql-no-such-config-9f21.toml"); + assert!(!missing.is_file()); + + let err = config::load(Some(missing.to_str().unwrap())).unwrap_err(); + assert!( + err.contains("config file not found") || err.contains("cannot read config file"), + "got {}", + err + ); + assert!(err.contains("chusql-no-such-config-9f21.toml"), "got {}", err); +} + +/// 不给 --config:固定路径没有文件时全用内置默认,且不报错 +#[test] +fn load_without_explicit_path_uses_builtin_defaults() { + let _guard = env_guard(); + let default = config::default_config_path(); + let loaded = config::load(None).unwrap(); + if default.is_file() { + assert_eq!(loaded.config_path, Some(default)); + } else { + assert_eq!(loaded.config, Config::default()); + assert_eq!(loaded.config.data_dir, config::default_data_dir()); + assert_eq!(loaded.config.pipe_name, DEFAULT_PIPE_NAME); + assert!(loaded.origins.iter().all(|(_, _, o)| *o == Origin::Default)); + assert!(loaded.config_path.is_none()); + } } /// 日志等级非法要报错 #[test] fn rejects_bad_log_level() { - let err = config::resolve(Some("[log]\nlevel = \"loud\"\n"), None, &|_| None).unwrap_err(); + let _guard = env_guard(); + let err = config::resolve(Some("[log]\nlevel = \"loud\"\n"), None).unwrap_err(); assert!(err.contains("log.level"), "got {}", err); } -/// 页大小非法要报错 +/// 页大小或 order 非法都要报错 #[test] -fn rejects_bad_page_size() { - let err = config::resolve(Some("[page]\nsize = 3000\n"), None, &|_| None).unwrap_err(); - assert!(err.contains("page.size"), "got {}", err); +fn rejects_bad_numeric_limits() { + let _guard = env_guard(); + for text in ["[page]\nsize = 3000\n", "[page]\nsize = 128\n"] { + let err = config::resolve(Some(text), None).unwrap_err(); + assert!(err.contains("page.size"), "{}: {}", text, err); + } - let err = config::resolve(Some("[page]\nsize = 128\n"), None, &|_| None).unwrap_err(); - assert!(err.contains("page.size"), "got {}", err); -} - -/// order 非法要报错 -#[test] -fn rejects_bad_btree_order() { - let err = config::resolve(Some("[btree]\norder = 2\n"), None, &|_| None).unwrap_err(); + let err = config::resolve(Some("[btree]\norder = 2\n"), None).unwrap_err(); assert!(err.contains("btree.order"), "got {}", err); - let err = config::resolve(Some("[btree]\norder = 300\n"), None, &|_| None).unwrap_err(); + let err = config::resolve(Some("[btree]\norder = 300\n"), None).unwrap_err(); assert!(err.contains("does not fit"), "got {}", err); } /// 未知配置键要报错 #[test] fn rejects_unknown_key() { - let err = config::resolve(Some("[page]\nsizes = 4096\n"), None, &|_| None).unwrap_err(); + let _guard = env_guard(); + let err = config::resolve(Some("[page]\nsizes = 4096\n"), None).unwrap_err(); assert!(err.contains("bad config"), "got {}", err); } -/// 环境变量非法要报错 +/// 坏的页面尺寸只能来自文件 #[test] -fn rejects_bad_env_value() { - let env = |k: &str| (k == "CHUSQL_PAGE_SIZE").then(|| "big".to_string()); - let err = config::resolve(None, None, &env).unwrap_err(); - assert!(err.contains("CHUSQL_PAGE_SIZE"), "got {}", err); -} +fn rejects_bad_page_size_from_file() { + let _guard = env_guard(); + unsafe { std::env::set_var("CHUSQL_PAGE_SIZE", "big") }; -/// 来源文案正确 -#[test] -fn origin_description() { - assert_eq!(Origin::Default.describe(), "default"); - assert_eq!(Origin::Env("CHUSQL_PIPE").describe(), "env CHUSQL_PIPE"); - assert_eq!( - Origin::File(PathBuf::from("a.toml")).describe(), - format!("file {}", PathBuf::from("a.toml").display()) - ); + let err = config::resolve(Some("[page]\nsize = 3000\n"), None).unwrap_err(); + assert!(err.contains("page.size"), "got {}", err); + + unsafe { std::env::remove_var("CHUSQL_PAGE_SIZE") }; } diff --git a/chusql-storage/tests/heap_test.rs b/chusql-storage/tests/heap_test.rs index 1787c97..32af9a1 100644 --- a/chusql-storage/tests/heap_test.rs +++ b/chusql-storage/tests/heap_test.rs @@ -14,20 +14,62 @@ fn row(pairs: &[(&str, serde_json::Value)]) -> Row { m } -/// 插一行再扫描读回 +/// 索引文件被清空(强杀或截断)后打开,会按堆数据补齐 #[test] -fn insert_and_scan_one_row() { +fn empty_index_file_is_rebuilt_from_rows() { let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("t.db"); - let mut t = HeapTable::open(&path, DEFAULT_PAGE_SIZE, DEFAULT_POOL_SIZE).unwrap(); + let data = dir.path().join("t.db"); + let index = dir.path().join("t.idx"); + { + let mut t = HeapTable::open_indexed(&data, &index, DEFAULT_PAGE_SIZE, 4, DEFAULT_POOL_SIZE).unwrap(); + for i in 1..=5 { + t.insert_row(&row(&[("id", json!(i))])).unwrap(); + } + t.flush().unwrap(); + } + std::fs::write(&index, b"").unwrap(); + let mut t = HeapTable::open_indexed(&data, &index, DEFAULT_PAGE_SIZE, 4, DEFAULT_POOL_SIZE).unwrap(); + assert!(t.get_by_column_key("id", 3).unwrap().is_some(), "空索引文件要从堆数据重建"); + assert!(t.get_by_column_key("id", 9).unwrap().is_none()); +} - t.insert_row(&row(&[("id", json!(1)), ("name", json!("Alice"))])) - .unwrap(); +/// 字符串列能真正建索引:按值点查,NULL 不入索引 +#[test] +fn string_index_looks_up_by_value() { + let dir = tempfile::tempdir().unwrap(); + let data = dir.path().join("s.db"); + let index = dir.path().join("s.idx"); + let mut t = HeapTable::open(&data, DEFAULT_PAGE_SIZE, DEFAULT_POOL_SIZE).unwrap(); + t.insert_row(&row(&[("id", json!(1)), ("name", json!("alice"))])).unwrap(); + t.insert_row(&row(&[("id", json!(2)), ("name", json!("bob"))])).unwrap(); + t.insert_row(&row(&[("id", json!(3)), ("name", json!(null))])).unwrap(); + t.build_index("name", &index, DEFAULT_PAGE_SIZE, 4, DEFAULT_POOL_SIZE).unwrap(); - let rows = t.scan().unwrap(); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0]["id"], json!(1)); - assert_eq!(rows[0]["name"], json!("Alice")); + assert_eq!(t.get_by_string_key("name", "bob").unwrap().unwrap()["id"], json!(2)); + assert!(t.get_by_string_key("name", "dave").unwrap().is_none()); + assert!(t.get_by_string_key("name", "").unwrap().is_none(), "NULL 不入索引"); +} + +/// 允许重复值建索引:点查把重复的行都取回来 +#[test] +fn duplicate_values_are_indexed_and_all_returned() { + let dir = tempfile::tempdir().unwrap(); + let data = dir.path().join("d.db"); + let index = dir.path().join("d.idx"); + let mut t = HeapTable::open(&data, DEFAULT_PAGE_SIZE, DEFAULT_POOL_SIZE).unwrap(); + t.insert_row(&row(&[("id", json!(1)), ("name", json!("alice"))])).unwrap(); + t.insert_row(&row(&[("id", json!(2)), ("name", json!("alice"))])).unwrap(); + t.build_index("name", &index, DEFAULT_PAGE_SIZE, 4, DEFAULT_POOL_SIZE).unwrap(); + + let mut ids: Vec = t + .get_all_by_string_key("name", "alice") + .unwrap() + .iter() + .filter_map(|r| r["id"].as_i64()) + .collect(); + ids.sort_unstable(); + assert_eq!(ids, vec![1, 2]); + assert!(t.get_all_by_string_key("name", "bob").unwrap().is_empty()); } /// 500 行跨页顺序不变 @@ -94,22 +136,6 @@ fn reopen_and_scan() { assert_eq!(rows[1]["id"], json!(2)); } -/// 512 字节页可用 -#[test] -fn custom_page_size() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("small.db"); - let mut t = HeapTable::open(&path, 512, DEFAULT_POOL_SIZE).unwrap(); - - for i in 0..50 { - t.insert_row(&row(&[("id", json!(i))])).unwrap(); - } - assert_eq!(t.scan().unwrap().len(), 50); - - let big = "x".repeat(600); - assert!(t.insert_row(&row(&[("data", json!(big))])).is_err()); -} - /// 整表改写后索引重建 #[test] fn replace_all_rebuilds_index() { @@ -246,40 +272,34 @@ fn secondary_index_is_maintained() { t.delete_by_keys(&[2]).unwrap(); assert!(t.get_by_column_key("code", 7002).unwrap().is_none()); - assert!(t.insert_row(&row(&[("id", json!(4)), ("code", json!(7001))])).is_err()); - let mut t2 = HeapTable::open(&dir.path().join("d.db"), DEFAULT_PAGE_SIZE, DEFAULT_POOL_SIZE).unwrap(); + t.insert_row(&row(&[("id", json!(4)), ("code", json!(7001))])).unwrap(); + let mut dup_ids: Vec = t + .get_all_by_column_key("code", 7001) + .unwrap() + .iter() + .filter_map(|r| r["id"].as_i64()) + .collect(); + dup_ids.sort_unstable(); + assert_eq!(dup_ids, vec![1, 4], "duplicate index values keep both rows"); + + let mut t2 = HeapTable::open(dir.path().join("d.db"), DEFAULT_PAGE_SIZE, DEFAULT_POOL_SIZE).unwrap(); t2.insert_rows(&[ row(&[("id", json!(1)), ("age", json!(30))]), row(&[("id", json!(2)), ("age", json!(30))]), ]) .unwrap(); - let err = t2.build_index( + t2.build_index( "age", &dir.path().join("d.age.idx"), DEFAULT_PAGE_SIZE, chusql_storage::config::DEFAULT_BTREE_ORDER, DEFAULT_POOL_SIZE, - ); - assert!(err.is_err(), "duplicate values must not be indexed"); - assert!(!t2.has_index("age"), "no half-built index after failure"); -} - -/// 同一个 id 插两次要报错 -#[test] -fn duplicate_key_is_rejected() { - let dir = tempfile::tempdir().unwrap(); - let mut t = HeapTable::open_indexed( - dir.path().join("t.db"), - dir.path().join("t.idx"), - DEFAULT_PAGE_SIZE, - chusql_storage::config::DEFAULT_BTREE_ORDER, - DEFAULT_POOL_SIZE, ) .unwrap(); - - t.insert_row(&row(&[("id", json!(1))])).unwrap(); - let r = t.insert_row(&row(&[("id", json!(1))])); - assert!(r.is_err()); - assert_eq!(t.scan().unwrap().len(), 1, "the failed row should not reach the heap"); + assert_eq!( + t2.get_all_by_column_key("age", 30).unwrap().len(), + 2, + "duplicate values are all indexed" + ); } diff --git a/chusql-storage/tests/page_test.rs b/chusql-storage/tests/page_test.rs index d68a086..bda3293 100644 --- a/chusql-storage/tests/page_test.rs +++ b/chusql-storage/tests/page_test.rs @@ -49,25 +49,6 @@ fn append_grows_file() { assert_eq!(pf.num_pages().unwrap(), 3); } -/// 两页内容互不影响 -#[test] -fn two_pages_independent() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("test.db"); - - let mut pf = PageFile::open(&path, DEFAULT_PAGE_SIZE).unwrap(); - - let mut a = Page::new(0, DEFAULT_PAGE_SIZE); - a.data[0] = 1; - let mut b = Page::new(1, DEFAULT_PAGE_SIZE); - b.data[0] = 2; - pf.write_page(&a).unwrap(); - pf.write_page(&b).unwrap(); - - assert_eq!(pf.read_page(0).unwrap().data[0], 1); - assert_eq!(pf.read_page(1).unwrap().data[0], 2); -} - /// 覆写同页读到新内容 #[test] fn overwrite_page() { diff --git a/chusql-storage/tests/pipe_test.rs b/chusql-storage/tests/pipe_test.rs index abf94ce..528375e 100644 --- a/chusql-storage/tests/pipe_test.rs +++ b/chusql-storage/tests/pipe_test.rs @@ -3,13 +3,306 @@ use std::process::{Child, Command, Stdio}; use std::thread; use std::time::Duration; -use interprocess::local_socket::{ - prelude::*, - GenericNamespaced -}; +use interprocess::local_socket::prelude::*; use interprocess::TryClone; -// 命名管道集成测试:起真 server,跑协议、索引与配置。 +// 端点集成测试:起真 server,跑协议、索引与配置(端点由 endpoint 模块统一算)。 + +#[test] +fn scan_projects_columns_and_preserves_empty_rows() -> Result<(), Box> { + let pipe = unique_pipe_name(); + let (_srv, _data) = start_server(&pipe); + let mut c = connect(&pipe)?; + request_ok(&mut c, serde_json::json!({"method":"create_table","table":"items","columns":[{"name":"id","ty":"int"},{"name":"name","ty":"str"}]}))?; + request_ok(&mut c, serde_json::json!({"method":"insert_batch","table":"items","rows":[{"id":1,"name":"one"},{"id":2,"name":null}]}))?; + for (columns, expected) in [ + (serde_json::json!(["name"]), serde_json::json!([{"name":"one"},{"name":null}])), + (serde_json::json!([]), serde_json::json!([{},{}])), + ] { + let result: serde_json::Value = serde_json::from_str(&send(&mut c, &serde_json::json!({"method":"scan","table":"items","columns":columns}).to_string()))?; + assert_eq!(result["rows"], expected); + } + let bad: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items","columns":["missing"]}"#))?; + assert_eq!(bad["status"], "error"); + Ok(()) +} + +#[test] +fn drop_column_keeps_heap_bytes_and_hides_values_after_restart() -> Result<(), Box> { + let pipe = unique_pipe_name(); + let (server, data) = start_server(&pipe); + let mut c = connect(&pipe)?; + request_ok(&mut c, serde_json::json!({"method":"create_table","table":"items","columns":[{"name":"id","ty":"int"},{"name":"name","ty":"str"},{"name":"age","ty":"int"}]}))?; + request_ok(&mut c, serde_json::json!({"method":"insert_batch","table":"items","rows":[{"id":1,"name":"old","age":20},{"id":2,"name":"secret","age":30}]}))?; + for column in ["name", "age"] { + request_ok(&mut c, serde_json::json!({"method":"create_index","table":"items","column":column}))?; + } + let heap = data.path().join("databases/main/items.db"); + let before = std::fs::read(&heap)?; + request_ok(&mut c, serde_json::json!({"method":"drop_column","table":"items","column":"name"}))?; + assert_eq!(std::fs::read(&heap)?, before, "DROP COLUMN must not rewrite heap pages"); + drop(c); + drop(server); + let _server = start_server_in(&pipe, data.path()); + let mut c = connect(&pipe)?; + for request in [ + serde_json::json!({"method":"scan","table":"items"}), + serde_json::json!({"method":"range_by_index","table":"items","column":"age","lo":0}), + ] { + let result: serde_json::Value = serde_json::from_str(&send(&mut c, &request.to_string()))?; + assert_eq!(result["rows"], serde_json::json!([{"id":1,"age":20},{"id":2,"age":30}])); + } + let lookup: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"lookup_by_index","table":"items","column":"id","key":1}"#))?; + assert_eq!(lookup["rows"], serde_json::json!([{"id":1,"age":20}])); + let schema: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"describe_table","table":"items"}"#))?; + assert_eq!(schema["row_count"], 2); + assert!(schema["columns"].as_array().unwrap().iter().all(|col| col["name"] != "name")); + assert!(schema["stats"].as_array().unwrap().iter().all(|col| col["name"] != "name")); + let bad: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"insert","table":"items","row":{"id":3,"name":"resurrect"}}"#))?; + assert_eq!(bad["status"], "error"); + request_ok(&mut c, serde_json::json!({"method":"insert","table":"items","row":{"id":3,"age":40}}))?; + Ok(()) +} + +#[test] +fn databases_isolate_catalog_and_rows() -> Result<(), Box> { + let pipe = unique_pipe_name(); + let (_srv, data) = start_server(&pipe); + let mut c = connect(&pipe)?; + let request = |c: &mut LocalSocketStream, value: serde_json::Value| -> Result { + serde_json::from_str(&send(c, &value.to_string())) + }; + for database in ["alpha", "beta"] { + assert_eq!(request(&mut c, serde_json::json!({"method":"create_database","database":database}))?["status"], "ok"); + assert_eq!(request(&mut c, serde_json::json!({"method":"create_table","database":database,"table":"items","columns":[{"name":"id","ty":"int"}]}))?["status"], "ok"); + } + assert_eq!(request(&mut c, serde_json::json!({"method":"insert","database":"alpha","table":"items","row":{"id":1}}))?["status"], "ok"); + assert_eq!(request(&mut c, serde_json::json!({"method":"scan","database":"alpha","table":"items"}))?["rows"], serde_json::json!([{"id":1}])); + assert_eq!(request(&mut c, serde_json::json!({"method":"scan","database":"beta","table":"items"}))?["rows"], serde_json::json!([])); + assert_eq!(request(&mut c, serde_json::json!({"method":"list_tables"}))?["tables"], serde_json::json!([])); + assert!(data.path().join("databases/main/catalog.json").is_file()); + assert!(data.path().join("databases/alpha/catalog.json").is_file()); + assert!(data.path().join("databases/beta/catalog.json").is_file()); + assert_eq!(request(&mut c, serde_json::json!({"method":"drop_database","database":"alpha"}))?["status"], "ok"); + assert_eq!(request(&mut c, serde_json::json!({"method":"scan","database":"alpha","table":"items"}))?["status"], "error"); + assert_eq!(request(&mut c, serde_json::json!({"method":"scan","database":"beta","table":"items"}))?["rows"], serde_json::json!([])); + Ok(()) +} + + +#[test] +fn hidden_column_wal_replays_twice_without_touching_heap() -> Result<(), Box> { + use chusql_storage::wal::{Wal, WalOp}; + let pipe = unique_pipe_name(); + let (server, data) = start_server(&pipe); + let mut c = connect(&pipe)?; + request_ok(&mut c, serde_json::json!({"method":"insert","table":"items","row":{"id":1,"secret":"old"}}))?; + drop(c); + drop(server); + let heap = data.path().join("databases/main/items.db"); + let mut compacted: Option> = None; + for round in 0..2 { + let wal = Wal::new(data.path().join("databases/main/wal.log")); + let op = WalOp::HideColumn { table: "items".into(), column: "secret".into() }; + wal.truncate()?; + wal.append(&op)?; + assert_eq!(wal.read()?, Some(op)); + assert!(std::fs::metadata(wal.path())?.len() < 100); + drop(wal); + let server = start_server_in(&pipe, data.path()); + let mut c = connect(&pipe)?; + let result: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?; + assert_eq!(result["rows"], serde_json::json!([{"id":1}])); + let after = std::fs::read(&heap)?; + assert!( + !after.windows(6).any(|w| w == &b"secret"[..]), + "启动整理要把隐藏列的字节清掉" + ); + if round == 1 { + assert_eq!(Some(after.clone()), compacted, "整理过的表第二次启动不该再重写"); + } + compacted = Some(after); + assert_eq!(std::fs::metadata(data.path().join("databases/main/wal.log"))?.len(), 0); + drop(c); + drop(server); + } + Ok(()) +} + +#[test] +fn hidden_column_failure_preserves_wal_until_recovery() -> Result<(), Box> { + let pipe = unique_pipe_name(); + let (server, data) = start_server(&pipe); + let mut c = connect(&pipe)?; + request_ok(&mut c, serde_json::json!({"method":"insert","table":"items","row":{"id":1,"secret":"old"}}))?; + let index = data.path().join("databases/main/items.secret.idx"); + std::fs::create_dir(&index)?; + let failed: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"drop_column","table":"items","column":"secret"}"#))?; + assert_eq!(failed["status"], "error"); + assert!(std::fs::metadata(data.path().join("databases/main/wal.log"))?.len() > 0); + let blocked: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"insert","table":"items","row":{"id":2}}"#))?; + assert_eq!(blocked["status"], "error"); + assert!(blocked["message"].as_str().unwrap().contains("recovery required")); + drop(c); + drop(server); + std::fs::remove_dir(index)?; + let _server = start_server_in(&pipe, data.path()); + let mut c = connect(&pipe)?; + let rows: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?; + assert_eq!(rows["rows"], serde_json::json!([{"id":1}])); + Ok(()) +} + +#[test] +fn drop_column_with_a_live_index_removes_the_index_file() -> Result<(), Box> { + let pipe = unique_pipe_name(); + let (_srv, data) = start_server(&pipe); + let mut c = connect(&pipe)?; + request_ok(&mut c, serde_json::json!({"method":"insert","table":"items","row":{"id":1,"code":7}}))?; + request_ok(&mut c, serde_json::json!({"method":"create_index","table":"items","column":"code"}))?; + let index = data.path().join("databases/main/items.code.idx"); + assert!(index.is_file(), "index file exists after create_index"); + + let dropped: serde_json::Value = serde_json::from_str(&send( + &mut c, + r#"{"method":"drop_column","table":"items","column":"code"}"#, + ))?; + assert_eq!(dropped["status"], "ok", "drop_column with a live index: {dropped}"); + assert!(!index.exists(), "index file is removed"); + + let rows: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?; + assert_eq!(rows["rows"], serde_json::json!([{"id":1}])); + request_ok(&mut c, serde_json::json!({"method":"insert","table":"items","row":{"id":2}}))?; + Ok(()) +} + +#[test] +fn compact_reclaims_hidden_column_bytes() -> Result<(), Box> { + let pipe = unique_pipe_name(); + let (server, data) = start_server(&pipe); + let mut c = connect(&pipe)?; + for id in 1..=3 { + request_ok(&mut c, serde_json::json!({"method":"insert","table":"items","row":{"id":id,"secret":"padding","note":"n"}}))?; + } + request_ok(&mut c, serde_json::json!({"method":"drop_column","table":"items","column":"secret"}))?; + let heap = data.path().join("databases/main/items.db"); + assert!(std::fs::read(&heap)?.windows(6).any(|w| w == &b"secret"[..]), "fast path keeps the bytes"); + + request_ok(&mut c, serde_json::json!({"method":"compact","table":"items"}))?; + assert!(!std::fs::read(&heap)?.windows(6).any(|w| w == &b"secret"[..]), "compaction rewrites the pages"); + assert_eq!(std::fs::metadata(data.path().join("databases/main/wal.log"))?.len(), 0); + + let rows: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?; + assert_eq!( + rows["rows"], + serde_json::json!([{"id":1,"note":"n"},{"id":2,"note":"n"},{"id":3,"note":"n"}]) + ); + request_ok(&mut c, serde_json::json!({"method":"insert","table":"items","row":{"id":4,"note":"x"}}))?; + drop(c); + drop(server); + let _server = start_server_in(&pipe, data.path()); + let mut c = connect(&pipe)?; + let rows: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?; + assert_eq!(rows["rows"].as_array().map(Vec::len), Some(4), "rows survive compaction: {}", rows); + Ok(()) +} + +#[test] +fn startup_compaction_reclaims_dropped_columns() -> Result<(), Box> { + let pipe = unique_pipe_name(); + let (server, data) = start_server(&pipe); + let mut c = connect(&pipe)?; + request_ok(&mut c, serde_json::json!({"method":"insert","table":"items","row":{"id":1,"secret":"padding"}}))?; + request_ok(&mut c, serde_json::json!({"method":"drop_column","table":"items","column":"secret"}))?; + let heap = data.path().join("databases/main/items.db"); + assert!(std::fs::read(&heap)?.windows(6).any(|w| w == &b"secret"[..])); + drop(c); + drop(server); + + let _server = start_server_in(&pipe, data.path()); + let mut c = connect(&pipe)?; + assert!( + !std::fs::read(&heap)?.windows(6).any(|w| w == &b"secret"[..]), + "startup must compact tables that still carry hidden columns" + ); + let rows: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?; + assert_eq!(rows["rows"], serde_json::json!([{"id":1}])); + Ok(()) +} + +#[test] +fn databases_reject_unsafe_names_and_keep_only_system_reserved() -> Result<(), Box> { + let pipe = unique_pipe_name(); + let (_srv, data) = start_server(&pipe); + let mut c = connect(&pipe)?; + for database in ["", "../escape", "a/b", "a.b", "CON", "__chusql_users"] { + let response: serde_json::Value = serde_json::from_str(&send(&mut c, &serde_json::json!({"method":"create_database","database":database}).to_string()))?; + assert_eq!(response["status"], "error", "{database}: {response}"); + } + // system 是唯一的保留库:不能建、不能删 + for method in ["create_database", "drop_database"] { + let response: serde_json::Value = serde_json::from_str(&send(&mut c, &serde_json::json!({"method":method,"database":"system"}).to_string()))?; + assert_eq!(response["status"], "error", "{method}: {response}"); + } + // test 不再是保留名:能建、也能删(不再有默认工作库) + let created: serde_json::Value = serde_json::from_str(&send(&mut c, &serde_json::json!({"method":"create_database","database":"test"}).to_string()))?; + assert_eq!(created["status"], "ok", "{created}"); + assert!(data.path().join("databases/test/catalog.json").is_file()); + let dropped: serde_json::Value = serde_json::from_str(&send(&mut c, &serde_json::json!({"method":"drop_database","database":"test"}).to_string()))?; + assert_eq!(dropped["status"], "ok", "{dropped}"); + assert!(!data.path().join("databases/test").exists()); + assert!(data.path().join("system/catalog.json").is_file()); + Ok(()) +} + +#[test] +fn system_database_owns_accounts_and_nothing_is_default() -> Result<(), Box> { + let pipe = unique_pipe_name(); + let (_srv, data) = start_server(&pipe); + let mut c = connect(&pipe)?; + // 启动只建 system,另一个是测试自己建的 main + let listed: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"list_databases"}"#))?; + assert_eq!(listed["tables"], serde_json::json!(["main", "system"])); + let created: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"account_create","user":"alice","password_hash":"hash-1"}"#))?; + assert_eq!(created["status"], "accounts", "{created}"); + assert!(data.path().join("system/__chusql_users.db").is_file()); + // 账号是全局的:请求里带的库名对账号操作不起作用 + let accounts: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"accounts_list","database":"sales"}"#))?; + assert_eq!(accounts["status"], "accounts"); + assert_eq!(accounts["accounts"][0]["user"], "alice"); + // 裸表名落到选中的库(测试补的是 main) + assert_eq!(request_ok(&mut c, serde_json::json!({"method":"create_table","table":"items","columns":[{"name":"id","ty":"int"}]}))?, ()); + assert!(data.path().join("databases/main/items.db").is_file()); + assert!(!data.path().join("items.db").exists()); + assert!(!data.path().join("test").exists()); + Ok(()) +} + +/// 没选库的裸表名请求会被拒;限定名(db.table)不用选库 +#[test] +fn unqualified_requests_need_a_selected_database() -> Result<(), Box> { + let pipe = unique_pipe_name(); + let (_srv, _data) = start_server(&pipe); + let mut c = connect(&pipe)?; + let response: serde_json::Value = serde_json::from_str(&send_raw(&mut c, r#"{"method":"scan","table":"items"}"#))?; + assert_eq!(response["status"], "error", "{response}"); + assert!(response["message"].as_str().unwrap_or("").contains("no database selected"), "{response}"); + let created: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"create_table","table":"items","columns":[{"name":"id","ty":"int"}]}"#))?; + assert_eq!(created["status"], "ok", "{created}"); + let qualified: serde_json::Value = serde_json::from_str(&send_raw(&mut c, r#"{"method":"scan","table":"main.items"}"#))?; + assert_eq!(qualified["status"], "rows", "{qualified}"); + let unknown: serde_json::Value = serde_json::from_str(&send_raw(&mut c, r#"{"method":"scan","table":"ghost.items"}"#))?; + assert_eq!(unknown["status"], "error", "{unknown}"); + assert!(unknown["message"].as_str().unwrap_or("").contains("unknown database"), "{unknown}"); + Ok(()) +} + +/// 发一条请求并断言状态是 ok +fn request_ok(c: &mut LocalSocketStream, value: serde_json::Value) -> Result<(), Box> { + let response: serde_json::Value = serde_json::from_str(&send(c, &value.to_string()))?; + assert_eq!(response["status"], "ok", "{value}: {response}"); + Ok(()) +} /// 唯一管道名,避免测试互抢 fn unique_pipe_name() -> String { @@ -34,12 +327,15 @@ impl Drop for ServerProc { /// 起 server,等管道可连 fn start_server(pipe: &str) -> (ServerProc, tempfile::TempDir) { let data = tempfile::tempdir().unwrap(); + let server = start_server_in(pipe, data.path()); + (server, data) +} +fn start_server_in(pipe: &str, data: &std::path::Path) -> ServerProc { + let config = write_config(data, pipe); let child = Command::new(env!("CARGO_BIN_EXE_chusql-storage")) - .env("CHUSQL_PIPE", pipe) - .env("CHUSQL_DATA_DIR", data.path()) - .env("CHUSQL_PAGE_SIZE", "4096") - .env("CHUSQL_BTREE_ORDER", "4") + .arg("--config") + .arg(&config) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() @@ -49,23 +345,93 @@ fn start_server(pipe: &str) -> (ServerProc, tempfile::TempDir) { for _ in 0..100 { if connect(pipe).is_ok() { - return (srv, data); + // 服务启动只建 system 库,测试库在这里显式建 + create_fixture_database(pipe); + return srv; } thread::sleep(Duration::from_millis(50)); } panic!("server did not start within 5s"); } -/// 连到指定管道 -fn connect(pipe: &str) -> std::io::Result { - let name = pipe - .to_ns_name::() - .map_err(|e| std::io::Error::other(e.to_string()))?; - LocalSocketStream::connect(name) +/// 写一份配置给子进程:管名与数据目录都走 TOML, +/// 路径用单引号字面量串,免得 Windows 反斜杠要转义。 +fn write_config(data: &std::path::Path, pipe: &str) -> std::path::PathBuf { + let id = std::process::id(); + let ns = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = data.join(format!("chusql-{}-{}.toml", id, ns)); + let text = format!( + "[server]\npipe_name = '{}'\n[storage]\ndata_dir = '{}'\n", + pipe, + data.display() + ); + std::fs::write(&path, text).unwrap(); + path } -/// 发一行请求读一行响应 +#[test] +fn named_database_survives_restart_and_accounts_stay_global() -> Result<(), Box> { + let pipe = unique_pipe_name(); + let (server, data) = start_server(&pipe); + let mut c = connect(&pipe)?; + for request in [ + serde_json::json!({"method":"create_database","database":"sales"}), + serde_json::json!({"method":"create_table","database":"sales","table":"items","columns":[{"name":"id","ty":"int"}]}), + serde_json::json!({"method":"insert","database":"sales","table":"items","row":{"id":7}}), + ] { + let result: serde_json::Value = serde_json::from_str(&send(&mut c, &request.to_string()))?; + assert_eq!(result["status"], "ok"); + } + drop(c); + drop(server); + let _restarted = start_server_in(&pipe, data.path()); + let mut c = connect(&pipe)?; + let rows: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"sales.items"}"#))?; + assert_eq!(rows["rows"], serde_json::json!([{"id":7}])); + let accounts: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"accounts_list","database":"sales"}"#))?; + assert_eq!(accounts["status"], "accounts"); + let hidden: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"sales.__chusql_users"}"#))?; + assert_eq!(hidden["status"], "error"); + Ok(()) +} + +/// 连到指定端点的存储进程。端点规则与 server 侧共用 endpoint 模块, +/// 所以 Windows 的具名管道与 Unix 的套接字文件都由同一处决定。 +fn connect(pipe: &str) -> std::io::Result { + chusql_storage::endpoint::connect(pipe) +} + +/// 测试自己的库名:服务启动只建 system 库,测试显式建这一个 +const FIXTURE_DATABASE: &str = "main"; + +/// 请求里没写库名就补上测试库:服务不再自带默认库,裸表名必须先选库 +fn with_default_database(line: &str) -> String { + match serde_json::from_str::(line) { + Ok(serde_json::Value::Object(mut object)) => { + object.entry("database").or_insert_with(|| serde_json::Value::String(FIXTURE_DATABASE.to_string())); + serde_json::Value::Object(object).to_string() + } + _ => line.to_string(), + } +} + +/// 连通后显式建测试库;重启时库已存在,报错忽略 +fn create_fixture_database(pipe: &str) { + if let Ok(mut stream) = connect(pipe) { + let _ = send_raw(&mut stream, &format!(r#"{{"method":"create_database","database":"{FIXTURE_DATABASE}"}}"#)); + } +} + +/// 发一行请求读一行响应(没写库名就补测试库) fn send(stream: &mut LocalSocketStream, line: &str) -> String { + send_raw(stream, &with_default_database(line)) +} + +/// 原样发一行请求,不补库名——用来验证"没选库"的行为 +fn send_raw(stream: &mut LocalSocketStream, line: &str) -> String { stream.write_all(line.as_bytes()).unwrap(); stream.write_all(b"\n").unwrap(); stream.flush().unwrap(); @@ -78,41 +444,191 @@ fn send(stream: &mut LocalSocketStream, line: &str) -> String { /// ping 回 pong #[test] -fn ping_pong() { +fn catalog_is_the_only_table_authority() -> std::io::Result<()> { let pipe = unique_pipe_name(); - let (_srv, _data) = start_server(&pipe); - - let mut c = connect(&pipe).unwrap(); - assert_eq!(send(&mut c, r#"{"method":"ping"}"#), r#"{"status":"pong"}"#); + let (_srv, data) = start_server(&pipe); + std::fs::write(data.path().join("databases/main/orphan.db"), b"orphan")?; + let mut c = connect(&pipe)?; + for method in ["scan", "describe_table"] { + let result = send(&mut c, &format!(r#"{{"method":"{method}","table":"orphan"}}"#)); + assert!(result.contains("unknown table"), "{result}"); + } + let result = send(&mut c, r#"{"method":"list_tables"}"#); + assert!(!result.contains("orphan"), "{result}"); + Ok(()) } -/// 插入后能扫到 #[test] -fn insert_then_scan() { +fn reserved_tables_reject_every_generic_operation() -> std::io::Result<()> { let pipe = unique_pipe_name(); let (_srv, _data) = start_server(&pipe); - let mut c = connect(&pipe).unwrap(); - - let r1 = send( - &mut c, - r#"{"method":"insert","table":"users","row":{"id":1,"name":"Alice"}}"#, - ); - assert!(r1.contains(r#""status":"ok""#), "insert response: {}", r1); - - let r2 = send(&mut c, r#"{"method":"scan","table":"users"}"#); - assert!(r2.contains("Alice"), "scan response: {}", r2); + let mut c = connect(&pipe)?; + for method in ["scan", "insert", "insert_batch", "delete_keys", "lookup_by_index", + "replace_all", "describe_table", "create_table", "drop_table", "create_index", + "drop_index", "drop_column"] { + let request = serde_json::json!({"method": method, "table": "__chusql_users", + "column": "id", "key": 1, "keys": [], "row": {"id": 1}, "rows": [], "columns": []}); + let result = send(&mut c, &request.to_string()); + assert!(result.contains("reserved system table"), "{method}: {result}"); + } + Ok(()) } -/// 不存在的表报错 #[test] -fn scan_unknown_table_errors() { +fn generic_ipc_rejects_path_aliases() -> std::io::Result<()> { let pipe = unique_pipe_name(); let (_srv, _data) = start_server(&pipe); - let mut c = connect(&pipe).unwrap(); + let mut c = connect(&pipe)?; + for table in ["../__chusql_users", "__CHUSQL_USERS", "__chusql_users."] { + let result = send(&mut c, &serde_json::json!({"method":"scan", "table":table}).to_string()); + assert!(result.contains("error"), "{result}"); + } + let result = send(&mut c, r#"{"method":"create_index","table":"business","column":"../__chusql_users"}"#); + assert!(result.contains("invalid column name"), "{result}"); + Ok(()) +} - let r = send(&mut c, r#"{"method":"scan","table":"nope"}"#); - assert!(r.contains(r#""status":"error""#), "got {}", r); - assert!(r.contains("unknown table"), "got {}", r); +#[test] +fn account_create_reset_drop_and_hidden_from_ordinary_api() -> Result<(), Box> { + let pipe = unique_pipe_name(); + let (_srv, _data) = start_server(&pipe); + let mut c = connect(&pipe)?; + assert_eq!(serde_json::from_str::(&send(&mut c, r#"{"method":"accounts_list"}"#))?["accounts"], serde_json::json!([])); + let create = r#"{"method":"account_create","user":"Alice","password_hash":"hash-one"}"#; + let created: serde_json::Value = serde_json::from_str(&send(&mut c, create))?; + assert_eq!(created["accounts"].as_array().map(Vec::len), Some(1)); + assert_eq!(created["accounts"][0]["user"], "alice"); + assert_eq!(created["accounts"][0]["revision"], 1); + assert_eq!(created["accounts"][0].get("administrator"), None); + assert!(send(&mut c, create).contains("already exists")); + let reset = r#"{"method":"account_reset","user":"alice","password_hash":"hash-two"}"#; + let changed: serde_json::Value = serde_json::from_str(&send(&mut c, reset))?; + assert_eq!(changed["accounts"][0]["revision"], 2); + assert_eq!(changed["accounts"][0]["password_hash"], "hash-two"); + assert!(send(&mut c, r#"{"method":"account_reset","user":"nobody","password_hash":"hash-x"}"#).contains("unknown account")); + assert!(send(&mut c, r#"{"method":"account_drop","user":"nobody"}"#).contains("unknown account")); + let dropped: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"account_drop","user":"alice"}"#))?; + assert_eq!(dropped["accounts"].as_array().map(Vec::len), Some(0)); + let empty: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"accounts_list"}"#))?; + assert_eq!(empty["accounts"], serde_json::json!([])); + for method in ["list_tables", "list_catalog"] { + let result = send(&mut c, &serde_json::json!({"method":method}).to_string()); + assert!(!result.contains("__chusql_users"), "{result}"); + } + Ok(()) +} + +fn restart_at(pipe: &str, data: &std::path::Path) -> std::io::Result { + let child = Command::new(env!("CARGO_BIN_EXE_chusql-storage")) + .arg("--config") + .arg(write_config(data, pipe)) + .stdout(Stdio::null()).stderr(Stdio::null()).spawn()?; + let server = ServerProc(child); + for _ in 0..100 { + if connect(pipe).is_ok() { + create_fixture_database(pipe); + return Ok(server); + } + thread::sleep(Duration::from_millis(50)); + } + Err(std::io::Error::other("server restart timed out")) +} + +#[test] +fn account_snapshot_replays_twice_without_duplicating_accounts() -> Result<(), Box> { + use chusql_storage::{protocol::Account, wal::{Wal, WalOp}}; + let pipe = unique_pipe_name(); + let (server, data) = start_server(&pipe); + let mut c = connect(&pipe)?; + let initial = send(&mut c, r#"{"method":"account_create","user":"alice","password_hash":"hash-one"}"#); + assert!(initial.contains("accounts"), "{initial}"); + let changed: serde_json::Value = serde_json::from_str(&send(&mut c, + r#"{"method":"account_reset","user":"alice","password_hash":"hash-two"}"#))?; + let accounts: Vec = serde_json::from_value(changed["accounts"].clone())?; + drop(c); + drop(server); + for _ in 0..2 { + let wal = Wal::new(data.path().join("system/wal.log")); + wal.truncate()?; + wal.append(&WalOp::Accounts { accounts: accounts.clone() })?; + drop(wal); + std::fs::write(data.path().join("system/__chusql_users.idx"), b"torn index")?; + std::fs::write(data.path().join("system/__chusql_users.db"), b"torn heap")?; + let restarted = restart_at(&pipe, data.path())?; + let mut connection = connect(&pipe)?; + let actual: serde_json::Value = serde_json::from_str(&send(&mut connection, r#"{"method":"accounts_list"}"#))?; + assert_eq!(actual, changed); + assert_eq!(std::fs::metadata(data.path().join("system/wal.log"))?.len(), 0); + drop(connection); + drop(restarted); + } + Ok(()) +} + +#[test] +fn account_failure_preserves_wal_and_blocks_writes() -> Result<(), Box> { + let pipe = unique_pipe_name(); + let (server, data) = start_server(&pipe); + let mut c = connect(&pipe)?; + std::fs::create_dir(data.path().join("system/catalog.json.pending"))?; + let result = send(&mut c, r#"{"method":"account_create","user":"alice","password_hash":"hash-one"}"#); + assert!(result.contains("error"), "{result}"); + assert!(std::fs::metadata(data.path().join("system/wal.log"))?.len() > 0); + for request in [r#"{"method":"accounts_list"}"#, + r#"{"method":"account_create","user":"bob","password_hash":"hash-two"}"#] { + assert!(send(&mut c, request).contains("recovery required"), "{request}"); + } + let created = send(&mut c, r#"{"method":"create_table","table":"business","columns":[{"name":"id","ty":"int"}]}"#); + assert!(created.contains(r#""status":"ok""#), "{created}"); + drop(c); + drop(server); + std::fs::remove_dir(data.path().join("system/catalog.json.pending"))?; + let _restarted = restart_at(&pipe, data.path())?; + let mut c = connect(&pipe)?; + let recovered = send(&mut c, r#"{"method":"accounts_list"}"#); + assert!(recovered.contains("hash-one"), "{recovered}"); + Ok(()) +} + +#[test] +fn account_create_rejects_reserved_file_collision() -> std::io::Result<()> { + let pipe = unique_pipe_name(); + let (_server, data) = start_server(&pipe); + std::fs::write(data.path().join("system/__chusql_users.db"), b"existing")?; + let mut c = connect(&pipe)?; + let result = send(&mut c, r#"{"method":"account_create","user":"alice","password_hash":"hash-one"}"#); + assert!(result.contains("collision"), "{result}"); + assert_eq!(std::fs::read(data.path().join("system/__chusql_users.db"))?, b"existing"); + Ok(()) +} + +#[test] +fn oversized_account_hash_is_rejected_before_wal() -> Result<(), Box> { + let pipe = unique_pipe_name(); + let (_server, data) = start_server(&pipe); + let mut c = connect(&pipe)?; + let result = send(&mut c, &serde_json::json!({"method":"account_create", "user":"alice", + "password_hash":"a".repeat(300)}).to_string()); + assert!(result.contains("invalid account credential"), "{result}"); + let wal = data.path().join("system/wal.log"); + assert_eq!(if wal.exists() { std::fs::metadata(&wal)?.len() } else { 0 }, 0); + assert_eq!(serde_json::from_str::(&send(&mut c, r#"{"method":"accounts_list"}"#))?["accounts"], serde_json::json!([])); + Ok(()) +} + +#[test] +fn account_create_rejects_existing_ordinary_catalog_entry() -> std::io::Result<()> { + let data = tempfile::tempdir()?; + let mut catalog = chusql_storage::catalog::Catalog::default(); + catalog.create_table("__chusql_users", Vec::new())?; + std::fs::create_dir(data.path().join("system"))?; + catalog.save(data.path().join("system/catalog.json"))?; + let pipe = unique_pipe_name(); + let _server = restart_at(&pipe, data.path())?; + let mut c = connect(&pipe)?; + let result = send(&mut c, r#"{"method":"account_create","user":"alice","password_hash":"hash-one"}"#); + assert!(result.contains("collision"), "{result}"); + Ok(()) } /// 列出已建的表 @@ -174,21 +690,6 @@ fn insert_then_lookup_by_index() { assert!(r.contains(r#""rows":[]"#), "miss: {}", r); } -/// 插入时索引由表自己维护:没给 key,id 也会进索引 -#[test] -fn insert_indexes_the_id_column_automatically() { - let pipe = unique_pipe_name(); - let (_srv, _data) = start_server(&pipe); - let mut c = connect(&pipe).unwrap(); - - send(&mut c, r#"{"method":"insert","table":"auto_idx","row":{"id":7,"name":"Zoe"}}"#); - let r = send( - &mut c, - r#"{"method":"lookup_by_index","table":"auto_idx","column":"id","key":7}"#, - ); - assert!(r.contains("Zoe"), "got: {}", r); -} - /// 同一个 id 插两次要报错(索引列是唯一的) #[test] fn duplicate_id_is_rejected() { @@ -201,7 +702,7 @@ fn duplicate_id_is_rejected() { let r = send(&mut c, r#"{"method":"insert","table":"dup_id","row":{"id":1,"name":"B"}}"#); assert!(r.contains(r#""status":"error""#), "got: {}", r); - assert!(r.contains("duplicate key"), "got: {}", r); + assert!(r.contains("duplicate value on the id index"), "got: {}", r); } /// 批量插入一次全进 @@ -334,22 +835,31 @@ fn drop_column_removes_column_index_and_values() { assert!(r.contains(r#""status":"error""#), "a missing column should error: {}", r); } -/// 重复值列不给建索引 +/// 重复值列允许建索引:点查把重复的行都取回来,删一行不影响另一行;id 仍然唯一 #[test] -fn create_index_rejects_duplicate_values() { +fn create_index_accepts_duplicate_values() { let pipe = unique_pipe_name(); let (_srv, _data) = start_server(&pipe); let mut c = connect(&pipe).unwrap(); send(&mut c, r#"{"method":"insert","table":"dup_t","row":{"id":1,"age":30}}"#); send(&mut c, r#"{"method":"insert","table":"dup_t","row":{"id":2,"age":30}}"#); + send(&mut c, r#"{"method":"insert","table":"dup_t","row":{"id":3,"age":41}}"#); let r = send(&mut c, r#"{"method":"create_index","table":"dup_t","column":"age"}"#); - assert!(r.contains(r#""status":"error""#), "got: {}", r); - assert!(r.contains("duplicate value"), "got: {}", r); + assert!(r.contains(r#""status":"ok""#), "duplicate values are allowed: {}", r); let r = send(&mut c, r#"{"method":"lookup_by_index","table":"dup_t","column":"age","key":30}"#); - assert!(r.contains(r#""status":"no_index""#), "got: {}", r); + assert!(r.contains(r#""id":1"#) && r.contains(r#""id":2"#), "both rows come back: {}", r); + assert!(!r.contains(r#""id":3"#), "only matching rows: {}", r); + + let r = send(&mut c, r#"{"method":"delete_keys","table":"dup_t","keys":[1]}"#); + assert!(r.contains(r#""status":"ok""#), "delete: {}", r); + let r = send(&mut c, r#"{"method":"lookup_by_index","table":"dup_t","column":"age","key":30}"#); + assert!(!r.contains(r#""id":1"#) && r.contains(r#""id":2"#), "only the deleted entry goes away: {}", r); + + let r = send(&mut c, r#"{"method":"insert","table":"dup_t","row":{"id":2,"age":99}}"#); + assert!(r.contains(r#""status":"error""#), "the id index stays unique: {}", r); } /// 数据字典里的统计:不同值个数 @@ -385,10 +895,9 @@ fn lookup_survives_reopen() { let data = tempfile::tempdir().unwrap(); { - let exe = env!("CARGO_BIN_EXE_chusql-storage"); - let child = Command::new(exe) - .env("CHUSQL_PIPE", &pipe) - .env("CHUSQL_DATA_DIR", data.path()) + let child = Command::new(env!("CARGO_BIN_EXE_chusql-storage")) + .arg("--config") + .arg(write_config(data.path(), &pipe)) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() @@ -398,6 +907,7 @@ fn lookup_survives_reopen() { if connect(&pipe).is_ok() { break; } thread::sleep(Duration::from_millis(50)); } + create_fixture_database(&pipe); let mut c = connect(&pipe).unwrap(); for (id, name) in [(1, "Alice"), (2, "Bob"), (3, "Carol")] { let req = format!( @@ -413,8 +923,8 @@ fn lookup_survives_reopen() { let (_srv, _data) = { let child = Command::new(env!("CARGO_BIN_EXE_chusql-storage")) - .env("CHUSQL_PIPE", &pipe) - .env("CHUSQL_DATA_DIR", data.path()) + .arg("--config") + .arg(write_config(data.path(), &pipe)) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() @@ -424,6 +934,7 @@ fn lookup_survives_reopen() { if connect(&pipe).is_ok() { break; } thread::sleep(Duration::from_millis(50)); } + create_fixture_database(&pipe); (srv, data) }; @@ -448,7 +959,8 @@ fn config_file_is_used() { std::fs::write(&config_path, text).unwrap(); let child = Command::new(env!("CARGO_BIN_EXE_chusql-storage")) - .env("CHUSQL_CONFIG", &config_path) + .arg("--config") + .arg(&config_path) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() @@ -460,6 +972,7 @@ fn config_file_is_used() { } thread::sleep(Duration::from_millis(50)); } + create_fixture_database(&pipe); let mut c = connect(&pipe).unwrap(); assert_eq!(send(&mut c, r#"{"method":"ping"}"#), r#"{"status":"pong"}"#); @@ -467,31 +980,11 @@ fn config_file_is_used() { assert!(r.contains(r#""status":"ok""#), "insert: {}", r); assert!( - data_dir.join("cfg.db").exists(), + data_dir.join("databases/main/cfg.db").exists(), "data dir from config file was not used" ); } -/// 建表后能查 schema -#[test] -fn create_table_then_describe() { - let pipe = unique_pipe_name(); - let (_srv, _data) = start_server(&pipe); - let mut c = connect(&pipe).unwrap(); - - let r = send( - &mut c, - r#"{"method":"create_table","table":"ct_users","columns":[{"name":"id","ty":"int"},{"name":"name","ty":"str"}]}"#, - ); - assert!(r.contains(r#""status":"ok""#), "create: {}", r); - - let r = send(&mut c, r#"{"method":"describe_table","table":"ct_users"}"#); - assert!(r.contains("id"), "describe: {}", r); - assert!(r.contains("name"), "describe: {}", r); - assert!(r.contains(r#""ty":"int""#), "describe: {}", r); - assert!(r.contains(r#""ty":"str""#), "describe: {}", r); -} - /// 重复建表报错 #[test] fn create_table_twice_errors() { @@ -525,3 +1018,220 @@ fn scan_empty_table_after_create() { assert!(r.contains(r#""status":"rows""#), "scan: {}", r); assert!(r.contains(r#""rows":[]"#), "scan: {}", r); } + +/// 建表带上约束元数据,行里能有 null;replace_schema 整表换列定义与行 +#[test] +fn replace_schema_rewrites_columns_and_rows() { + let pipe = unique_pipe_name(); + let (_srv, _data) = start_server(&pipe); + let mut c = connect(&pipe).unwrap(); + + let created = send( + &mut c, + r#"{"method":"create_table","table":"alt_t","columns":[{"name":"id","ty":"int","nullable":false,"auto_increment":true,"primary_key":true},{"name":"name","ty":"varchar(8)","default":"x"}]}"#, + ); + assert!(created.contains(r#""status":"ok""#), "create: {}", created); + + let r = send(&mut c, r#"{"method":"insert","table":"alt_t","row":{"id":1,"name":null}}"#); + assert!(r.contains(r#""status":"ok""#), "insert null: {}", r); + let r = send(&mut c, r#"{"method":"insert","table":"alt_t","row":{"id":2,"name":"b"}}"#); + assert!(r.contains(r#""status":"ok""#), "insert: {}", r); + + let r = send(&mut c, r#"{"method":"describe_table","table":"alt_t"}"#); + assert!(r.contains(r#""ty":"varchar(8)""#), "type parameters survive: {}", r); + assert!(r.contains(r#""nullable":false"#), "nullability survives: {}", r); + assert!(r.contains(r#""auto_increment":true"#), "auto increment survives: {}", r); + assert!(r.contains(r#""primary_key":true"#), "primary key survives: {}", r); + assert!(r.contains(r#""default":"x""#), "default survives: {}", r); + let scanned = send(&mut c, r#"{"method":"scan","table":"alt_t"}"#); + assert!(scanned.contains(r#""name":null"#), "null value survives a scan: {}", scanned); + + let replaced = send( + &mut c, + r#"{"method":"replace_schema","table":"alt_t","columns":[{"name":"id","ty":"int","nullable":false},{"name":"tag","ty":"str","nullable":false,"default":"n"}],"rows":[{"id":1,"tag":"a"},{"id":2,"tag":"b"}]}"#, + ); + assert!(replaced.contains(r#""status":"ok""#), "replace_schema: {}", replaced); + + let r = send(&mut c, r#"{"method":"describe_table","table":"alt_t"}"#); + assert!(r.contains(r#""name":"tag""#), "new column: {}", r); + assert!(!r.contains(r#""name":"name""#), "old column gone: {}", r); + assert!(r.contains(r#""row_count":2"#), "row count rebuilt: {}", r); + + let r = send(&mut c, r#"{"method":"scan","table":"alt_t"}"#); + assert!(r.contains(r#""tag":"a""#), "rewritten rows: {}", r); +} + +/// 旧类型系统落盘的数据在启动时升级:缺的格补 null,老类型名并到新写法 +#[test] +fn legacy_catalog_and_partial_rows_are_migrated() -> Result<(), Box> { + let pipe = unique_pipe_name(); + let (server, data) = start_server(&pipe); + let mut c = connect(&pipe)?; + request_ok(&mut c, serde_json::json!({"method":"create_table","table":"legacy_t","columns":[{"name":"id","ty":"int"},{"name":"name","ty":"str"},{"name":"age","ty":"int"}]}))?; + request_ok(&mut c, serde_json::json!({"method":"insert","table":"legacy_t","row":{"id":1,"name":"a"}}))?; + drop(c); + drop(server); + + // 把字典改回老样子:只留 name / ty,类型名写成别名 + let catalog_path = data.path().join("databases/main/catalog.json"); + let mut catalog: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&catalog_path)?)?; + let columns = catalog["tables"]["legacy_t"]["columns"].as_array_mut().ok_or("missing columns")?; + for column in columns.iter_mut() { + let name = column["name"].clone(); + let ty = if column["ty"] == "int" { "integer" } else { "text" }; + *column = serde_json::json!({"name": name, "ty": ty}); + } + std::fs::write(&catalog_path, serde_json::to_string_pretty(&catalog)?)?; + + let _restarted = start_server_in(&pipe, data.path()); + let mut c = connect(&pipe)?; + + let rows: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"legacy_t"}"#))?; + assert_eq!(rows["rows"], serde_json::json!([{"id":1,"name":"a","age":null}]), "缺的格要补 null"); + + let described: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"describe_table","table":"legacy_t"}"#))?; + let types: Vec<&str> = described["columns"].as_array().ok_or("missing columns")?.iter().map(|col| col["ty"].as_str().unwrap_or("")).collect(); + assert_eq!(types, vec!["int", "str", "int"], "老类型名要并到新写法"); + assert!(described["columns"][0]["nullable"].is_boolean(), "新字段要落盘"); + + let saved: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&catalog_path)?)?; + assert_eq!(saved["tables"]["legacy_t"]["columns"][0]["ty"], "int"); + Ok(()) +} + +/// 账号表用新类型系统落盘:id 主键、用户名索引、注册与最近登录时间 +#[test] +fn account_table_uses_typed_columns_and_stamps_login() -> Result<(), Box> { + let pipe = unique_pipe_name(); + let (_srv, data) = start_server(&pipe); + let mut c = connect(&pipe)?; + + let created: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"account_create","user":"alice","password_hash":"hash"}"#))?; + assert_eq!(created["status"], "accounts", "{}", created); + let account = &created["accounts"][0]; + assert_eq!(account["user"], "alice"); + assert_eq!(account["id"], 1); + assert_eq!(account["registered_at"].as_str().unwrap_or("").len(), 19, "注册时间要写成 timestamp: {}", account); + assert!(account["last_login_at"].is_null(), "还没登录过: {}", account); + + let catalog: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(data.path().join("system/catalog.json"))?)?; + let entry = &catalog["tables"]["__chusql_users"]; + let columns = entry["columns"].as_array().ok_or("missing columns")?; + let names: Vec<&str> = columns.iter().map(|col| col["name"].as_str().unwrap_or("")).collect(); + assert_eq!(names, vec!["id", "user", "password_hash", "registered_at", "last_login_at", "revision"]); + let types: Vec<&str> = columns.iter().map(|col| col["ty"].as_str().unwrap_or("")).collect(); + assert_eq!(types, vec!["int", "varchar(64)", "varchar(256)", "timestamp", "timestamp", "int"]); + assert_eq!(columns[0]["primary_key"], true, "id 是主键"); + assert_eq!(columns[0]["nullable"], false); + assert_eq!(columns[1]["unique"], true, "用户名唯一"); + let indexes = entry["indexes"].as_array().ok_or("missing indexes")?; + assert!(indexes.iter().any(|i| i["column"] == "user"), "用户名索引要写进字典: {}", entry); + + let login: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"account_login","user":"alice"}"#))?; + assert_eq!(login["status"], "accounts", "{}", login); + let stamp = login["accounts"][0]["last_login_at"].as_str().unwrap_or(""); + assert_eq!(stamp.len(), 19, "登录要盖时间戳: {}", login); + Ok(()) +} + +/// 字符串列也能建索引并按键点查 +#[test] +fn string_index_lookup_over_the_pipe() { + let pipe = unique_pipe_name(); + let (_srv, _data) = start_server(&pipe); + let mut c = connect(&pipe).unwrap(); + + send(&mut c, r#"{"method":"insert","table":"sidx","row":{"id":1,"name":"alice"}}"#); + send(&mut c, r#"{"method":"insert","table":"sidx","row":{"id":2,"name":"bob"}}"#); + + let r = send(&mut c, r#"{"method":"create_index","table":"sidx","column":"name"}"#); + assert!(r.contains(r#""status":"ok""#), "string column index: {}", r); + + let r = send(&mut c, r#"{"method":"lookup_by_index","table":"sidx","column":"name","key":"bob"}"#); + assert!(r.contains(r#""id":2"#), "string lookup: {}", r); + + let r = send(&mut c, r#"{"method":"lookup_by_index","table":"sidx","column":"name","key":"dave"}"#); + assert!(r.contains(r#""rows":[]"#), "miss: {}", r); + + let r = send(&mut c, r#"{"method":"insert","table":"sidx","row":{"id":3,"name":"alice"}}"#); + assert!(r.contains(r#""status":"ok""#), "duplicate string value is allowed on a secondary index: {}", r); + let r = send(&mut c, r#"{"method":"lookup_by_index","table":"sidx","column":"name","key":"alice"}"#); + assert!(r.contains(r#""id":1"#) && r.contains(r#""id":3"#), "both rows come back: {}", r); +} + +/// 范围扫描走索引叶子链,两端可开可闭,没索引回 no_index +#[test] +fn range_scan_over_the_pipe() { + let pipe = unique_pipe_name(); + let (_srv, _data) = start_server(&pipe); + let mut c = connect(&pipe).unwrap(); + + for (id, code) in [(1, 100), (2, 200), (3, 300), (4, 400)] { + let req = format!( + r#"{{"method":"insert","table":"rng","row":{{"id":{},"code":{}}}}}"#, + id, code + ); + send(&mut c, &req); + } + let r = send(&mut c, r#"{"method":"create_index","table":"rng","column":"code"}"#); + assert!(r.contains(r#""status":"ok""#), "create_index: {}", r); + + let codes = |line: &str| -> Vec { + let value: serde_json::Value = serde_json::from_str(line).unwrap(); + let mut out: Vec = value["rows"] + .as_array() + .map(|rows| rows.iter().filter_map(|row| row["code"].as_i64()).collect()) + .unwrap_or_default(); + out.sort_unstable(); + out + }; + + let r = send(&mut c, r#"{"method":"range_by_index","table":"rng","column":"code","lo":200,"hi":400}"#); + assert_eq!(codes(&r), vec![200, 300, 400], "closed bounds: {}", r); + + let r = send(&mut c, r#"{"method":"range_by_index","table":"rng","column":"code","lo":200,"lo_inclusive":false,"hi":400,"hi_inclusive":false}"#); + assert_eq!(codes(&r), vec![300], "open bounds: {}", r); + + let r = send(&mut c, r#"{"method":"range_by_index","table":"rng","column":"code","lo":300}"#); + assert_eq!(codes(&r), vec![300, 400], "lower bound only: {}", r); + + let r = send(&mut c, r#"{"method":"range_by_index","table":"rng","column":"code","hi":200}"#); + assert_eq!(codes(&r), vec![100, 200], "upper bound only: {}", r); + + let r = send(&mut c, r#"{"method":"range_by_index","table":"rng","column":"nope"}"#); + assert!(r.contains(r#""status":"no_index""#), "unindexed column: {}", r); +} + +/// 字符串列按字节序范围扫描 +#[test] +fn string_range_scan_over_the_pipe() { + let pipe = unique_pipe_name(); + let (_srv, _data) = start_server(&pipe); + let mut c = connect(&pipe).unwrap(); + + for (id, name) in [(1, "alice"), (2, "bob"), (3, "carol"), (4, "dave")] { + let req = format!( + r#"{{"method":"insert","table":"srng","row":{{"id":{},"name":"{}"}}}}"#, + id, name + ); + send(&mut c, &req); + } + let r = send(&mut c, r#"{"method":"create_index","table":"srng","column":"name"}"#); + assert!(r.contains(r#""status":"ok""#), "create_index: {}", r); + + let names = |line: &str| -> Vec { + let value: serde_json::Value = serde_json::from_str(line).unwrap(); + let mut out: Vec = value["rows"] + .as_array() + .map(|rows| rows.iter().filter_map(|row| row["name"].as_str().map(str::to_string)).collect()) + .unwrap_or_default(); + out.sort(); + out + }; + + let r = send(&mut c, r#"{"method":"range_by_index","table":"srng","column":"name","lo":"b","hi":"carol"}"#); + assert_eq!(names(&r), vec!["bob".to_string(), "carol".to_string()], "{}", r); + + let r = send(&mut c, r#"{"method":"range_by_index","table":"srng","column":"name","lo":"carol","lo_inclusive":false}"#); + assert_eq!(names(&r), vec!["dave".to_string()], "{}", r); +} diff --git a/chusql-storage/tests/wal_test.rs b/chusql-storage/tests/wal_test.rs index 1fceef1..06c291f 100644 --- a/chusql-storage/tests/wal_test.rs +++ b/chusql-storage/tests/wal_test.rs @@ -13,77 +13,51 @@ fn row(pairs: &[(&str, serde_json::Value)]) -> Row { m } -/// 写读 Insert 操作 +/// 三种操作写完都能原样读回 #[test] -fn append_and_read_insert() { +fn append_and_read_round_trips() { let dir = tempfile::tempdir().unwrap(); let wal = Wal::new(dir.path().join("wal.log")); - let op = WalOp::Insert { - table: "users".into(), - row: row(&[("id", json!(1)), ("name", json!("Alice"))]), - }; - wal.append(&op).unwrap(); - - let back = wal.read().unwrap().unwrap(); - assert_eq!(back, op); + let ops = [ + WalOp::Insert { + table: "users".into(), + row: row(&[("id", json!(1)), ("name", json!("Alice"))]), + }, + WalOp::ReplaceAll { + table: "users".into(), + rows: vec![ + row(&[("id", json!(1)), ("name", json!("Alice"))]), + row(&[("id", json!(2)), ("name", json!("Bob"))]), + ], + }, + WalOp::DropColumn { + table: "users".into(), + column: "age".into(), + rows: vec![ + row(&[("id", json!(1)), ("name", json!("Alice"))]), + row(&[("id", json!(2)), ("name", json!("Bob"))]), + ], + }, + ]; + for op in ops { + wal.clear().unwrap(); + wal.append(&op).unwrap(); + assert_eq!(wal.read().unwrap().unwrap(), op); + } } -/// 写读 ReplaceAll 操作 +/// 文件不存在或为空都返回 None #[test] -fn append_and_read_replace_all() { +fn read_without_content_returns_none() { let dir = tempfile::tempdir().unwrap(); - let wal = Wal::new(dir.path().join("wal.log")); + let missing = Wal::new(dir.path().join("missing.log")); + assert!(missing.read().unwrap().is_none()); - let op = WalOp::ReplaceAll { - table: "users".into(), - rows: vec![ - row(&[("id", json!(1)), ("name", json!("Alice"))]), - row(&[("id", json!(2)), ("name", json!("Bob"))]), - ], - }; - wal.append(&op).unwrap(); - - let back = wal.read().unwrap().unwrap(); - assert_eq!(back, op); -} - -/// 写读 DropColumn 操作,行随日志落盘 -#[test] -fn append_and_read_drop_column() { - let dir = tempfile::tempdir().unwrap(); - let wal = Wal::new(dir.path().join("wal.log")); - - let op = WalOp::DropColumn { - table: "users".into(), - column: "age".into(), - rows: vec![ - row(&[("id", json!(1)), ("name", json!("Alice"))]), - row(&[("id", json!(2)), ("name", json!("Bob"))]), - ], - }; - wal.append(&op).unwrap(); - - let back = wal.read().unwrap().unwrap(); - assert_eq!(back, op); -} - -/// 文件不存在返回 None -#[test] -fn missing_file_returns_none() { - let dir = tempfile::tempdir().unwrap(); - let wal = Wal::new(dir.path().join("wal.log")); - assert!(wal.read().unwrap().is_none()); -} - -/// 空文件返回 None -#[test] -fn empty_file_returns_none() { - let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("wal.log"); std::fs::write(&path, b"").unwrap(); - let wal = Wal::new(&path); - assert!(wal.read().unwrap().is_none()); + let empty = Wal::new(&path); + assert!(empty.read().unwrap().is_none()); } /// 清空后读不到 @@ -124,18 +98,3 @@ fn truncated_tail_is_ignored() { assert!(wal.read().unwrap().is_none()); } -/// 第二次追加会覆盖 -#[test] -fn overwrite_on_second_append_is_visible() { - let dir = tempfile::tempdir().unwrap(); - let wal = Wal::new(dir.path().join("wal.log")); - - wal.append(&WalOp::Insert { - table: "t".into(), - row: row(&[("id", json!(1))]), - }) - .unwrap(); - let first = wal.read().unwrap().unwrap(); - assert!(matches!(first, WalOp::Insert { .. })); -} - diff --git a/chusql-web/app/Main.hs b/chusql-web/app/Main.hs index 1391353..271abd0 100644 --- a/chusql-web/app/Main.hs +++ b/chusql-web/app/Main.hs @@ -2,22 +2,25 @@ module Main (main) where -import ChuSQL.Web.Api (AppEnv (..), Live (..), newAppEnv, setLive, webApp) -import ChuSQL.Web.Auth (SessionPolicy (..), newSessionStore) +import ChuSQL.Web.API (AppEnv (..), Live (..), newAppEnvAt, setLive, webApp) +import ChuSQL.Web.Auth (Credential (..), SessionPolicy (..), newSessionStore) import ChuSQL.Web.Backend (Backend, ipcBackend) import ChuSQL.Web.Demo (SeedReport (..), seedDemo) import ChuSQL.Web.Config ( WebConfig (..), defaultPassword, defaultUser, - loadWebConfig, + loadWebConfigAt, resolveCredential, + resolvePipeName, resolveStaticDir, staticDirCandidates, usingDefaultCredentials, ) import ChuSQL.Web.RateLimit (newRateLimiter) -import ChuSQL.Web.Settings (effectiveSettings) +import ChuSQL.Storage.IPC (setPipeName) +import ChuSQL.Web.Settings (effectiveSettings, readSettingsFile) +import ChuSQL.Web.TOML (resolveConfigPath) import ChuSQL.Web.StorageProcess ( StorageProcess (..), findStorageServer, @@ -25,8 +28,9 @@ import ChuSQL.Web.StorageProcess ( stopStorageProcess, waitForStorage, ) -import Control.Exception (IOException, bracket, try) +import Control.Exception (IOException, bracket, onException, try) import Data.Char (toLower) +import qualified Data.Map.Strict as Map import Data.String (fromString) import Data.Text (Text) import qualified Data.Text as T @@ -42,7 +46,8 @@ import Text.Read (readMaybe) -- Web 管理端入口:读配置、备凭据、拉起存储进程,最后起 Warp。 data Options = Options - { optHost :: Maybe String + { optConfig :: Maybe FilePath + , optHost :: Maybe String , optPort :: Maybe Int , optStatic :: Maybe FilePath , optStorage :: Maybe FilePath @@ -65,7 +70,8 @@ data Options = Options emptyOptions :: Options emptyOptions = Options - { optHost = Nothing + { optConfig = Nothing + , optHost = Nothing , optPort = Nothing , optStatic = Nothing , optStorage = Nothing @@ -91,6 +97,7 @@ parseArgs = go emptyOptions go opts [] = Right opts go opts ("--help" : rest) = go opts{optHelp = True} rest go opts ("-h" : rest) = go opts{optHelp = True} rest + go opts ("--config" : v : rest) = go opts{optConfig = Just v} rest go opts ("--host" : v : rest) = go opts{optHost = Just v} rest go opts ("--static" : v : rest) = go opts{optStatic = Just v} rest go opts ("--storage" : v : rest) = go opts{optStorage = Just v} rest @@ -109,11 +116,9 @@ parseArgs = go emptyOptions go opts ("--cookie-secure" : v : rest) = go opts{optCookieSecure = Just (isTrue v)} rest go _ (a : _) = Left ("unrecognized argument or missing value: " ++ a) - -- | 读一个整数开关 withInt :: String -> String -> (Int -> Either String Options) -> Either String Options withInt name v k = maybe (Left (name ++ " needs an integer, got: " ++ v)) k (readMaybe v) - -- | 布尔开关认 1/true/yes/on isTrue :: String -> Bool isTrue s = map toLower s `elem` ["1", "true", "yes", "on"] @@ -122,27 +127,36 @@ usage :: IO () usage = do putStrLn "usage: chusql-web [options]" putStrLn "" - putStrLn " --host H listen address (CHUSQL_WEB_HOST, default 127.0.0.1)" - putStrLn " --port N listen port (CHUSQL_WEB_PORT, default 7777)" - putStrLn " --static DIR static asset directory (CHUSQL_WEB_STATIC, default static)" - putStrLn " --user NAME account name (CHUSQL_WEB_USER, default admin)" - putStrLn " --storage SERVER Rust storage exe to spawn (CHUSQL_STORAGE_SERVER)" - putStrLn " --cookie-secure BOOL add Secure to the cookie (CHUSQL_WEB_COOKIE_SECURE, default false)" - putStrLn " --body-limit N max request body bytes (CHUSQL_WEB_BODY_LIMIT, default 65536)" - putStrLn " --session-idle N session idle timeout, sec (CHUSQL_WEB_SESSION_IDLE, default 28800)" - putStrLn " --session-max N session max age, sec (CHUSQL_WEB_SESSION_MAX, default 86400)" - putStrLn " --login-max-attempts N failed logins before 429 (CHUSQL_WEB_LOGIN_MAX_ATTEMPTS, default 5)" - putStrLn " --login-window N lockout window, sec (CHUSQL_WEB_LOGIN_WINDOW, default 300)" - putStrLn " --page-size N rows per page by default (CHUSQL_WEB_PAGE_SIZE, default 25)" - putStrLn " --max-page-size N rows per page ceiling (CHUSQL_WEB_MAX_PAGE_SIZE, default 500)" - putStrLn " --max-rows N rows returned per query (CHUSQL_WEB_MAX_ROWS, default 1000)" - putStrLn " --max-sql-length N max SQL characters (CHUSQL_WEB_MAX_SQL_LENGTH, default 20000)" - putStrLn " --seed BOOL load demo data when the db is empty (CHUSQL_WEB_SEED, default false)" + putStrLn "config: chusql.toml sits at a fixed place and its sections hold every knob below;" + putStrLn " Windows: %APPDATA%\\ChuSQL\\chusql.toml" + putStrLn " other: $XDG_CONFIG_HOME/ChuSQL/chusql.toml (or ~/.config/ChuSQL/chusql.toml)" + putStrLn " [web] host/port/static_dir/user/password and the limits; [server] pipe_name;" + putStrLn " [storage] data_dir; [page] size; [btree] order; [buffer] pool_size; [log] level" putStrLn "" - putStrLn "password: CHUSQL_WEB_PASSWORD_HASH > CHUSQL_WEB_PASSWORD > built-in demo password" - putStrLn ("without any of them the demo account is " ++ T.unpack defaultUser ++ " / " ++ T.unpack defaultPassword) - putStrLn "storage knobs for the spawned process: CHUSQL_PAGE_SIZE / CHUSQL_BTREE_ORDER /" - putStrLn " CHUSQL_BUFFER_POOL_SIZE / CHUSQL_LOG / CHUSQL_PIPE / CHUSQL_DATA_DIR" + putStrLn " --config FILE read that config file instead ([web] host ...)" + putStrLn " --host H listen address ([web] host, default 127.0.0.1)" + putStrLn " --port N listen port ([web] port, default 7777)" + putStrLn " --static DIR static asset directory ([web] static_dir, default static)" + putStrLn " --user NAME administrator name ([web] user, default root)" + putStrLn " --storage SERVER Rust storage exe to spawn ([web] storage_server)" + putStrLn " --cookie-secure BOOL add Secure to the cookie ([web] cookie_secure, default false)" + putStrLn " --body-limit N max request body bytes ([web] body_limit, default 65536)" + putStrLn " --session-idle N session idle timeout, sec ([web] session_idle, default 28800)" + putStrLn " --session-max N session max age, sec ([web] session_max, default 86400)" + putStrLn " --login-max-attempts N failed logins before 429 ([web] login_max_attempts, default 5)" + putStrLn " --login-window N lockout window, sec ([web] login_window, default 300)" + putStrLn " --page-size N rows per page by default ([web] rows_per_page, default 25)" + putStrLn " --max-page-size N rows per page ceiling ([web] max_page_size, default 500)" + putStrLn " --max-rows N rows returned per query ([web] max_rows, default 1000)" + putStrLn " --max-sql-length N max SQL characters ([web] max_sql_length, default 20000)" + putStrLn " --seed BOOL load demo data when the db is empty ([web] seed, default false)" + putStrLn "" + putStrLn "administrator password: user / password in the [web] section of chusql.toml, else the built-in demo" + putStrLn " an empty password means the administrator signs in with no password; ordinary accounts are then refused" + putStrLn ("without either the demo administrator is " ++ T.unpack defaultUser ++ " / " ++ T.unpack defaultPassword) + putStrLn "ordinary accounts: CREATE USER / ALTER USER / DROP USER from the SQL console (administrator only)" + putStrLn "storage process: [server] pipe_name says where to talk and where a spawned one listens;" + putStrLn " [storage] data_dir, [page] size, [btree] order, [buffer] pool_size and [log] level are its own" -- | 命令行覆盖配置 applyOptions :: WebConfig -> Options -> WebConfig @@ -168,20 +182,19 @@ applyOptions cfg opts = , wcSeedDemo = orElse (optSeedDemo opts) (wcSeedDemo cfg) } where - -- | 开关给了用开关的,否则保留原值 orElse :: Maybe a -> a -> a orElse (Just x) _ = x orElse Nothing y = y -- | 配了存储进程路径就拉起来,没配就假设外面已经跑着 -startStorageIfWanted :: WebConfig -> IO (Either String (Maybe StorageProcess)) -startStorageIfWanted cfg = case wcStorageServer cfg of +startStorageIfWanted :: WebConfig -> FilePath -> IO (Either String (Maybe StorageProcess)) +startStorageIfWanted cfg configPath = case wcStorageServer cfg of Nothing -> pure (Right Nothing) Just given -> do found <- findStorageServer (Just given) case found of Left e -> pure (Left e) - Right bin -> fmap (fmap Just) (startStorageProcess bin) + Right bin -> fmap (fmap Just) (startStorageProcess bin configPath) -- | 入口 main :: IO () @@ -200,16 +213,20 @@ main = do -- | 起服务 run :: Options -> IO () run opts = do - base <- loadWebConfig + configPath <- resolveConfigPath (optConfig opts) + base <- loadWebConfigAt configPath let cfg = applyOptions base opts + setPipeName (resolvePipeName cfg) foundStatic <- resolveStaticDir (wcStaticDir cfg) case foundStatic of Nothing -> do putStrLn ("static directory not found; looked in: " ++ unwords (staticDirCandidates (wcStaticDir cfg))) exitFailure Just staticDir -> do - cred <- resolveCredential cfg - storage <- startStorageIfWanted cfg + let settingsFile = configPath + saved <- readSettingsFile settingsFile + cred <- resolveCredential cfg saved + storage <- startStorageIfWanted cfg configPath case storage of Left err -> do putStrLn err @@ -226,17 +243,18 @@ run opts = do getCurrentTime (wcLoginMaxAttempts cfg) (fromIntegral (wcLoginWindow cfg)) - env0 <- newAppEnv backend sessions cred limiter staticDir + env0 <- newAppEnvAt settingsFile backend sessions cred limiter staticDir + `onException` maybe (pure ()) stopStorageProcess maybeStorage let env = env0 { aeCookieSecure = wcCookieSecure cfg , aeEffective = effectiveSettings cfg - , aeLog = \msg -> TIO.putStrLn ("[web] " <> msg) + , aeLog = TIO.putStrLn } setLive env (liveFromConfig cfg) app <- webApp env - printBanner cfg staticDir maybeStorage - reportCredential cfg + printBanner cfg configPath staticDir maybeStorage + reportCredential cred saved waitForStorageReport seedIfWanted cfg backend let settings = @@ -273,15 +291,17 @@ serve settings app = do exitFailure -- | 口令是从哪来的(用了内置默认口令就明确警告) -reportCredential :: WebConfig -> IO () -reportCredential cfg - | usingDefaultCredentials cfg = do +reportCredential :: Credential -> Map.Map Text Text -> IO () +reportCredential cred saved + | usingDefaultCredentials saved = do putStrLn "" - putStrLn " !! DEMO CREDENTIALS IN USE:" - putStrLn (" !! user " ++ T.unpack (wcUser cfg) ++ " / password " ++ T.unpack defaultPassword) - putStrLn " !! set CHUSQL_WEB_PASSWORD (or CHUSQL_WEB_PASSWORD_HASH) before exposing this server." + putStrLn " !! Administrator credentials are not configured; the built-in demo password is in use:" + putStrLn (" !! user " ++ T.unpack (credUser cred) ++ " / password " ++ T.unpack defaultPassword) + putStrLn " !! set user / password in the [web] section of chusql.toml and restart" putStrLn "" - | otherwise = putStrLn ("password: from CHUSQL_WEB_PASSWORD(_HASH) (user " ++ T.unpack (wcUser cfg) ++ ")") + | T.null (credEncoded cred) = + putStrLn ("administrator: " ++ T.unpack (credUser cred) ++ " (passwordless: only the administrator may sign in)") + | otherwise = putStrLn ("administrator: " ++ T.unpack (credUser cred) ++ " (credentials come from the settings file only)") -- | 配了 seed 就补一份演示数据 seedIfWanted :: WebConfig -> Backend -> IO () @@ -290,10 +310,10 @@ seedIfWanted cfg backend | otherwise = do result <- seedDemo backend case result of - Left e -> TIO.putStrLn ("[web] demo data skipped: " <> T.pack e) + Left e -> TIO.putStrLn ("demo data skipped: " <> T.pack e) Right report -> TIO.putStrLn - ( "[web] demo data: " + ( "demo data: " <> (if null (srCreated report) then "already present" else "created " <> T.pack (unwords (srCreated report))) <> (if null (srSkipped report) then "" else ", kept " <> T.pack (unwords (srSkipped report))) <> (if null (srIndexes report) then "" else ", index on " <> T.pack (unwords (srIndexes report))) @@ -305,15 +325,16 @@ waitForStorageReport = do up <- waitForStorage 20 putStrLn ( if up - then "storage: up (named pipe answered ping)" + then "storage: up (endpoint answered ping)" else "storage: DOWN (no ping reply; start the Rust storage process or pass --storage)" ) -- | 启动横幅 -printBanner :: WebConfig -> FilePath -> Maybe StorageProcess -> IO () -printBanner cfg staticDir maybeStorage = do +printBanner :: WebConfig -> FilePath -> FilePath -> Maybe StorageProcess -> IO () +printBanner cfg configPath staticDir maybeStorage = do putStrLn "==================== ChuSQL Web ====================" putStrLn ("url: http://" ++ wcHost cfg ++ ":" ++ show (wcPort cfg) ++ "/") + putStrLn ("config file: " ++ configPath) putStrLn ("static dir: " ++ staticDir) putStrLn ( "limits: body " @@ -340,5 +361,5 @@ printBanner cfg staticDir maybeStorage = do ++ "s" ) case maybeStorage of - Just sp -> putStrLn ("storage proc: " ++ spPipe sp ++ " data dir " ++ spDataDir sp) - Nothing -> putStrLn "storage proc: assumed to run elsewhere (pipe name from CHUSQL_PIPE)" + Just sp -> putStrLn ("storage proc: spawned, talking on pipe " ++ spPipe sp) + Nothing -> putStrLn "storage proc: assumed to run elsewhere (pipe name from [server] pipe_name)" diff --git a/chusql-web/chusql-web.cabal b/chusql-web/chusql-web.cabal index 917d88d..4f71481 100644 --- a/chusql-web/chusql-web.cabal +++ b/chusql-web/chusql-web.cabal @@ -10,17 +10,20 @@ build-type: Simple library hs-source-dirs: src exposed-modules: ChuSQL.Web.Actions - ChuSQL.Web.Api + ChuSQL.Web.Accounts + ChuSQL.Web.API ChuSQL.Web.Auth ChuSQL.Web.Backend ChuSQL.Web.Config ChuSQL.Web.Demo + ChuSQL.Web.Privileges ChuSQL.Web.RateLimit ChuSQL.Web.Secure ChuSQL.Web.Settings ChuSQL.Web.Static ChuSQL.Web.StorageProcess - ChuSQL.Web.UiSettings + ChuSQL.Web.TOML + ChuSQL.Web.UISettings build-depends: base >= 4.14 && < 5 , aeson , bytestring @@ -48,6 +51,7 @@ executable chusql-web , bytestring , chusql-engine , chusql-web + , containers , crypton , directory , filepath diff --git a/chusql-web/frontend/bin/chusql-cli.js b/chusql-web/frontend/bin/chusql-cli.js deleted file mode 100644 index f7a139d..0000000 --- a/chusql-web/frontend/bin/chusql-cli.js +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env node -// CLI 占位入口:功能尚未实现,仅提示用户。 - -console.log('ChuSQL CLI is not implemented yet. Coming soon.'); diff --git a/chusql-web/frontend/bin/chusql.js b/chusql-web/frontend/bin/chusql.js deleted file mode 100644 index 44270cb..0000000 --- a/chusql-web/frontend/bin/chusql.js +++ /dev/null @@ -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 [options] - -Commands: - web Start the web server - config list List all configuration - config get Get a configuration value - config set Set a configuration value - config unset Remove a configuration value - config path Print the configuration file path - help Show this help message - -Global options: - --port Specify port - --host Specify host - --no-open Do not open the browser automatically - --config Use a custom configuration file - --adapter Specify the data source adapter - --help, -h Show help - --version, -v Show version - -Run "chusql --help" for command-specific options.`; - -function configCommand(args, configPath) { - const config = readConfig(configPath); - const [key, rawValue] = args.operands; - if (!args.action || args.action === 'list') console.log(JSON.stringify(config, null, 2)); - else if (args.action === 'path') console.log(configPath); - else if (args.action === 'get') { - if (!key) throw new Error('Usage: chusql config get '); - const value = getAtPath(config, key); - if (value === undefined) throw new Error(`Configuration key not found: ${key}`); - console.log(typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)); - } else if (args.action === 'set') { - if (!key || rawValue === undefined) throw new Error('Usage: chusql config set '); - setAtPath(config, key, parseConfigValue(rawValue)); - writeConfig(configPath, config); - console.log(green(`Saved ${key}.`)); - } else if (args.action === 'unset') { - if (!key) throw new Error('Usage: chusql config unset '); - if (!unsetAtPath(config, key)) throw new Error(`Configuration key not found: ${key}`); - writeConfig(configPath, config); - console.log(green(`Removed ${key}.`)); - } else throw new Error(`Unknown config command: ${args.action}`); -} - -async function webCommand(args, configPath) { - const stored = readConfig(configPath); - const config = { - ...stored, - ...(args.port === undefined ? {} : { port: args.port }), - ...(args.host === undefined ? {} : { host: args.host }), - ...(args.adapter === undefined ? {} : { adapter: args.adapter }), - ...(args.autoOpenBrowser === undefined ? {} : { autoOpenBrowser: args.autoOpenBrowser }), - }; - if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) throw new Error('Configured port must be an integer between 1 and 65535.'); - if (!['mock', 'rest'].includes(config.adapter)) throw new Error('Configured adapter must be mock or rest.'); - process.env.VITE_CHUSQL_ADAPTER = config.adapter; - const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); - const server = await createServer({ root, server: { host: config.host, port: config.port, strictPort: true } }); - await server.listen(); - const url = `http://${config.host}:${config.port}/`; - console.log(`${cyan('➜')} ${green('ChuSQL Web is running at:')}`); - console.log(` ${url}`); - console.log('Press Ctrl+C to stop.'); - if (config.autoOpenBrowser) openBrowser(url); -} - -try { - const args = parseArgs(process.argv.slice(2)); - const configPath = path.resolve(args.configPath ?? defaultConfigPath()); - if (args.help || args.command === 'help') console.log(help); - else if (args.command === 'version') console.log('ChuSQL 1.0.0'); - else if (args.command === 'config') configCommand(args, configPath); - else if (args.command === 'web') await webCommand(args, configPath); - else throw new Error(`Unknown command: ${args.command}`); -} catch (error) { - console.error(red(`Error: ${error instanceof Error ? error.message : String(error)}`)); - process.exitCode = 1; -} diff --git a/chusql-web/frontend/bin/lib/args.js b/chusql-web/frontend/bin/lib/args.js deleted file mode 100644 index aaab954..0000000 --- a/chusql-web/frontend/bin/lib/args.js +++ /dev/null @@ -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; -} diff --git a/chusql-web/frontend/bin/lib/args.test.js b/chusql-web/frontend/bin/lib/args.test.js deleted file mode 100644 index a22e91d..0000000 --- a/chusql-web/frontend/bin/lib/args.test.js +++ /dev/null @@ -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'); - }); -}); diff --git a/chusql-web/frontend/bin/lib/config.js b/chusql-web/frontend/bin/lib/config.js deleted file mode 100644 index 14f7a39..0000000 --- a/chusql-web/frontend/bin/lib/config.js +++ /dev/null @@ -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; -} diff --git a/chusql-web/frontend/bin/lib/config.test.js b/chusql-web/frontend/bin/lib/config.test.js deleted file mode 100644 index 9084d91..0000000 --- a/chusql-web/frontend/bin/lib/config.test.js +++ /dev/null @@ -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(); - }); -}); diff --git a/chusql-web/frontend/bin/lib/open.js b/chusql-web/frontend/bin/lib/open.js deleted file mode 100644 index 2193497..0000000 --- a/chusql-web/frontend/bin/lib/open.js +++ /dev/null @@ -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(); -} diff --git a/chusql-web/frontend/index.html b/chusql-web/frontend/index.html deleted file mode 100644 index b59c5c1..0000000 --- a/chusql-web/frontend/index.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - ChuSQL 数据库终端 - - - -
- - - diff --git a/chusql-web/frontend/package-lock.json b/chusql-web/frontend/package-lock.json deleted file mode 100644 index aed5115..0000000 --- a/chusql-web/frontend/package-lock.json +++ /dev/null @@ -1,2226 +0,0 @@ -{ - "name": "chusql-web-ide", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "chusql-web-ide", - "version": "1.0.0", - "dependencies": { - "lucide-react": "0.468.0", - "monaco-editor": "0.52.2", - "react": "18.3.1", - "react-dom": "18.3.1", - "vite": "8.3.1", - "zustand": "5.0.3" - }, - "bin": { - "chusql": "bin/chusql.js", - "chusql-cli": "bin/chusql-cli.js" - }, - "devDependencies": { - "@testing-library/jest-dom": "6.6.3", - "@testing-library/react": "16.1.0", - "@types/node": "22.20.4", - "@types/react": "18.3.18", - "@types/react-dom": "18.3.5", - "@vitejs/plugin-react": "6.1.1", - "jsdom": "25.0.1", - "typescript": "5.7.2", - "vitest": "5.0.2" - }, - "engines": { - "node": ">=20.19" - } - }, - "node_modules/@adobe/css-tools": { - "version": "4.5.0", - "dev": true, - "license": "MIT" - }, - "node_modules/@asamuzakjp/css-color": { - "version": "3.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@csstools/css-calc": "^2.1.3", - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "lru-cache": "^10.4.3" - } - }, - "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "10.4.3", - "dev": true, - "license": "ISC" - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.6.0", - "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", - "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.151.0", - "resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.151.0.tgz", - "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/oxc-project" - } - }, - "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.11", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", - "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", - "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", - "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.11", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", - "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.11", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", - "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.11", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", - "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", - "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.11", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", - "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", - "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", - "cpu": [ - "ppc64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", - "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", - "cpu": [ - "s390x" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", - "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.11", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", - "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", - "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.11", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", - "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.11", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", - "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "license": "MIT" - }, - "node_modules/@testing-library/dom": { - "version": "10.4.2", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/jest-dom": { - "version": "6.6.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "chalk": "^3.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "lodash": "^4.17.21", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "dev": true, - "license": "MIT" - }, - "node_modules/@testing-library/react": { - "version": "16.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.20.4", - "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.20.4.tgz", - "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.3.18", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.0.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.3.5", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "6.1.1", - "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", - "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "^1.0.1" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "oxc-transform-react": "^0.145.0", - "vite": "^8.0.0" - }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - }, - "oxc-transform-react": { - "optional": true - } - } - }, - "node_modules/@vitest/mocker": { - "version": "5.0.2", - "resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-5.0.2.tgz", - "integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.2", - "estree-walker": "^3.0.3", - "magic-string": "^1.2.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/spy": { - "version": "5.0.2", - "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-5.0.2.tgz", - "integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/aria-query": { - "version": "5.3.0", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "dev": true, - "license": "MIT" - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmmirror.com/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "dev": true, - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/css.escape": { - "version": "1.5.1", - "dev": true, - "license": "MIT" - }, - "node_modules/cssstyle": { - "version": "4.6.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^3.2.0", - "rrweb-cssom": "^0.8.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/cssstyle/node_modules/rrweb-cssom": { - "version": "0.8.0", - "dev": true, - "license": "MIT" - }, - "node_modules/csstype": { - "version": "3.2.3", - "devOptional": true, - "license": "MIT" - }, - "node_modules/data-urls": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decimal.js": { - "version": "10.6.0", - "dev": true, - "license": "MIT" - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/entities": { - "version": "6.0.1", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "2.3.2", - "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.3.2.tgz", - "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.4.0", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.6", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-encoding": "^3.1.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "dev": true, - "license": "MIT" - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "license": "MIT" - }, - "node_modules/jsdom": { - "version": "25.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "cssstyle": "^4.1.0", - "data-urls": "^5.0.0", - "decimal.js": "^10.4.3", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^4.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.5", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.12", - "parse5": "^7.1.2", - "rrweb-cssom": "^0.7.1", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^5.0.0", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0", - "ws": "^8.18.0", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "canvas": "^2.11.2" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/lightningcss": { - "version": "1.33.0", - "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.33.0.tgz", - "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.33.0", - "lightningcss-darwin-arm64": "1.33.0", - "lightningcss-darwin-x64": "1.33.0", - "lightningcss-freebsd-x64": "1.33.0", - "lightningcss-linux-arm-gnueabihf": "1.33.0", - "lightningcss-linux-arm64-gnu": "1.33.0", - "lightningcss-linux-arm64-musl": "1.33.0", - "lightningcss-linux-x64-gnu": "1.33.0", - "lightningcss-linux-x64-musl": "1.33.0", - "lightningcss-win32-arm64-msvc": "1.33.0", - "lightningcss-win32-x64-msvc": "1.33.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", - "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", - "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", - "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", - "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.33.0", - "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", - "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", - "cpu": [ - "arm" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", - "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", - "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", - "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", - "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", - "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", - "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lodash": { - "version": "4.18.1", - "dev": true, - "license": "MIT" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lucide-react": { - "version": "0.468.0", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" - } - }, - "node_modules/lz-string": { - "version": "1.5.0", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" - } - }, - "node_modules/magic-string": { - "version": "1.4.2", - "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-1.4.2.tgz", - "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.6.0" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/min-indent": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/monaco-editor": { - "version": "0.52.2", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", - "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.1.3", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.19", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/nwsapi": { - "version": "2.2.28", - "dev": true, - "license": "MIT" - }, - "node_modules/obug": { - "version": "2.2.1", - "resolved": "https://registry.npmmirror.com/obug/-/obug-2.2.1.tgz", - "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT", - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/parse5": { - "version": "7.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.28", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.18", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/pretty-format": { - "version": "27.5.1", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/react": { - "version": "18.3.1", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-is": { - "version": "17.0.2", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/redent": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/rolldown": { - "version": "1.2.11", - "resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.2.11.tgz", - "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.151.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.11", - "@rolldown/binding-android-arm64": "1.2.11", - "@rolldown/binding-darwin-arm64": "1.2.11", - "@rolldown/binding-darwin-x64": "1.2.11", - "@rolldown/binding-freebsd-x64": "1.2.11", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", - "@rolldown/binding-linux-arm64-gnu": "1.2.11", - "@rolldown/binding-linux-arm64-musl": "1.2.11", - "@rolldown/binding-linux-ppc64-gnu": "1.2.11", - "@rolldown/binding-linux-s390x-gnu": "1.2.11", - "@rolldown/binding-linux-x64-gnu": "1.2.11", - "@rolldown/binding-linux-x64-musl": "1.2.11", - "@rolldown/binding-openharmony-arm64": "1.2.11", - "@rolldown/binding-win32-arm64-msvc": "1.2.11", - "@rolldown/binding-win32-x64-msvc": "1.2.11" - } - }, - "node_modules/rrweb-cssom": { - "version": "0.7.1", - "dev": true, - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "dev": true, - "license": "MIT" - }, - "node_modules/saxes": { - "version": "6.0.0", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "node_modules/scheduler": { - "version": "0.23.2", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmmirror.com/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", - "dev": true, - "license": "MIT" - }, - "node_modules/strip-indent": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "dev": true, - "license": "MIT" - }, - "node_modules/tinybench": { - "version": "6.2.0", - "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-6.2.0.tgz", - "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/tinyexec": { - "version": "1.3.1", - "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.3.1.tgz", - "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tldts": { - "version": "6.1.86", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^6.1.86" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "6.1.86", - "dev": true, - "license": "MIT" - }, - "node_modules/tough-cookie": { - "version": "5.1.2", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^6.1.32" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "5.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/typescript": { - "version": "5.7.2", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/vite": { - "version": "8.3.1", - "resolved": "https://registry.npmmirror.com/vite/-/vite-8.3.1.tgz", - "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", - "license": "MIT", - "dependencies": { - "lightningcss": "^1.33.0", - "picomatch": "^4.0.7", - "postcss": "^8.5.28", - "rolldown": "~1.2.9", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.7.1", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vitest": { - "version": "5.0.2", - "resolved": "https://registry.npmmirror.com/vitest/-/vitest-5.0.2.tgz", - "integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/mocker": "5.0.2", - "chai": "^6.2.2", - "es-module-lexer": "^2.3.2", - "expect-type": "^1.4.0", - "magic-string": "^1.2.3", - "obug": "^2.1.4", - "picomatch": "^4.0.7", - "std-env": "^4.2.0", - "tinybench": "^6.1.4", - "tinyexec": "^1.3.0", - "tinyglobby": "^0.2.17", - "why-is-node-running": "^3.2.1" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^22.12.0 || ^24.0.0 || >=26.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "5.0.2", - "@vitest/browser-preview": "5.0.2", - "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", - "@vitest/coverage-istanbul": "5.0.2", - "@vitest/coverage-v8": "5.0.2", - "@vitest/ui": "5.0.2", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-encoding": { - "version": "3.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-url": { - "version": "14.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/why-is-node-running": { - "version": "3.2.2", - "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-3.2.2.tgz", - "integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==", - "dev": true, - "license": "MIT", - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=20.11" - } - }, - "node_modules/ws": { - "version": "8.22.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "dev": true, - "license": "MIT" - }, - "node_modules/zustand": { - "version": "5.0.3", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "immer": ">=9.0.6", - "react": ">=18.0.0", - "use-sync-external-store": ">=1.2.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - }, - "use-sync-external-store": { - "optional": true - } - } - } - } -} diff --git a/chusql-web/frontend/package.json b/chusql-web/frontend/package.json deleted file mode 100644 index 33ae8e2..0000000 --- a/chusql-web/frontend/package.json +++ /dev/null @@ -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" - } -} diff --git a/chusql-web/frontend/src/App.module.css b/chusql-web/frontend/src/App.module.css deleted file mode 100644 index 94b23f9..0000000 --- a/chusql-web/frontend/src/App.module.css +++ /dev/null @@ -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; } diff --git a/chusql-web/frontend/src/App.test.tsx b/chusql-web/frontend/src/App.test.tsx deleted file mode 100644 index 77d44f9..0000000 --- a/chusql-web/frontend/src/App.test.tsx +++ /dev/null @@ -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: () =>
})); - -describe('App table editing flow', () => { - beforeEach(() => useChangeStore.getState().clear()); - it('stages row deletion on the first click and allows undo', async () => { - const adapter = new MockAdapter({ latencyMs: 0 }); - const commit = vi.spyOn(adapter, 'commitChanges'); - render(); - fireEvent.click(await screen.findByRole('button', { name: '打开表 users' })); - fireEvent.click(await screen.findByText('User_001')); - fireEvent.click(screen.getByRole('button', { name: '删除选中行' })); - expect(useChangeStore.getState().changes.deletes.size).toBe(1); - expect(commit).not.toHaveBeenCalled(); - fireEvent.click(screen.getByRole('button', { name: '撤销' })); - expect(useChangeStore.getState().changes.deletes.size).toBe(0); - }); - it('keeps edits local and commits the complete change set once', async () => { - const adapter = new MockAdapter({ latencyMs: 0 }); - const commit = vi.spyOn(adapter, 'commitChanges'); - render(); - - expect(screen.getByText('资源管理器')).toBeInTheDocument(); - fireEvent.click(await screen.findByRole('button', { name: /打开表 users/ })); - const cell = await screen.findByText('User_001'); - fireEvent.doubleClick(cell); - const editor = screen.getByRole('textbox', { name: '编辑 name' }); - fireEvent.change(editor, { target: { value: 'edited' } }); - fireEvent.keyDown(editor, { key: 'Enter' }); - - expect(screen.getByRole('button', { name: '提交 (1)' })).toBeEnabled(); - expect(commit).not.toHaveBeenCalled(); - fireEvent.click(screen.getByRole('button', { name: '提交 (1)' })); - await waitFor(() => expect(commit).toHaveBeenCalledTimes(1)); - expect(commit.mock.calls[0][0].changes).toHaveLength(1); - }); -}); - -describe('App tabs', () => { - beforeEach(() => useChangeStore.getState().clear()); - - it('keeps two tables open and switches back to the first one', async () => { - render(); - fireEvent.click(await screen.findByRole('button', { name: '打开表 users' })); - expect(await screen.findByText('User_001')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: '打开表 orders' })); - expect(screen.getAllByRole('tab')).toHaveLength(2); - expect(screen.getByRole('tab', { name: 'orders' })).toHaveAttribute('aria-selected', 'true'); - fireEvent.click(screen.getByRole('tab', { name: 'users' })); - expect(screen.getByRole('tab', { name: 'users' })).toHaveAttribute('aria-selected', 'true'); - expect(await screen.findByText('User_001')).toBeInTheDocument(); - }); - - it('does not add a second tab for a table that is already open', async () => { - render(); - fireEvent.click(await screen.findByRole('button', { name: '打开表 users' })); - fireEvent.click(screen.getByRole('button', { name: '打开表 users' })); - expect(screen.getAllByRole('tab')).toHaveLength(1); - expect(await screen.findByText('User_001')).toBeInTheDocument(); - }); - - it('activates the neighbouring tab after closing the active one', async () => { - render(); - fireEvent.click(await screen.findByRole('button', { name: '打开表 users' })); - await screen.findByText('User_001'); - fireEvent.click(screen.getByRole('button', { name: '打开表 orders' })); - fireEvent.click(screen.getByRole('button', { name: '关闭 orders' })); - expect(screen.getAllByRole('tab')).toHaveLength(1); - expect(screen.queryByRole('tab', { name: 'orders' })).not.toBeInTheDocument(); - expect(await screen.findByText('User_001')).toBeInTheDocument(); - }); - - it('opens one tab for every new query', async () => { - render(); - fireEvent.click(await screen.findByRole('button', { name: '新建查询' })); - fireEvent.click(screen.getByRole('button', { name: '新建查询' })); - expect(screen.getAllByRole('tab')).toHaveLength(2); - expect(await screen.findByTestId('sql-console')).toBeInTheDocument(); - expect(screen.getAllByTestId('sql-console')).toHaveLength(1); - }); -}); - -describe('App pending changes', () => { - beforeEach(() => useChangeStore.getState().clear()); - - it('shows a staged row edit as highlighted SQL instead of JSON', async () => { - render(); - fireEvent.click(await screen.findByRole('button', { name: '打开表 users' })); - fireEvent.doubleClick(await screen.findByText('User_001')); - const editor = screen.getByRole('textbox', { name: '编辑 name' }); - fireEvent.change(editor, { target: { value: "A'B" } }); - fireEvent.keyDown(editor, { key: 'Enter' }); - const code = await screen.findByTitle("UPDATE users SET name = 'A''B' WHERE id = 1;"); - expect(code).toHaveTextContent("UPDATE users SET name = 'A''B' WHERE id = 1;"); - expect(within(code).getByText('UPDATE')).toHaveStyle({ color: '#569CD6' }); - expect(within(code).getByText("'A''B'")).toHaveStyle({ color: '#CE9178' }); - expect(within(code).getByText('users')).toHaveStyle({ color: 'rgb(200, 200, 200)' }); - expect(screen.queryByText(/\{"id":1/)).not.toBeInTheDocument(); - }); - - it('counts a staged index change as a pending change and previews its SQL', async () => { - render(); - fireEvent.click(await screen.findByRole('button', { name: '打开表 users' })); - await screen.findByText('User_001'); - fireEvent.click(screen.getByRole('button', { name: '管理 users 的索引' })); - const indexRows = within(screen.getByRole('dialog')).getAllByRole('listitem'); - fireEvent.click(within(indexRows[2]).getByRole('button', { name: '建索引' })); - expect(await screen.findByTitle('CREATE INDEX ON users (age);')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: '提交 (1)' })).toBeEnabled(); - }); - - it('creates a staged table only when the change set is committed', async () => { - const adapter = new MockAdapter({ latencyMs: 0 }); - render(); - fireEvent.click(await screen.findByRole('button', { name: '新建表' })); - fireEvent.change(screen.getByLabelText('表名'), { target: { value: 'items' } }); - fireEvent.change(screen.getByLabelText('列名 2'), { target: { value: 'title' } }); - fireEvent.click(screen.getByRole('button', { name: '建表' })); - expect(await screen.findByTitle('CREATE TABLE items (id int, title str);')).toBeInTheDocument(); - expect((await adapter.listTables('chusql')).some((table) => table.name === 'items')).toBe(false); - fireEvent.click(screen.getByRole('button', { name: '提交 (1)' })); - await waitFor(async () => { - expect((await adapter.listTables('chusql')).some((table) => table.name === 'items')).toBe(true); - }); - expect(await screen.findByRole('button', { name: '打开表 items' })).toBeInTheDocument(); - }); - - it('selects a single row on click, toggles with Ctrl and extends with Shift', async () => { - render(); - fireEvent.click(await screen.findByRole('button', { name: '打开表 users' })); - fireEvent.click(await screen.findByText('User_001')); - expect(screen.getByText('已选 1 行')).toBeInTheDocument(); - fireEvent.click(screen.getByText('User_002'), { ctrlKey: true }); - expect(screen.getByText('已选 2 行')).toBeInTheDocument(); - fireEvent.click(screen.getByText('User_002'), { ctrlKey: true }); - expect(screen.getByText('已选 1 行')).toBeInTheDocument(); - fireEvent.click(screen.getByText('User_001')); - fireEvent.click(screen.getByText('User_004'), { shiftKey: true }); - expect(screen.getByText('已选 4 行')).toBeInTheDocument(); - fireEvent.click(screen.getByText('User_003')); - expect(screen.getByText('已选 1 行')).toBeInTheDocument(); - }); -}); diff --git a/chusql-web/frontend/src/App.tsx b/chusql-web/frontend/src/App.tsx deleted file mode 100644 index c52be28..0000000 --- a/chusql-web/frontend/src/App.tsx +++ /dev/null @@ -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([]); - const [tabs, setTabs] = useState([]); - const [activeId, setActiveId] = useState(); - const [totals, setTotals] = useState>({}); - const [messages, setMessages] = useState([]); - const [results, setResults] = useState([]); - const [history, setHistory] = useState(initialHistory); - const [revision, setRevision] = useState(0); - const [committing, setCommitting] = useState(false); - const [schemaAction, setSchemaAction] = useState(); - const idRef = useRef(0); - const queryRef = useRef(0); - const { changes, undoStack, redoStack, undoOne, undo, redo, clear, acknowledge } = useChangeStore(); - const settings = useSettingsStore((state) => state.settings); - const count = changeCount(changes); - const activeTab = tabs.find((tab) => tab.id === activeId); - const activeTableTab = activeTab?.kind === 'table' ? activeTab : undefined; - const activeMeta = activeTableTab ? tables.find((table) => table.name === activeTableTab.table) : undefined; - const pushMessage = (message: string) => setMessages((current) => [...current, message].slice(-100)); - const nextTabId = () => `tab-${(idRef.current += 1)}`; - const applyTabs = (state: TabState) => { setTabs(state.tabs); setActiveId(state.activeId); }; - const forgetTotal = (id: string) => setTotals((current) => { const next = { ...current }; delete next[id]; return next; }); - - const loadTables = async () => { - try { setTables(await adapter.listTables(zhCN.database)); } - catch (reason) { pushMessage(`${zhCN.loadError}: ${reason instanceof Error ? reason.message : String(reason)}`); } - }; - useEffect(() => { void loadTables(); }, [adapter]); - useEffect(() => syncUiSettings(), []); - useEffect(() => { - const root = document.documentElement; - root.style.setProperty('--ui-font', fontFamilyValue(settings.uiFonts)); - root.style.setProperty('--grid-font', fontFamilyValue(settings.gridFonts)); - root.style.setProperty('--grid-font-size', `${settings.gridFontSize}px`); - root.style.setProperty('--grid-row-height', `${settings.gridRowHeight}px`); - root.style.fontSize = `${settings.uiFontSize}px`; - }, [settings]); - useEffect(() => { - const warn = (event: BeforeUnloadEvent) => { if (count) event.preventDefault(); }; - window.addEventListener('beforeunload', warn); - return () => window.removeEventListener('beforeunload', warn); - }, [count]); - - const rollback = () => { clear(); setRevision((value) => value + 1); }; - const applySchema = async (change: SchemaChange) => { - if (change.op === 'createTable') await adapter.createTable(change.table, change.columns); - else if (change.op === 'dropTable') await adapter.dropTable(change.table); - else if (change.op === 'createIndex') await adapter.createIndex(change.table, change.column); - else await adapter.dropIndex(change.table, change.column); - }; - const commit = async () => { - if (committing) return; - setCommitting(true); - try { - const rows = toCommitPayload(changes); - const schemas = Array.from(changes.schemas.values()); - const creates = schemas.filter((change) => change.op === 'createTable' || change.op === 'createIndex'); - const drops = schemas.filter((change) => change.op === 'dropIndex' || change.op === 'dropTable'); - let applied = 0; - let failure = ''; - try { - for (const change of creates) { await applySchema(change); applied += 1; } - if (rows.length) { - const result = await adapter.commitChanges({ db: zhCN.database, changes: rows }); - applied += result.ok ? rows.length : result.applied; - if (!result.ok) failure = result.errors?.map((item) => item.message).join('; ') ?? ''; - } - if (!failure) for (const change of drops) { await applySchema(change); applied += 1; } - } catch (reason) { failure = reason instanceof Error ? reason.message : String(reason); } - if (applied) acknowledge(changes, applied); - if (failure) pushMessage(`${zhCN.commitFailed} (${applied} applied; retry submits only the remainder): ${failure}`); - else pushMessage(zhCN.committed(applied)); - await loadTables(); setRevision((value) => value + 1); - } finally { setCommitting(false); } - }; - const requestCommit = () => { if (count) void commit(); }; - const openTable = (name: string) => applyTabs(openTableTab(tabs, name, nextTabId())); - const newQuery = (sql?: string) => applyTabs(openQueryTab(tabs, zhCN.queryTab((queryRef.current += 1)), nextTabId(), sql)); - const openSettings = () => applyTabs(openSettingsTab(tabs, zhCN.settings, nextTabId())); - const closeTabById = (tab: Tab) => { - forgetTotal(tab.id); - applyTabs(closeTab(tabs, tab.id, activeId)); - }; - let editor =
{zhCN.emptyWorkspace}
; - if (tabs.length) { - let body =
{zhCN.emptyWorkspace}
; - if (activeTab?.kind === 'sql') body = {zhCN.loading}
}> setResults((current) => [...current, result])} onMessage={pushMessage} onHistory={setHistory} />; - else if (activeTab?.kind === 'settings') body = ; - else if (activeTableTab && activeMeta) body = setTotals((current) => ({ ...current, [activeTableTab.id]: value }))} onMessage={pushMessage} />; - editor =
{tabs.map((tab) =>
)}
{body}
; - } - - return <> setSchemaAction({ kind: 'create' })} onIndexes={(table) => setSchemaAction({ kind: 'indexes', table })} onDropTable={(table) => setSchemaAction({ kind: 'drop', table })} />} editor={editor} bottom={} status={<>{zhCN.connection}{zhCN.database}{activeMeta && {activeMeta.name}}{activeTableTab && activeMeta && {zhCN.rowCount(totals[activeTableTab.id] ?? 0)}}{zhCN.pendingCount(count)}} changeCount={count} canUndo={undoStack.length > 0} canRedo={redoStack.length > 0} onCommit={requestCommit} onRollback={rollback} onUndo={undo} onRedo={redo} onSql={() => newQuery()} onSettings={openSettings} />{schemaAction && setSchemaAction(undefined)} onChanged={pushMessage} />}; -} diff --git a/chusql-web/frontend/src/adapters/MockAdapter.test.ts b/chusql-web/frontend/src/adapters/MockAdapter.test.ts deleted file mode 100644 index 473227c..0000000 --- a/chusql-web/frontend/src/adapters/MockAdapter.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/chusql-web/frontend/src/adapters/MockAdapter.ts b/chusql-web/frontend/src/adapters/MockAdapter.ts deleted file mode 100644 index 40d9e8f..0000000 --- a/chusql-web/frontend/src/adapters/MockAdapter.ts +++ /dev/null @@ -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> }; - -function makeData(): Map { - const users = Array.from({ length: 500 }, (_, index) => ({ - id: index + 1, - name: `User_${String(index + 1).padStart(3, '0')}`, - age: 18 + (index % 55), - active: index % 4 !== 0, - joined_at: `202${index % 5}-${String((index % 12) + 1).padStart(2, '0')}-${String((index % 28) + 1).padStart(2, '0')}`, - })); - const orders = Array.from({ length: 500 }, (_, index) => ({ - id: index + 1, - user_id: (index % 500) + 1, - product: ['Keyboard', 'Monitor', 'Database Course', 'Hard Drive'][index % 4], - amount: 49 + ((index * 37) % 2400), - paid: index % 3 !== 0, - })); - return new Map([ - ['users', { - columns: [ - { name: 'id', type: 'int', primaryKey: true, indexed: true }, - { name: 'name', type: 'str' }, - { name: 'age', type: 'int' }, - { name: 'active', type: 'bool' }, - { name: 'joined_at', type: 'date' }, - ], rows: users, - }], - ['orders', { - columns: [ - { name: 'id', type: 'int', primaryKey: true, indexed: true }, - { name: 'user_id', type: 'int', indexed: true }, - { name: 'product', type: 'str' }, - { name: 'amount', type: 'int' }, - { name: 'paid', type: 'bool' }, - ], rows: orders, - }], - ]); -} - -function compareValues(left: unknown, right: unknown): number { - if (left === right) return 0; - if (left === null || left === undefined) return -1; - if (right === null || right === undefined) return 1; - if (typeof left === 'number' && typeof right === 'number') return left - right; - return String(left).localeCompare(String(right), 'zh-CN', { numeric: true }); -} - -export class MockAdapter implements DataSourceAdapter { - async createTable(name: string, columns: Array>): Promise { - await this.delay(); - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || this.tables.has(name)) throw new Error('Invalid or already existing table name'); - if (!columns.length || new Set(columns.map((column) => column.name)).size !== columns.length) throw new Error('Column names must be non-empty and unique'); - this.tables.set(name, { columns: columns.map((column) => ({ ...column, primaryKey: column.name === 'id', indexed: column.name === 'id' })), rows: [] }); - } - async dropTable(table: string): Promise { - await this.delay(); - if (!this.tables.delete(table)) throw new Error('Table does not exist'); - } - async createIndex(table: string, column: string): Promise { - await this.delay(); - const found = this.tables.get(table); - const meta = found?.columns.find((item) => item.name === column); - if (!found || !meta || meta.type !== 'int' || meta.indexed) throw new Error('Only unindexed integer columns can be indexed'); - if (new Set(found.rows.map((row) => row[column])).size !== found.rows.length) throw new Error('Index column contains duplicate values and must be unique'); - meta.indexed = true; - } - async dropIndex(table: string, column: string): Promise { - await this.delay(); - const meta = this.tables.get(table)?.columns.find((item) => item.name === column); - if (!meta?.indexed || column === 'id') throw new Error('Index does not exist or is a built-in index that cannot be dropped'); - meta.indexed = false; - } - private readonly tables = makeData(); - private readonly latencyMs: number | [number, number]; - - constructor(options: { latencyMs?: number | [number, number] } = {}) { - this.latencyMs = options.latencyMs ?? [80, 200]; - } - - private async delay(): Promise { - const milliseconds = Array.isArray(this.latencyMs) - ? this.latencyMs[0] + Math.random() * (this.latencyMs[1] - this.latencyMs[0]) - : this.latencyMs; - if (milliseconds > 0) await new Promise((resolve) => window.setTimeout(resolve, milliseconds)); - } - - async listTables(_db: string): Promise { - await this.delay(); - return Array.from(this.tables, ([name, table]) => ({ name, rowCount: table.rows.length, columns: table.columns })); - } - - async getColumns(_db: string, table: string): Promise { - await this.delay(); - const found = this.tables.get(table); - if (!found) throw new Error(`Unknown table: ${table}`); - return found.columns.map((column) => ({ ...column })); - } - - async fetchRows(params: FetchRowsParams): Promise<{ rows: Record[]; total: number }> { - await this.delay(); - const found = this.tables.get(params.table); - if (!found) throw new Error(`Unknown table: ${params.table}`); - let rows = found.rows.filter((row) => params.filters.every((filter) => matchesFilter(row[filter.column], filter.expression))); - if (params.search?.trim()) { - const term = params.search.toLocaleLowerCase(); - rows = rows.filter((row) => Object.values(row).some((value) => String(value ?? '').toLocaleLowerCase().includes(term))); - } - if (params.sort.length) { - rows = [...rows].sort((left, right) => { - for (const sort of params.sort) { - const compared = compareValues(left[sort.column], right[sort.column]); - if (compared !== 0) return sort.dir === 'asc' ? compared : -compared; - } - return 0; - }); - } - const start = params.page * params.pageSize; - return { rows: rows.slice(start, start + params.pageSize).map((row) => ({ ...row })), total: rows.length }; - } - - async commitChanges(payload: { db: string; changes: CommitChange[] }): Promise<{ ok: boolean; applied: number; errors?: Array<{ index: number; message: string }> }> { - await this.delay(); - const working = new Map(Array.from(this.tables, ([name, table]) => [name, { columns: table.columns, rows: table.rows.map((row) => ({ ...row })) }])); - const errors: Array<{ index: number; message: string }> = []; - payload.changes.forEach((change, index) => { - const table = working.get(change.table); - if (!table) { - errors.push({ index, message: `Unknown table: ${change.table}` }); - return; - } - if (change.op === 'insert') { - if (table.rows.some((row) => row.id === change.values.id)) errors.push({ index, message: `Duplicate id: ${String(change.values.id)}` }); - else table.rows.push({ ...change.values }); - return; - } - const rowIndex = table.rows.findIndex((row) => row.id === change.pk); - if (rowIndex < 0) { - errors.push({ index, message: `Row not found: ${String(change.pk)}` }); - } else if (change.op === 'delete') { - table.rows.splice(rowIndex, 1); - } else { - table.rows[rowIndex] = { ...table.rows[rowIndex], ...change.set }; - } - }); - if (errors.length) return { ok: false, applied: 0, errors }; - this.tables.clear(); - for (const [name, table] of working) this.tables.set(name, table); - return { ok: true, applied: payload.changes.length }; - } - - async execStatement({ sql }: { db: string; sql: string }): Promise { - const started = performance.now(); - await this.delay(); - const normalized = sql.trim().replace(/;$/, ''); - const select = normalized.match(/^select\s+(.+?)\s+from\s+([A-Za-z_][A-Za-z0-9_]*)/is); - if (select) { - const table = this.tables.get(select[2]); - if (!table) return { type: 'error', durationMs: performance.now() - started, message: `Unknown table: ${select[2]}` }; - const columns = select[1].trim() === '*' ? table.columns.map((column) => column.name) : select[1].split(',').map((item) => item.trim()); - return { - type: 'rows', columns, - rows: table.rows.slice(0, 200).map((row) => columns.map((column) => row[column])), - durationMs: performance.now() - started, - }; - } - if (/^(insert|update|delete|create|drop)\b/i.test(normalized)) { - return { type: 'affected', affected: 1, durationMs: performance.now() - started }; - } - return { type: 'error', durationMs: performance.now() - started, message: 'MockAdapter cannot execute this statement' }; - } -} diff --git a/chusql-web/frontend/src/adapters/RestAdapter.test.ts b/chusql-web/frontend/src/adapters/RestAdapter.test.ts deleted file mode 100644 index 89e6b23..0000000 --- a/chusql-web/frontend/src/adapters/RestAdapter.test.ts +++ /dev/null @@ -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('', { status: 500 }))); - await expect(new RestAdapter().listTables('chusql')).rejects.toThrow('Request failed with status 500'); - }); -}); diff --git a/chusql-web/frontend/src/adapters/RestAdapter.ts b/chusql-web/frontend/src/adapters/RestAdapter.ts deleted file mode 100644 index 6e11307..0000000 --- a/chusql-web/frontend/src/adapters/RestAdapter.ts +++ /dev/null @@ -1,153 +0,0 @@ -import type { - ColumnMeta, CommitChange, DataSourceAdapter, FetchRowsParams, StatementResult, TableMeta, -} from '../types'; - -// REST 数据源:调用后端 JSON 接口并校验返回结构。 - -type JsonObject = Record; - -function isObject(value: unknown): value is JsonObject { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -function asColumns(value: unknown): ColumnMeta[] { - if (!Array.isArray(value)) throw new Error('The server returned an invalid column list.'); - return value.map((entry) => { - if (!isObject(entry) || typeof entry.name !== 'string' || typeof entry.type !== 'string') { - throw new Error('The server returned an invalid column description.'); - } - if (!['int', 'str', 'bool', 'date'].includes(entry.type)) throw new Error(`Unsupported column type: ${entry.type}`); - return { - name: entry.name, - type: entry.type as ColumnMeta['type'], - ...(typeof entry.primaryKey === 'boolean' ? { primaryKey: entry.primaryKey } : {}), - ...(typeof entry.indexed === 'boolean' ? { indexed: entry.indexed } : {}), - ...(typeof entry.prime === 'boolean' ? { prime: entry.prime } : {}), - ...(typeof entry.distinct === 'number' ? { distinct: entry.distinct } : {}), - ...(typeof entry.statsCapped === 'boolean' ? { statsCapped: entry.statsCapped } : {}), - }; - }); -} - -function asTable(value: unknown): TableMeta { - if (!isObject(value) || typeof value.table !== 'string' || typeof value.rowCount !== 'number') { - throw new Error('The server returned an invalid table description.'); - } - return { name: value.table, rowCount: value.rowCount, columns: asColumns(value.columns) }; -} - -async function requestJson(url: string, init?: RequestInit): Promise { - const response = await fetch(url, { credentials: 'same-origin', ...init }); - let body: unknown; - try { - body = await response.json(); - } catch { - body = undefined; - } - if (!response.ok) { - const message = isObject(body) && typeof body.message === 'string' - ? body.message - : `Request failed with status ${response.status}.`; - throw new Error(message); - } - return body; -} - -function jsonInit(method: string, body?: unknown): RequestInit { - return { - method, - headers: { 'Content-Type': 'application/json' }, - body: body === undefined ? undefined : JSON.stringify(body), - }; -} - -function changeRequest(change: CommitChange): [string, RequestInit] { - const table = encodeURIComponent(change.table); - if (change.op === 'insert') return [`/api/tables/${table}/rows`, jsonInit('POST', { values: change.values })]; - const pk = encodeURIComponent(String(change.pk)); - if (change.op === 'delete') return [`/api/tables/${table}/rows/${pk}`, jsonInit('DELETE')]; - return [`/api/tables/${table}/rows/${pk}`, jsonInit('PATCH', { values: change.set })]; -} - -export class RestAdapter implements DataSourceAdapter { - async createTable(name: string, columns: Array>): Promise { - await requestJson('/api/tables', jsonInit('POST', { name, columns })); - } - async dropTable(table: string): Promise { - await requestJson(`/api/tables/${encodeURIComponent(table)}`, jsonInit('DELETE')); - } - async createIndex(table: string, column: string): Promise { - await requestJson(`/api/tables/${encodeURIComponent(table)}/indexes`, jsonInit('POST', { column })); - } - async dropIndex(table: string, column: string): Promise { - await requestJson(`/api/tables/${encodeURIComponent(table)}/indexes/${encodeURIComponent(column)}`, jsonInit('DELETE')); - } - async listTables(_db: string): Promise { - const body = await requestJson('/api/tables'); - if (!Array.isArray(body)) throw new Error('The server returned an invalid table list.'); - return body.map(asTable); - } - - async getColumns(_db: string, table: string): Promise { - return asTable(await requestJson(`/api/tables/${encodeURIComponent(table)}`)).columns; - } - - async fetchRows(params: FetchRowsParams): Promise<{ rows: Record[]; total: number }> { - const query = new URLSearchParams({ - limit: String(params.pageSize), - offset: String(params.page * params.pageSize), - }); - const firstSort = params.sort[0]; - if (firstSort) { - query.set('sort', firstSort.column); - query.set('dir', firstSort.dir); - } - if (params.filters.length) { - query.set('filter', JSON.stringify(params.filters.map((filter) => ({ column: filter.column, value: filter.expression })))); - } - const body = await requestJson(`/api/tables/${encodeURIComponent(params.table)}/rows?${query}`); - if (!isObject(body) || !Array.isArray(body.columns) || !body.columns.every((column) => typeof column === 'string') || !Array.isArray(body.rows) || typeof body.total !== 'number') { - throw new Error('The server returned an invalid row page.'); - } - const columns = body.columns as string[]; - const rows = body.rows.map((raw) => { - if (!Array.isArray(raw)) throw new Error('The server returned an invalid row.'); - return Object.fromEntries(columns.map((column, index) => [column, raw[index]])); - }); - return { rows, total: body.total }; - } - - async commitChanges(payload: { db: string; changes: CommitChange[] }): Promise<{ ok: boolean; applied: number; errors?: Array<{ index: number; message: string }> }> { - let applied = 0; - for (const [index, change] of payload.changes.entries()) { - const [url, init] = changeRequest(change); - try { - await requestJson(url, init); - applied += 1; - } catch (error) { - return { - ok: false, - applied, - errors: [{ index, message: error instanceof Error ? error.message : String(error) }], - }; - } - } - return { ok: true, applied }; - } - - async execStatement({ sql }: { db: string; sql: string }): Promise { - const started = performance.now(); - try { - const body = await requestJson('/api/query', jsonInit('POST', { sql })); - if (!isObject(body) || !Array.isArray(body.columns) || !Array.isArray(body.rows)) { - throw new Error('The server returned an invalid query result.'); - } - const durationMs = performance.now() - started; - return body.columns.length - ? { type: 'rows', columns: body.columns as string[], rows: body.rows as unknown[][], durationMs } - : { type: 'affected', affected: typeof body.rowCount === 'number' ? body.rowCount : 0, durationMs }; - } catch (error) { - return { type: 'error', durationMs: performance.now() - started, message: error instanceof Error ? error.message : String(error) }; - } - } -} diff --git a/chusql-web/frontend/src/components/common/AuthGate.test.tsx b/chusql-web/frontend/src/components/common/AuthGate.test.tsx deleted file mode 100644 index 592c396..0000000 --- a/chusql-web/frontend/src/components/common/AuthGate.test.tsx +++ /dev/null @@ -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(

protected content

); - - expect(await screen.findByRole('heading', { name: '登录 ChuSQL' })).toBeInTheDocument(); - expect(screen.queryByText('protected content')).not.toBeInTheDocument(); - fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'chusql' } }); - fireEvent.click(screen.getByRole('button', { name: '登录' })); - - await waitFor(() => expect(screen.getByText('protected content')).toBeInTheDocument()); - expect(request).toHaveBeenLastCalledWith('/api/login', expect.objectContaining({ method: 'POST' })); - }); -}); diff --git a/chusql-web/frontend/src/components/common/AuthGate.tsx b/chusql-web/frontend/src/components/common/AuthGate.tsx deleted file mode 100644 index 60f7e2d..0000000 --- a/chusql-web/frontend/src/components/common/AuthGate.tsx +++ /dev/null @@ -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('checking'); - const [user, setUser] = useState('root'); - const [password, setPassword] = useState(''); - const [busy, setBusy] = useState(false); - const [failed, setFailed] = useState(false); - - useEffect(() => { - const controller = new AbortController(); - void fetch('/api/session', { credentials: 'same-origin', signal: controller.signal }) - .then((response) => setState(response.ok ? 'signed-in' : 'signed-out')) - .catch((error: unknown) => { - if (!(error instanceof DOMException && error.name === 'AbortError')) setState('signed-out'); - }); - return () => controller.abort(); - }, []); - - const submit = async (event: FormEvent) => { - event.preventDefault(); - setBusy(true); - setFailed(false); - try { - const response = await fetch('/api/login', { - method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ user, password }), - }); - if (response.ok) setState('signed-in'); - else setFailed(true); - } catch { - setFailed(true); - } finally { - setBusy(false); - } - }; - - if (state === 'signed-in') return children; - if (state === 'checking') return

{zhCN.signingIn}

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

{zhCN.signInTitle}

- - - {failed &&

{zhCN.signInFailed}

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

{title}

- } label={zhCN.close} compact onClick={onClose} /> -
-
{children}
- {footer &&
{footer}
} -
- ); -} diff --git a/chusql-web/frontend/src/components/common/auth.module.css b/chusql-web/frontend/src/components/common/auth.module.css deleted file mode 100644 index 9961b1f..0000000 --- a/chusql-web/frontend/src/components/common/auth.module.css +++ /dev/null @@ -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; } diff --git a/chusql-web/frontend/src/components/common/common.module.css b/chusql-web/frontend/src/components/common/common.module.css deleted file mode 100644 index 3d7b4d5..0000000 --- a/chusql-web/frontend/src/components/common/common.module.css +++ /dev/null @@ -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); } diff --git a/chusql-web/frontend/src/components/grid/DataGrid.test.tsx b/chusql-web/frontend/src/components/grid/DataGrid.test.tsx deleted file mode 100644 index df6ab7a..0000000 --- a/chusql-web/frontend/src/components/grid/DataGrid.test.tsx +++ /dev/null @@ -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(); - - expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); - expect(screen.queryByText('#')).not.toBeInTheDocument(); - expect(screen.queryByRole('textbox', { name: '筛选 name' })).not.toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'name' })); - expect(onSort).toHaveBeenCalledWith('name'); - fireEvent.click(screen.getByRole('button', { name: '筛选 name' })); - fireEvent.change(screen.getByRole('textbox', { name: '筛选 name' }), { target: { value: 'Ada' } }); - expect(onFilter).toHaveBeenCalledWith('name', 'Ada'); - - fireEvent.click(screen.getByText('Ada')); - expect(onSelectRow).toHaveBeenCalledWith(0, 'single'); - fireEvent.click(screen.getByText('Ada'), { ctrlKey: true }); - expect(onSelectRow).toHaveBeenCalledWith(0, 'toggle'); - fireEvent.click(screen.getByText('Ada'), { shiftKey: true }); - expect(onSelectRow).toHaveBeenCalledWith(0, 'range'); - fireEvent.keyDown(screen.getByText('Ada'), { key: ' ' }); - expect(onSelectRow).toHaveBeenCalledTimes(4); - fireEvent.keyDown(screen.getByRole('separator', { name: '调整 name 列宽' }), { key: 'ArrowRight' }); - expect(onWidth).toHaveBeenCalledWith('name', 150); - expect(screen.getByRole('table')).toHaveStyle({ width: '280px' }); - fireEvent.keyDown(screen.getByRole('textbox', { name: '筛选 name' }), { key: 'Escape' }); - expect(screen.queryByRole('textbox', { name: '筛选 name' })).not.toBeInTheDocument(); - }); -}); diff --git a/chusql-web/frontend/src/components/grid/DataGrid.tsx b/chusql-web/frontend/src/components/grid/DataGrid.tsx deleted file mode 100644 index 12b2e3b..0000000 --- a/chusql-web/frontend/src/components/grid/DataGrid.tsx +++ /dev/null @@ -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 { _tempId?: string } - -export type SelectionMode = 'single' | 'toggle' | 'range'; - -interface DataGridProps { - table: string; - columns: ColumnMeta[]; - rows: GridRow[]; - changes: ChangeSet; - nullText: string; - sort?: { column: string; dir: 'asc' | 'desc' }; - filters: Record; - widths: Record; - selectedRows: Set; - onSort: (column: string) => void; - onFilter: (column: string, expression: string) => void; - onWidth: (column: string, width: number) => void; - onSelectRow: (index: number, mode: SelectionMode) => void; - onEdit: (row: GridRow, column: string, oldValue: unknown, newValue: unknown) => void; - onDelete: (row: GridRow) => void; -} - -interface EditingCell { rowIndex: number; column: string; value: unknown } - -// 行键:临时行用 tempId,其余用主键。 -export function rowKey(row: GridRow, index: number): string { return row._tempId ?? String(row.id ?? index); } - -function valueFor(row: GridRow, table: string, column: string, changes: ChangeSet): unknown { - if (row._tempId) return changes.inserts.get(row._tempId)?.values[column] ?? row[column]; - return changes.updates.get(changeKey(table, row.id, column))?.newValue ?? row[column]; -} - -function Editor({ column, value, onChange, onKeyDown, onBlur }: { column: ColumnMeta; value: unknown; onChange: (value: unknown) => void; onKeyDown: (event: KeyboardEvent) => void; onBlur: () => void }) { - if (column.type === 'bool') return ; - return onChange(event.target.value)} onKeyDown={onKeyDown} onBlur={onBlur} />; -} - -export function DataGrid(props: DataGridProps) { - const [editing, setEditing] = useState(); - const [filterColumn, setFilterColumn] = useState(); - const deleted = useMemo(() => new Set(Array.from(props.changes.deletes.values()).filter((item) => item.table === props.table).map((item) => String(item.pk))), [props.changes, props.table]); - const commitEdit = () => { - if (!editing) return; - const row = props.rows[editing.rowIndex]; - const column = props.columns.find((item) => item.name === editing.column); - if (!row || !column) return setEditing(undefined); - let next: unknown = editing.value; - if (column.type === 'int') next = Number(editing.value); - if (column.type === 'bool') next = editing.value === true || editing.value === 'true'; - props.onEdit(row, column.name, valueFor(row, props.table, column.name, props.changes), next); - setEditing(undefined); - }; - const onEditorKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') setEditing(undefined); - if (event.key === 'Enter' || event.key === 'Tab') { event.preventDefault(); commitEdit(); } - }; - const paste = (event: ClipboardEvent, rowIndex: number, columnIndex: number) => { - const matrix = event.clipboardData.getData('text/plain').replace(/\r/g, '').split('\n').filter((line) => line.length).map((line) => line.split('\t')); - if (!matrix.length) return; - event.preventDefault(); - matrix.forEach((values, y) => values.forEach((value, x) => { - const row = props.rows[rowIndex + y]; - const column = props.columns[columnIndex + x]; - if (row && column && !column.primaryKey) props.onEdit(row, column.name, valueFor(row, props.table, column.name, props.changes), value); - })); - }; - const beginResize = (event: PointerEvent, column: string) => { - event.preventDefault(); - const startX = event.clientX; - const startWidth = props.widths[column] ?? 140; - const move = (next: globalThis.PointerEvent) => props.onWidth(column, Math.max(70, startWidth + next.clientX - startX)); - const stop = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', stop); }; - window.addEventListener('pointermove', move); window.addEventListener('pointerup', stop); - }; - return ( -
sum + (props.widths[column.name] ?? 140), 0) }}> - {props.columns.map((column) => )} - - {props.columns.map((column) => )} - - - {props.rows.map((row, rowIndex) => { - const key = rowKey(row, rowIndex); - const isDeleted = !row._tempId && deleted.has(String(row.id)); - return { event.preventDefault(); props.onDelete(row); }}> - {props.columns.map((column, columnIndex) => { - const value = valueFor(row, props.table, column.name, props.changes); - const dirty = row._tempId || props.changes.updates.has(changeKey(props.table, row.id, column.name)); - const isEditing = editing?.rowIndex === rowIndex && editing.column === column.name; - return ; - })} - ; - })} - -
-
-
- {filterColumn === column.name &&
props.onFilter(column.name, event.target.value)} onKeyDown={(event) => { if (event.key === 'Escape' || event.key === 'Enter') { setFilterColumn(undefined); event.currentTarget.closest('th')?.querySelector('button')?.focus(); } }} />
} - { if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); props.onWidth(column.name, Math.max(70, (props.widths[column.name] ?? 140) + (event.key === 'ArrowRight' ? 10 : -10))); } }} onPointerDown={(event) => beginResize(event, column.name)} /> -
{ if (!isEditing && event.detail < 2) props.onSelectRow(rowIndex, event.ctrlKey || event.metaKey ? 'toggle' : event.shiftKey ? 'range' : 'single'); }} onKeyDown={(event) => { if (!isEditing && event.key === ' ') { event.preventDefault(); props.onSelectRow(rowIndex, event.ctrlKey || event.metaKey ? 'toggle' : 'single'); } }} onPaste={(event) => paste(event, rowIndex, columnIndex)} onDoubleClick={() => !column.primaryKey && setEditing({ rowIndex, column: column.name, value })}> - {isEditing ? setEditing({ ...editing, value })} onKeyDown={onEditorKeyDown} onBlur={commitEdit} /> : value === null || value === undefined ? {props.nullText} : String(value)} -
- ); -} diff --git a/chusql-web/frontend/src/components/grid/RowDialog.test.tsx b/chusql-web/frontend/src/components/grid/RowDialog.test.tsx deleted file mode 100644 index 2975715..0000000 --- a/chusql-web/frontend/src/components/grid/RowDialog.test.tsx +++ /dev/null @@ -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(); - expect(screen.getByLabelText('id')).toHaveValue(''); - fireEvent.click(screen.getByRole('button', { name: '加入待提交' })); - expect(onInsert).not.toHaveBeenCalled(); - expect(screen.getByRole('alert')).toHaveTextContent('id:请输入合法整数'); - fireEvent.change(screen.getByLabelText('id'), { target: { value: '800' } }); - fireEvent.change(screen.getByLabelText('name'), { target: { value: "Ada's" } }); - fireEvent.click(screen.getByRole('button', { name: '加入待提交' })); - expect(onInsert).toHaveBeenCalledWith({ id: 800, name: "Ada's", active: false }); -}); diff --git a/chusql-web/frontend/src/components/grid/RowDialog.tsx b/chusql-web/frontend/src/components/grid/RowDialog.tsx deleted file mode 100644 index ace2878..0000000 --- a/chusql-web/frontend/src/components/grid/RowDialog.tsx +++ /dev/null @@ -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) => void }) { - const [draft, setDraft] = useState>(() => Object.fromEntries(table.columns.map((column) => [column.name, column.type === 'bool' ? 'false' : '']))); - const [error, setError] = useState(''); - const submit = () => { - const values: Record = {}; - for (const column of table.columns) { - const value = draft[column.name]; - if (column.type === 'int' && (!/^-?\d+$/.test(value) || !Number.isSafeInteger(Number(value)))) return setError(zhCN.invalidInteger(column.name)); - if (column.type === 'date' && !/^\d{4}-\d{2}-\d{2}$/.test(value)) return setError(zhCN.chooseDate(column.name)); - values[column.name] = column.type === 'int' ? Number(value) : column.type === 'bool' ? value === 'true' : value; - } - onInsert(values); - }; - return }> -

{zhCN.insertRowHint}

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

{error}

} -
; -} diff --git a/chusql-web/frontend/src/components/grid/TableEditor.tsx b/chusql-web/frontend/src/components/grid/TableEditor.tsx deleted file mode 100644 index 0ca4345..0000000 --- a/chusql-web/frontend/src/components/grid/TableEditor.tsx +++ /dev/null @@ -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([]); - const [page, setPage] = useState(0); - const [total, setTotal] = useState(0); - const [sort, setSort] = useState<{ column: string; dir: 'asc' | 'desc' }>(); - const [filters, setFilters] = useState>({}); - const [widths, setWidths] = useState>(() => JSON.parse(localStorage.getItem(`chusql.widths.${table.name}`) ?? '{}')); - const [selectedRows, setSelectedRows] = useState(new Set()); - const [selectionAnchor, setSelectionAnchor] = useState(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [replaceOpen, setReplaceOpen] = useState(false); - const [find, setFind] = useState(''); - const [replacement, setReplacement] = useState(''); - const [insertOpen, setInsertOpen] = useState(false); - const requestId = useRef(0); - const { changes, updateCell, insertRow, updateInsert, deleteRow } = useChangeStore(); - const inserted = useMemo(() => Array.from(changes.inserts.values()).filter((item) => item.table === table.name).map((item): GridRow => ({ ...item.values, _tempId: item.tempId })), [changes, table.name]); - const visibleRows: GridRow[] = [...rows, ...inserted]; - const activeFilters: FilterExpr[] = Object.entries(filters).filter(([, value]) => value.trim()).map(([column, expression]) => ({ column, expression })); - - const load = async (nextPage: number, append: boolean) => { - const id = ++requestId.current; setLoading(true); setError(''); - try { - const result = await adapter.fetchRows({ db: zhCN.database, table: table.name, page: nextPage, pageSize: settings.pageSize, sort: sort ? [sort] : [], filters: activeFilters }); - if (id !== requestId.current) return; - setRows((current) => append ? [...current, ...result.rows] : result.rows); - if (!append) { setSelectedRows(new Set()); setSelectionAnchor(undefined); } - setTotal(result.total); setPage(nextPage); onTotal(result.total); - } catch (reason) { if (id === requestId.current) setError(reason instanceof Error ? reason.message : String(reason)); } - finally { if (id === requestId.current) setLoading(false); } - }; - - useEffect(() => { const timer = window.setTimeout(() => void load(0, false), 180); return () => window.clearTimeout(timer); }, [table.name, settings.pageSize, sort?.column, sort?.dir, JSON.stringify(filters), revision]); - const sortColumn = (column: string) => setSort((current) => current?.column !== column ? { column, dir: 'asc' } : { column, dir: current.dir === 'asc' ? 'desc' : 'asc' }); - const setWidth = (column: string, width: number) => setWidths((current) => { const next = { ...current, [column]: width }; localStorage.setItem(`chusql.widths.${table.name}`, JSON.stringify(next)); return next; }); - const addRow = (values: Record) => { - const tempId = `temp-${crypto.randomUUID()}`; - insertRow(table.name, tempId, values); - setInsertOpen(false); - onMessage(zhCN.rowStaged); - }; - const stageDelete = (row: GridRow) => { - deleteRow(table.name, row._tempId ?? row.id, row, Boolean(row._tempId)); - }; - const selectRow = (index: number, mode: SelectionMode) => { - const key = rowKey(visibleRows[index], index); - if (mode === 'range' && selectionAnchor !== undefined) { - const from = Math.min(selectionAnchor, index); - const to = Math.max(selectionAnchor, index); - setSelectedRows(new Set(visibleRows.slice(from, to + 1).map((row, offset) => rowKey(row, from + offset)))); - return; - } - if (mode === 'toggle') { - setSelectedRows((current) => { const next = new Set(current); if (next.has(key)) next.delete(key); else next.add(key); return next; }); - setSelectionAnchor(index); - return; - } - setSelectedRows(new Set([key])); - setSelectionAnchor(index); - }; - const removeSelected = () => { - if (!selectedRows.size) return onMessage(zhCN.selectedRowsRequired); - visibleRows.forEach((row, index) => { if (selectedRows.has(row._tempId ?? String(row.id ?? index))) stageDelete(row); }); - setSelectedRows(new Set()); - setSelectionAnchor(undefined); - }; - const replaceSelected = () => { - if (!selectedRows.size) return onMessage(zhCN.selectedRowsRequired); - visibleRows.forEach((row, index) => { - const key = row._tempId ?? String(row.id ?? index); if (!selectedRows.has(key)) return; - table.columns.filter((column) => !column.primaryKey && column.type === 'str').forEach((column) => { - const oldValue = row._tempId ? changes.inserts.get(row._tempId)?.values[column.name] : row[column.name]; - if (String(oldValue ?? '').includes(find)) { - const next = String(oldValue ?? '').replaceAll(find, replacement); - if (row._tempId) updateInsert(row._tempId, { ...(changes.inserts.get(row._tempId)?.values ?? row), [column.name]: next }); - else updateCell(table.name, row.id, column.name, row[column.name], next); - } - }); - }); setReplaceOpen(false); - }; - - return
-
- } label={zhCN.refresh} onClick={() => void load(0, false)} /> - } label={zhCN.insertRow} onClick={() => setInsertOpen(true)} /> - } label={zhCN.deleteRows} disabled={!selectedRows.size} onClick={removeSelected} /> - } label={zhCN.batchReplace} onClick={() => selectedRows.size ? setReplaceOpen(true) : onMessage(zhCN.selectedRowsRequired)} /> - {zhCN.selectedRows(selectedRows.size)}{zhCN.rowCount(total)} -
- {replaceOpen &&
} -
{ const target = event.currentTarget; if (!loading && rows.length < total && target.scrollTop + target.clientHeight >= target.scrollHeight - 32) void load(page + 1, true); }}> - {error ?
{zhCN.loadError}: {error}
: setFilters((current) => ({ ...current, [column]: expression }))} onWidth={setWidth} onSelectRow={selectRow} onEdit={(row, column, oldValue, newValue) => row._tempId ? updateInsert(row._tempId, { ...(changes.inserts.get(row._tempId)?.values ?? row), [column]: newValue }) : updateCell(table.name, row.id, column, oldValue, newValue)} onDelete={stageDelete} />} - {loading &&
{zhCN.loading}
} - {!loading && !visibleRows.length && !error &&
{zhCN.noRows}
} -
- {insertOpen && setInsertOpen(false)} onInsert={addRow} />} -
; -} diff --git a/chusql-web/frontend/src/components/grid/grid.module.css b/chusql-web/frontend/src/components/grid/grid.module.css deleted file mode 100644 index 98e6541..0000000 --- a/chusql-web/frontend/src/components/grid/grid.module.css +++ /dev/null @@ -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; } diff --git a/chusql-web/frontend/src/components/layout/BottomPanel.tsx b/chusql-web/frontend/src/components/layout/BottomPanel.tsx deleted file mode 100644 index 003a5a4..0000000 --- a/chusql-web/frontend/src/components/layout/BottomPanel.tsx +++ /dev/null @@ -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) => {token.text})}; -} - -export function BottomPanel({ changes, results, messages, onUndoOne }: BottomPanelProps) { - const [tab, setTab] = useState<'changes' | 'results' | 'messages'>('changes'); - const [resultIndex, setResultIndex] = useState(0); - const entries = [ - ...Array.from(changes.updates, ([key, value]) => ({ kind: 'updates' as const, key, label: zhCN.updateChange(value.table, value.column), detail: updateSql(value.table, value.pk, value.column, value.newValue) })), - ...Array.from(changes.inserts, ([key, value]) => ({ kind: 'inserts' as const, key, label: zhCN.insertChange(value.table), detail: insertSql(value.table, value.values) })), - ...Array.from(changes.deletes, ([key, value]) => ({ kind: 'deletes' as const, key, label: zhCN.deleteChange(value.table), detail: deleteSql(value.table, value.pk) })), - ...Array.from(changes.schemas, ([key, value]) => ({ kind: 'schemas' as const, key, label: schemaLabel(value), detail: schemaSql(value) })), - ]; - const result = results[resultIndex]; - return
- -
- {tab === 'changes' && (entries.length ?
    {entries.map((entry) =>
  • {entry.label}} label={zhCN.undoChange} onClick={() => onUndoOne(entry.kind, entry.key)} />
  • )}
:

{zhCN.noPendingChanges}

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

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

} :

{zhCN.noResults}

)} - {tab === 'messages' &&
    {messages.map((message, index) =>
  1. {message}
  2. )}
} -
-
; -} diff --git a/chusql-web/frontend/src/components/layout/Explorer.test.tsx b/chusql-web/frontend/src/components/layout/Explorer.test.tsx deleted file mode 100644 index 8eec040..0000000 --- a/chusql-web/frontend/src/components/layout/Explorer.test.tsx +++ /dev/null @@ -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(); - const expand = screen.getByRole('button', { name: '展开 users 的列' }); - expect(screen.queryByText('email')).not.toBeInTheDocument(); - fireEvent.click(expand); - expect(expand).toHaveAttribute('aria-expanded', 'true'); - for (const name of ['主键列', '索引列', '普通列']) expect(screen.getByRole('img', { name })).toBeInTheDocument(); - expect(onOpenTable).not.toHaveBeenCalled(); - fireEvent.click(screen.getByRole('button', { name: '打开表 users' })); - expect(onOpenTable).toHaveBeenCalledWith('users'); - fireEvent.click(expand); - expect(screen.queryByText('email')).not.toBeInTheDocument(); -}); diff --git a/chusql-web/frontend/src/components/layout/Explorer.tsx b/chusql-web/frontend/src/components/layout/Explorer.tsx deleted file mode 100644 index 0ed8866..0000000 --- a/chusql-web/frontend/src/components/layout/Explorer.tsx +++ /dev/null @@ -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()); - return ( - - ); -} diff --git a/chusql-web/frontend/src/components/layout/SchemaDialog.test.tsx b/chusql-web/frontend/src/components/layout/SchemaDialog.test.tsx deleted file mode 100644 index d55037c..0000000 --- a/chusql-web/frontend/src/components/layout/SchemaDialog.test.tsx +++ /dev/null @@ -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(); - fireEvent.change(screen.getByLabelText('表名'), { target: { value: 'items' } }); - fireEvent.change(screen.getByLabelText('列名 2'), { target: { value: 'id' } }); - fireEvent.click(screen.getByRole('button', { name: '建表' })); - expect(screen.getByRole('alert')).toHaveTextContent('列名重复'); - expect(useChangeStore.getState().changes.schemas.size).toBe(0); - fireEvent.change(screen.getByLabelText('列名 2'), { target: { value: 'title' } }); - fireEvent.click(screen.getByRole('button', { name: '建表' })); - expect(onChanged).toHaveBeenCalledWith('已暂存:CREATE TABLE items (id int, title str);'); - expect(onClose).toHaveBeenCalled(); - expect([...useChangeStore.getState().changes.schemas.values()]).toEqual([ - { op: 'createTable', table: 'items', columns: [{ name: 'id', type: 'int' }, { name: 'title', type: 'str' }] }, - ]); -}); - -it('stages a drop table change on the first click without asking for the name', async () => { - const adapter = new MockAdapter({ latencyMs: 0 }); - const table = (await adapter.listTables('chusql'))[0]; - const onChanged = vi.fn(); - render(); - expect(screen.queryByLabelText(/输入/)).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: '永久删除表' })); - expect(onChanged).toHaveBeenCalledWith('已暂存:DROP TABLE users;'); - expect([...useChangeStore.getState().changes.schemas.values()]).toEqual([{ op: 'dropTable', table: 'users' }]); - expect((await adapter.listTables('chusql')).some((item) => item.name === 'users')).toBe(true); -}); - -it('protects the built-in primary key index and non-integer columns', async () => { - const adapter = new MockAdapter({ latencyMs: 0 }); - const table = (await adapter.listTables('chusql'))[0]; - render(); - const rows = screen.getAllByRole('listitem'); - expect(within(rows[0]).getByRole('button')).toBeDisabled(); - expect(within(rows[1]).getByRole('button')).toBeDisabled(); - expect(within(rows[2]).getByRole('button')).toBeEnabled(); -}); - -it('stages index creation and deletion on the first click and reflects the pending state', async () => { - const adapter = new MockAdapter({ latencyMs: 0 }); - await adapter.createTable('items', [{ name: 'id', type: 'int' }, { name: 'sku', type: 'int' }]); - const table = (await adapter.listTables('chusql')).find((item) => item.name === 'items')!; - const onChanged = vi.fn(); - render(); - expect(screen.getByText('无索引')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: '建索引' })); - expect(screen.getByText('唯一索引')).toBeInTheDocument(); - expect(onChanged).toHaveBeenCalledWith('已暂存:CREATE INDEX ON items (sku);'); - fireEvent.click(within(screen.getAllByRole('listitem')[1]).getByRole('button', { name: '删索引' })); - expect(screen.getByText('无索引')).toBeInTheDocument(); - expect([...useChangeStore.getState().changes.schemas.keys()]).toEqual(['createIndex:items:sku', 'dropIndex:items:sku']); -}); - -it('leaves nothing staged when the create dialog is cancelled', () => { - const onClose = vi.fn(); - render(); - fireEvent.change(screen.getByLabelText('表名'), { target: { value: 'items' } }); - fireEvent.click(screen.getByRole('button', { name: '取消' })); - expect(onClose).toHaveBeenCalled(); - expect(useChangeStore.getState().changes.schemas.size).toBe(0); -}); diff --git a/chusql-web/frontend/src/components/layout/SchemaDialog.tsx b/chusql-web/frontend/src/components/layout/SchemaDialog.tsx deleted file mode 100644 index 5749b54..0000000 --- a/chusql-web/frontend/src/components/layout/SchemaDialog.tsx +++ /dev/null @@ -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>>([{ name: 'id', type: 'int' }, { name: '', type: 'str' }]); - const [error, setError] = useState(''); - const stageSchema = useChangeStore((state) => state.stageSchema); - const schemas = useChangeStore((state) => state.changes.schemas); - const target = action.kind === 'create' ? undefined : action.table; - const stagedIndex = new Map(); - for (const change of schemas.values()) { - if (change.table !== target?.name) continue; - if (change.op === 'createIndex') stagedIndex.set(change.column, true); - else if (change.op === 'dropIndex') stagedIndex.set(change.column, false); - } - const stage = (change: SchemaChange) => { stageSchema(change); onChanged(zhCN.stagedMessage(schemaSql(change))); }; - const create = () => { - if (!identifier.test(name) || columns.some((column) => !identifier.test(column.name))) return setError(zhCN.invalidIdentifier); - if (new Set(columns.map((column) => column.name)).size !== columns.length) return setError(zhCN.duplicateColumns); - stage({ op: 'createTable', table: name, columns }); - onClose(); - }; - const drop = () => { - if (!target) return; - stage({ op: 'dropTable', table: target.name }); - onClose(); - }; - const changeIndex = (column: string, indexed: boolean) => { - if (!target) return; - stage(indexed - ? { op: 'dropIndex', table: target.name, column } - : { op: 'createIndex', table: target.name, column }); - }; - const title = action.kind === 'create' ? zhCN.newTableTitle : `${action.kind === 'drop' ? zhCN.dropTableTitle : zhCN.indexManagerTitle} · ${target?.name}`; - return {action.kind === 'create' && }{action.kind === 'drop' && }}> - {action.kind === 'create' ?
- -

{zhCN.createTableHint}

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

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

- : <> -

{zhCN.indexHint}

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

{error}

} -
; -} diff --git a/chusql-web/frontend/src/components/layout/Workbench.test.tsx b/chusql-web/frontend/src/components/layout/Workbench.test.tsx deleted file mode 100644 index e1fa997..0000000 --- a/chusql-web/frontend/src/components/layout/Workbench.test.tsx +++ /dev/null @@ -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(} editor={
} bottom={
} status={
} changeCount={0} canUndo={false} canRedo={false} onCommit={vi.fn()} onRollback={vi.fn()} onUndo={vi.fn()} onRedo={vi.fn()} onSql={vi.fn()} onSettings={vi.fn()} />); -} - -// 取侧边栏与船坞两条拖动条 -function splitters() { - return { sidebar: screen.getByRole('separator', { name: zhCN.resizeSidebar }), dock: screen.getByRole('separator', { name: zhCN.resizeDock }) }; -} - -// 取底部船坞默认高度 -function defaultDock() { return Math.round(window.innerHeight * 0.22); } - -// 取底部船坞高度上限 -function dockCeiling() { return Math.max(96, Math.round(window.innerHeight * 0.7)); } - -beforeEach(() => localStorage.clear()); -afterEach(() => localStorage.clear()); - -it('resizes the sidebar and the dock by 16px with arrow keys', () => { - renderWorkbench(); - const { sidebar, dock } = splitters(); - expect(sidebar).toHaveAttribute('aria-valuenow', '250'); - fireEvent.keyDown(sidebar, { key: 'ArrowRight' }); - expect(sidebar).toHaveAttribute('aria-valuenow', '266'); - fireEvent.keyDown(sidebar, { key: 'ArrowLeft' }); - expect(sidebar).toHaveAttribute('aria-valuenow', '250'); - expect(dock).toHaveAttribute('aria-valuenow', String(defaultDock())); - fireEvent.keyDown(dock, { key: 'ArrowUp' }); - expect(dock).toHaveAttribute('aria-valuenow', String(defaultDock() + 16)); - fireEvent.keyDown(dock, { key: 'ArrowDown' }); - expect(dock).toHaveAttribute('aria-valuenow', String(defaultDock())); -}); - -it('clamps the sidebar to 160..560 and the dock to 96 and above', () => { - renderWorkbench(); - const { sidebar, dock } = splitters(); - for (let i = 0; i < 30; i += 1) fireEvent.keyDown(sidebar, { key: 'ArrowLeft' }); - expect(sidebar).toHaveAttribute('aria-valuenow', '160'); - fireEvent.keyDown(sidebar, { key: 'ArrowLeft' }); - expect(sidebar).toHaveAttribute('aria-valuenow', '160'); - for (let i = 0; i < 40; i += 1) fireEvent.keyDown(sidebar, { key: 'ArrowRight' }); - expect(sidebar).toHaveAttribute('aria-valuenow', '560'); - fireEvent.keyDown(sidebar, { key: 'ArrowRight' }); - expect(sidebar).toHaveAttribute('aria-valuenow', '560'); - for (let i = 0; i < 30; i += 1) fireEvent.keyDown(dock, { key: 'ArrowDown' }); - expect(dock).toHaveAttribute('aria-valuenow', '96'); - fireEvent.keyDown(dock, { key: 'ArrowDown' }); - expect(dock).toHaveAttribute('aria-valuenow', '96'); - for (let i = 0; i < 60; i += 1) fireEvent.keyDown(dock, { key: 'ArrowUp' }); - expect(dock).toHaveAttribute('aria-valuenow', String(dockCeiling())); -}); - -it('exposes separator roles with orientation, value and bounds', () => { - renderWorkbench(); - const { sidebar, dock } = splitters(); - expect(sidebar).toHaveAttribute('aria-orientation', 'vertical'); - expect(sidebar).toHaveAttribute('aria-valuemin', '160'); - expect(sidebar).toHaveAttribute('aria-valuemax', '560'); - expect(sidebar).toHaveAttribute('tabindex', '0'); - expect(dock).toHaveAttribute('aria-orientation', 'horizontal'); - expect(dock).toHaveAttribute('aria-valuemin', '96'); - expect(dock).toHaveAttribute('aria-valuemax', String(dockCeiling())); - expect(dock).toHaveAttribute('tabindex', '0'); -}); - -it('stores changed sizes in localStorage under chusql.layout.v1', () => { - renderWorkbench(); - const { sidebar, dock } = splitters(); - fireEvent.keyDown(sidebar, { key: 'ArrowRight' }); - fireEvent.keyDown(dock, { key: 'ArrowUp' }); - expect(JSON.parse(localStorage.getItem('chusql.layout.v1') ?? '{}')).toEqual({ sidebarWidth: 266, dockHeight: defaultDock() + 16 }); -}); - -it('restores saved sizes from localStorage on the first render', () => { - localStorage.setItem('chusql.layout.v1', JSON.stringify({ sidebarWidth: 300, dockHeight: 150 })); - renderWorkbench(); - const { sidebar, dock } = splitters(); - expect(sidebar).toHaveAttribute('aria-valuenow', '300'); - expect(dock).toHaveAttribute('aria-valuenow', '150'); -}); - -it('clamps saved sizes that fall outside the allowed range', () => { - localStorage.setItem('chusql.layout.v1', JSON.stringify({ sidebarWidth: 9999, dockHeight: 1 })); - renderWorkbench(); - const { sidebar, dock } = splitters(); - expect(sidebar).toHaveAttribute('aria-valuenow', '560'); - expect(dock).toHaveAttribute('aria-valuenow', '96'); -}); - -it('falls back to default sizes when the stored layout is unreadable', () => { - localStorage.setItem('chusql.layout.v1', '{not json'); - renderWorkbench(); - const { sidebar, dock } = splitters(); - expect(sidebar).toHaveAttribute('aria-valuenow', '250'); - expect(dock).toHaveAttribute('aria-valuenow', String(defaultDock())); -}); - -it('widens the sidebar by 40px when the vertical splitter is dragged right', () => { - renderWorkbench(); - const { sidebar } = splitters(); - act(() => { - sidebar.dispatchEvent(new MouseEvent('pointerdown', { clientX: 300, bubbles: true })); - }); - act(() => { - window.dispatchEvent(new MouseEvent('pointermove', { clientX: 340, bubbles: true })); - }); - act(() => { - window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); - }); - expect(sidebar).toHaveAttribute('aria-valuenow', '290'); -}); - -it('makes the dock taller by 40px when the horizontal splitter is dragged up', () => { - renderWorkbench(); - const { dock } = splitters(); - act(() => { - dock.dispatchEvent(new MouseEvent('pointerdown', { clientY: 500, bubbles: true })); - }); - act(() => { - window.dispatchEvent(new MouseEvent('pointermove', { clientY: 460, bubbles: true })); - }); - act(() => { - window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); - }); - expect(dock).toHaveAttribute('aria-valuenow', String(defaultDock() + 40)); -}); - -it('stops resizing after the pointer is released', () => { - renderWorkbench(); - const { sidebar } = splitters(); - act(() => { - sidebar.dispatchEvent(new MouseEvent('pointerdown', { clientX: 300, bubbles: true })); - }); - act(() => { - window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); - }); - act(() => { - window.dispatchEvent(new MouseEvent('pointermove', { clientX: 400, bubbles: true })); - }); - expect(sidebar).toHaveAttribute('aria-valuenow', '250'); -}); diff --git a/chusql-web/frontend/src/components/layout/Workbench.tsx b/chusql-web/frontend/src/components/layout/Workbench.tsx deleted file mode 100644 index 93eedb9..0000000 --- a/chusql-web/frontend/src/components/layout/Workbench.tsx +++ /dev/null @@ -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(readLayout); - const [drag, setDrag] = useState(); - const { sidebar, dock } = layout; - - useEffect(() => { writeLayout(layout); }, [layout]); - - useEffect(() => { - if (!drag) return; - const move = (event: globalThis.PointerEvent) => setLayout((current) => { - const delta = drag.kind === 'sidebar' ? event.clientX - drag.start : drag.start - event.clientY; - const next = drag.size + delta; - return drag.kind === 'sidebar' ? { ...current, sidebar: clampSize('sidebar', next) } : { ...current, dock: clampSize('dock', next) }; - }); - const stop = () => setDrag(undefined); - window.addEventListener('pointermove', move); - window.addEventListener('pointerup', stop); - return () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', stop); }; - }, [drag]); - - const resize = (kind: SplitterKind, value: number) => setLayout((current) => (kind === 'sidebar' ? { ...current, sidebar: clampSize('sidebar', value) } : { ...current, dock: clampSize('dock', value) })); - - const startDrag = (kind: SplitterKind, event: PointerEvent) => { - event.preventDefault(); - setDrag({ kind, start: kind === 'sidebar' ? event.clientX : event.clientY, size: kind === 'sidebar' ? sidebar : dock }); - }; - - const onSplitterKeyDown = (kind: SplitterKind, event: KeyboardEvent) => { - const step = kind === 'sidebar' - ? (event.key === 'ArrowRight' ? KEY_STEP : event.key === 'ArrowLeft' ? -KEY_STEP : 0) - : (event.key === 'ArrowUp' ? KEY_STEP : event.key === 'ArrowDown' ? -KEY_STEP : 0); - if (!step) return; - event.preventDefault(); - resize(kind, (kind === 'sidebar' ? sidebar : dock) + step); - }; - - return ( -
-
- {zhCN.connection} - -
-
- {props.explorer} -
{props.editor}
-
{props.bottom}
-
{props.status}
-
- ); -} - -function GitMark() { - return ; -} diff --git a/chusql-web/frontend/src/components/layout/bottom.module.css b/chusql-web/frontend/src/components/layout/bottom.module.css deleted file mode 100644 index 32d6694..0000000 --- a/chusql-web/frontend/src/components/layout/bottom.module.css +++ /dev/null @@ -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; } diff --git a/chusql-web/frontend/src/components/layout/layout.module.css b/chusql-web/frontend/src/components/layout/layout.module.css deleted file mode 100644 index 4b1ecdc..0000000 --- a/chusql-web/frontend/src/components/layout/layout.module.css +++ /dev/null @@ -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); } diff --git a/chusql-web/frontend/src/components/settings/FontChainEditor.tsx b/chusql-web/frontend/src/components/settings/FontChainEditor.tsx deleted file mode 100644 index 880242e..0000000 --- a/chusql-web/frontend/src/components/settings/FontChainEditor.tsx +++ /dev/null @@ -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(); - const move = (event: DragEvent, target: number) => { - event.preventDefault(); - if (dragged === undefined || dragged === target) return; - const next = [...value]; const [font] = next.splice(dragged, 1); next.splice(target, 0, font); onChange(next); setDragged(target); - }; - return
{label} -
{value.map((font, index) =>
move(event, index)}> setDragged(index)} onDragEnd={() => setDragged(undefined)} title={zhCN.moveFont}> onChange(value.map((item, itemIndex) => itemIndex === index ? event.target.value : item))} />} compact label={zhCN.removeFont(font)} disabled={value.length === 1} onClick={() => onChange(value.filter((_, itemIndex) => itemIndex !== index))} />
)}
- } label={zhCN.addFallback} onClick={() => onChange([...value, ''])} /> - {children} -
{zhCN.preview}{zhCN.previewText}
-
; -} diff --git a/chusql-web/frontend/src/components/settings/SettingsPanel.test.tsx b/chusql-web/frontend/src/components/settings/SettingsPanel.test.tsx deleted file mode 100644 index 03e3dcd..0000000 --- a/chusql-web/frontend/src/components/settings/SettingsPanel.test.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import { beforeEach, expect, it } from 'vitest'; -import { SettingsPanel } from './SettingsPanel'; -import { useSettingsStore } from '../../store/settingsStore'; - -// 覆盖设置面板的输入聚焦与数值提交。 - -beforeEach(() => useSettingsStore.getState().reset()); -it('preserves the font input and focus while typing and deleting', () => { - render(); - const input = screen.getAllByRole('textbox')[0]; - input.focus(); - fireEvent.change(input, { target: { value: 'Consola' } }); - expect(screen.getAllByRole('textbox')[0]).toBe(input); - expect(input).toHaveFocus(); - fireEvent.change(input, { target: { value: '' } }); - expect(input).toHaveValue(''); - expect(input).toHaveFocus(); -}); -it('allows an empty numeric draft and commits a bounded value on blur', () => { - render(); - const input = screen.getAllByRole('spinbutton')[0]; - fireEvent.change(input, { target: { value: '' } }); - expect(input).toHaveValue(null); - expect(useSettingsStore.getState().settings.uiFontSize).toBe(13); - fireEvent.change(input, { target: { value: '15' } }); - fireEvent.blur(input); - expect(useSettingsStore.getState().settings.uiFontSize).toBe(15); - fireEvent.change(input, { target: { value: '99' } }); - fireEvent.keyDown(input, { key: 'Enter' }); - expect(input).toHaveValue(16); - fireEvent.change(input, { target: { value: '' } }); - fireEvent.blur(input); - expect(input).toHaveValue(16); -}); diff --git a/chusql-web/frontend/src/components/settings/SettingsPanel.tsx b/chusql-web/frontend/src/components/settings/SettingsPanel.tsx deleted file mode 100644 index a88a2b4..0000000 --- a/chusql-web/frontend/src/components/settings/SettingsPanel.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { RotateCcw } from 'lucide-react'; -import { useEffect, useState } from 'react'; -import { zhCN } from '../../i18n/zh-CN'; -import { fontFamilyValue, useSettingsStore } from '../../store/settingsStore'; -import { IconButton } from '../common/IconButton'; -import { FontChainEditor } from './FontChainEditor'; -import styles from './settings.module.css'; - -// 设置面板:外观与行为两组选项及字体预览。 - -function NumberField({ label, value, min, max, onChange }: { label: string; value: number; min: number; max: number; onChange: (value: number) => void }) { - const [draft, setDraft] = useState(String(value)); - useEffect(() => setDraft(String(value)), [value]); - const commit = () => { - if (!draft.trim() || !Number.isFinite(Number(draft))) { setDraft(String(value)); return; } - const next = Math.min(max, Math.max(min, Math.round(Number(draft)))); - setDraft(String(next)); onChange(next); - }; - return ; -} -function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (value: boolean) => void }) { - return ; -} - -export function SettingsPanel() { - const [section, setSection] = useState<'appearance' | 'behavior'>('appearance'); - const { settings, setNumber, setBoolean, setText, setFonts, reset } = useSettingsStore(); - return
- -

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

- {section === 'appearance' ? <> - setFonts('uiFonts', value)} previewStyle={{ fontFamily: fontFamilyValue(settings.uiFonts), fontSize: settings.uiFontSize }}> setNumber('uiFontSize', value)} /> - setFonts('gridFonts', value)} previewStyle={{ fontFamily: fontFamilyValue(settings.gridFonts), fontSize: settings.gridFontSize, lineHeight: `${settings.gridRowHeight}px` }}> setNumber('gridFontSize', value)} /> setNumber('gridRowHeight', value)} /> - setFonts('sqlFonts', value)} previewStyle={{ fontFamily: fontFamilyValue(settings.sqlFonts), fontSize: settings.sqlFontSize, lineHeight: `${settings.sqlLineHeight}px` }}> setNumber('sqlFontSize', value)} /> setNumber('sqlLineHeight', value)} /> - :
- setNumber('pageSize', value)} /> - - setNumber('sqlTabSize', value)} /> - setBoolean('sqlLineNumbers', value)} /> - setBoolean('autocomplete', value)} /> - setBoolean('minimap', value)} /> -
} -
} label={zhCN.restoreDefaults} onClick={reset} />
-
-
; -} diff --git a/chusql-web/frontend/src/components/settings/settings.module.css b/chusql-web/frontend/src/components/settings/settings.module.css deleted file mode 100644 index 1eb0fa3..0000000 --- a/chusql-web/frontend/src/components/settings/settings.module.css +++ /dev/null @@ -1,29 +0,0 @@ -/* 设置面板样式:侧边分组、字段与字体预览。 */ -.settings { display: grid; grid-template-columns: 210px minmax(0, 1fr); height: 100%; overflow: hidden; } -.settings aside { background: var(--bg-elevated); border-right: 1px solid var(--border); padding-top: 8px; } -.settings aside h2 { font-size: 12px; letter-spacing: .12em; margin: 8px 12px 12px; text-transform: uppercase; } -.settings aside button { background: transparent; border: 0; border-left: 2px solid transparent; color: var(--fg-primary); display: block; font: inherit; height: 28px; padding: 0 12px; text-align: left; width: 100%; } -.settings aside button:hover, .settings aside .active { background: var(--bg-active); } -.settings aside .active { border-left-color: var(--accent); } -.settings > section { overflow: auto; padding: 0 18px 20px; } -.settings header { background: var(--bg-base); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 2; } -.settings h1 { font-size: 18px; font-weight: 500; margin: 0; padding: 16px 0 10px; } -.fontGroup { border: 1px solid var(--border); margin: 14px 0; padding: 9px; } -.fontGroup legend { color: var(--info); font-weight: 700; padding: 0 5px; } -.fontList { display: grid; gap: 3px; margin-bottom: 6px; } -.fontRow { align-items: center; display: grid; gap: 5px; grid-template-columns: 16px minmax(160px, 360px) 24px; } -.fontRow input, .field input { background: var(--bg-base); border: 1px solid var(--border); color: var(--fg-primary); height: 24px; padding: 0 6px; } -.field { align-items: center; display: grid; gap: 10px; grid-template-columns: 150px 120px; margin: 7px 0; } -.toggle { align-items: center; display: flex; gap: 8px; margin: 10px 0; } -.preview { background: var(--bg-base); border: 1px solid var(--border-subtle); display: grid; gap: 4px; margin-top: 9px; min-height: 52px; padding: 7px; } -.preview strong { color: var(--fg-muted); font-family: var(--ui-font); font-size: 11px; } -.behavior { padding: 12px 0; } -.settings footer { border-top: 1px solid var(--border); margin-top: 16px; padding-top: 8px; } -@media (max-width: 720px) { .settings { grid-template-columns: 140px minmax(0, 1fr); } .settings > section { padding-inline: 10px; } } -@media (max-width: 720px) { - .settings { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); } - .settings aside { display: flex; border-right: 0; border-bottom: 1px solid var(--border); padding: 0; } - .settings aside h2 { display: none; } - .fontRow { grid-template-columns: 16px minmax(0, 1fr) 24px; } - .field { grid-template-columns: minmax(0, 1fr) 90px; } -} diff --git a/chusql-web/frontend/src/components/sql/SqlConsole.tsx b/chusql-web/frontend/src/components/sql/SqlConsole.tsx deleted file mode 100644 index 56c9426..0000000 --- a/chusql-web/frontend/src/components/sql/SqlConsole.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { Play, PlayCircle, Square, TextSelect } from 'lucide-react'; -import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; -import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; -import 'monaco-editor/min/vs/editor/editor.main.css'; -import 'monaco-editor/esm/vs/basic-languages/sql/sql.contribution'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import { splitSqlStatements, type SqlStatement } from '../../core/sql'; -import { SQL_TOKEN_COLORS } from '../../core/sqlHighlight'; -import { zhCN } from '../../i18n/zh-CN'; -import type { DataSourceAdapter, StatementResult } from '../../types'; -import type { IdeSettings } from '../../store/settingsStore'; -import { IconButton } from '../common/IconButton'; -import styles from './sql.module.css'; -import { restoreEditorLayout } from './editorLayout'; -import { fontFamilyValue } from '../../store/settingsStore'; - -// SQL 控制台:Monaco 编辑器、语句切分与逐条执行。 - -(self as unknown as { MonacoEnvironment: { getWorker: () => Worker } }).MonacoEnvironment = { getWorker: () => new EditorWorker() }; - -export interface QueryHistoryEntry { sql: string; at: string; ok: boolean; durationMs: number } - -interface SqlConsoleProps { - adapter: DataSourceAdapter; - settings: IdeSettings; - initialSql?: string; - onResult: (result: StatementResult) => void; - onMessage: (message: string) => void; - onHistory: (history: QueryHistoryEntry[]) => void; -} - -type RunStatus = { state: 'pending' | 'running' | 'success' | 'error'; durationMs?: number }; - -function readHistory(): QueryHistoryEntry[] { - try { - const parsed = JSON.parse(localStorage.getItem('chusql.sql.history.v2') ?? '[]'); - return Array.isArray(parsed) ? parsed.slice(0, 50) : []; - } catch { return []; } -} - -export function SqlConsole({ adapter, settings, initialSql = 'SELECT * FROM users;', onResult, onMessage, onHistory }: SqlConsoleProps) { - const host = useRef(null); - const editor = useRef(); - const stopRequested = useRef(false); - const [sql, setSql] = useState(initialSql); - const [running, setRunning] = useState(false); - const [statuses, setStatuses] = useState>({}); - const statements = useMemo(() => splitSqlStatements(sql), [sql]); - - useEffect(() => { - if (!host.current) return undefined; - const stopLayoutRestore = restoreEditorLayout(host.current); - monaco.editor.defineTheme('chusql-dark', { - base: 'vs-dark', inherit: true, - rules: [ - { token: 'keyword', foreground: SQL_TOKEN_COLORS.keyword.slice(1) }, - { token: 'string', foreground: SQL_TOKEN_COLORS.string.slice(1) }, - { token: 'number', foreground: SQL_TOKEN_COLORS.number.slice(1) }, - { token: 'comment', foreground: SQL_TOKEN_COLORS.comment.slice(1) }, - ], - colors: { 'editor.background': '#1e1e1e', 'editorLineNumber.foreground': '#858585', 'editor.selectionBackground': '#264f78' }, - }); - const instance = monaco.editor.create(host.current, { - value: sql, language: 'sql', theme: 'chusql-dark', glyphMargin: true, - automaticLayout: true, minimap: { enabled: settings.minimap }, fontFamily: fontFamilyValue(settings.sqlFonts), - fontSize: settings.sqlFontSize, lineHeight: settings.sqlLineHeight, tabSize: settings.sqlTabSize, - lineNumbers: settings.sqlLineNumbers ? 'on' : 'off', quickSuggestions: settings.autocomplete, - }); - editor.current = instance; - const subscription = instance.onDidChangeModelContent(() => setSql(instance.getValue())); - return () => { stopLayoutRestore(); subscription.dispose(); instance.dispose(); editor.current = undefined; }; - }, []); - - useEffect(() => { editor.current?.updateOptions({ minimap: { enabled: settings.minimap }, fontFamily: fontFamilyValue(settings.sqlFonts), fontSize: settings.sqlFontSize, lineHeight: settings.sqlLineHeight, tabSize: settings.sqlTabSize, lineNumbers: settings.sqlLineNumbers ? 'on' : 'off', quickSuggestions: settings.autocomplete }); }, [settings]); - useEffect(() => { setStatuses(Object.fromEntries(statements.map((statement) => [statement.id, { state: 'pending' }]))); }, [sql]); - useEffect(() => { - const instance = editor.current; - if (!instance) return; - const decorations = statements.map((statement) => { - const status = statuses[statement.id]?.state ?? 'pending'; - return { range: new monaco.Range(statement.startLine, 1, statement.endLine, 1), options: { isWholeLine: status === 'running', className: status === 'running' ? styles.runningLine : undefined, linesDecorationsClassName: status === 'running' ? styles.runningEdge : undefined, glyphMarginClassName: styles[`glyph${status[0].toUpperCase()}${status.slice(1)}`] } }; - }); - const ids = instance.deltaDecorations([], decorations); - return () => { instance.deltaDecorations(ids, []); }; - }, [statements, statuses]); - - const recordHistory = (statement: SqlStatement, result: StatementResult) => { - const next = [{ sql: statement.sql, at: new Date().toISOString(), ok: result.type !== 'error', durationMs: result.durationMs }, ...readHistory()].slice(0, 50); - localStorage.setItem('chusql.sql.history.v2', JSON.stringify(next)); onHistory(next); - }; - const run = async (items: SqlStatement[]) => { - if (!items.length) return onMessage(zhCN.noStatement); - stopRequested.current = false; setRunning(true); - for (const statement of items) { - if (stopRequested.current) break; - setStatuses((current) => ({ ...current, [statement.id]: { state: 'running' } })); - const result = await adapter.execStatement({ db: zhCN.database, sql: statement.sql }); - setStatuses((current) => ({ ...current, [statement.id]: { state: result.type === 'error' ? 'error' : 'success', durationMs: result.durationMs } })); - recordHistory(statement, result); onResult(result); - if (result.type === 'error') onMessage(result.message ?? zhCN.statementFailed); - else if (result.type === 'rows') onMessage(zhCN.executionSuccess(result.rows?.length ?? 0, result.durationMs)); - else onMessage(zhCN.executionAffected(result.affected ?? 0, result.durationMs)); - } - if (stopRequested.current) onMessage(zhCN.executionStopped); - setRunning(false); - }; - const pending = () => statements.find((statement) => statuses[statement.id]?.state !== 'success'); - const selection = () => { - const instance = editor.current; const range = instance?.getSelection(); - if (!instance || !range || range.isEmpty()) return []; - return splitSqlStatements(instance.getModel()?.getValueInRange(range) ?? ''); - }; - - return
-
} label={zhCN.runNext} disabled={running} onClick={() => { const next = pending(); void run(next ? [next] : []); }} />} label={zhCN.runAll} disabled={running} onClick={() => void run(statements)} />} label={zhCN.runSelection} disabled={running} onClick={() => void run(selection())} />} label={zhCN.stop} disabled={!running} onClick={() => { stopRequested.current = true; }} />
-
-
    {statements.map((statement) => { const status = statuses[statement.id] ?? { state: 'pending' }; return
  1. {status.state === 'pending' ? '○' : status.state === 'running' ? '▶' : status.state === 'success' ? '✓' : '✗'}{status.state === 'pending' ? zhCN.statementPending : status.state === 'running' ? zhCN.statementRunning : status.state === 'success' ? zhCN.statementSuccess : zhCN.statementFailed}{status.durationMs !== undefined && {status.durationMs.toFixed(1)} ms}
  2. ; })}
-
; -} diff --git a/chusql-web/frontend/src/components/sql/editorLayout.test.ts b/chusql-web/frontend/src/components/sql/editorLayout.test.ts deleted file mode 100644 index 23917f5..0000000 --- a/chusql-web/frontend/src/components/sql/editorLayout.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { expect, it, vi } from 'vitest'; -import { restoreEditorLayout } from './editorLayout'; - -// editorLayout 测试:只恢复布局声明,不碰已生效样式。 - -it('restores only layout declarations from CSP-blocked editor markup', () => { - const host = document.createElement('div'); - const line = document.createElement('div'); - line.setAttribute('style', 'left:20px;width:36px;background-image:url(https://example.invalid/x);'); - const setProperty = vi.fn(); - Object.defineProperty(line, 'style', { value: { length: 0, setProperty } }); - host.append(line); - const stop = restoreEditorLayout(host); - expect(setProperty.mock.calls).toEqual([['left', '20px'], ['width', '36px']]); - stop(); -}); - -it('leaves already active CSSOM styles untouched', () => { - const host = document.createElement('div'); - host.style.width = '200px'; - const setProperty = vi.spyOn(host.style, 'setProperty'); - const stop = restoreEditorLayout(host); - expect(setProperty).not.toHaveBeenCalled(); - stop(); -}); diff --git a/chusql-web/frontend/src/components/sql/editorLayout.ts b/chusql-web/frontend/src/components/sql/editorLayout.ts deleted file mode 100644 index 2feaa44..0000000 --- a/chusql-web/frontend/src/components/sql/editorLayout.ts +++ /dev/null @@ -1,36 +0,0 @@ -// 修复被 CSP 拦截的 Monaco 内联样式:用 CSSOM 重放布局属性。 - -const layoutProperties = new Set([ - 'left', 'right', 'top', 'bottom', 'width', 'height', 'min-width', 'max-width', - 'position', 'display', 'line-height', 'font-size', 'font-family', 'font-weight', - 'font-style', 'letter-spacing', 'vertical-align', 'white-space', 'transform', - 'color', 'background-color', 'border-color', 'border-width', 'border-style', - 'padding-left', 'padding-right', 'margin-left', 'margin-right', 'opacity', -]); - -export function restoreEditorLayout(host: HTMLElement): () => void { - const parsed = document.createElement('span').style; - const restore = (element: Element) => { - if (!(element instanceof HTMLElement) || element.style.length) return; - const raw = element.getAttribute('style'); - if (!raw) return; - parsed.cssText = raw; - for (const property of Array.from(parsed)) { - const value = parsed.getPropertyValue(property); - if (layoutProperties.has(property) && !/url\s*\(|expression\s*\(/i.test(value)) element.style.setProperty(property, value); - } - }; - const restoreTree = (element: Element) => { - restore(element); - element.querySelectorAll('[style]').forEach(restore); - }; - const observer = new MutationObserver((records) => { - for (const record of records) { - if (record.type === 'attributes' && record.target instanceof Element) restore(record.target); - else record.addedNodes.forEach((node) => { if (node instanceof Element) restoreTree(node); }); - } - }); - observer.observe(host, { subtree: true, childList: true, attributes: true, attributeFilter: ['style'] }); - restoreTree(host); - return () => observer.disconnect(); -} diff --git a/chusql-web/frontend/src/components/sql/sql.module.css b/chusql-web/frontend/src/components/sql/sql.module.css deleted file mode 100644 index 6a654b5..0000000 --- a/chusql-web/frontend/src/components/sql/sql.module.css +++ /dev/null @@ -1,15 +0,0 @@ -/* SQL 控制台样式:工具栏、编辑器与语句状态条。 */ -.console { display: grid; grid-template: 30px minmax(0, 1fr) auto / minmax(0, 1fr); height: 100%; min-height: 0; min-width: 0; overflow: hidden; } -.toolbar { align-items: center; background: var(--bg-elevated); border-bottom: 1px solid var(--border); display: flex; gap: 2px; padding: 2px 5px; } -.editor { min-height: 0; min-width: 0; overflow: hidden; position: relative; } -.statementList { background: var(--bg-elevated); border-top: 1px solid var(--border); display: flex; gap: 12px; list-style: none; margin: 0; min-height: 28px; overflow-x: auto; padding: 4px 8px; } -.statementList li { align-items: center; color: var(--fg-muted); display: flex; gap: 5px; white-space: nowrap; } -.statementList small { color: var(--success); } -.runningLine { background: rgb(0 122 204 / 14%); } -.runningEdge { border-left: 2px solid var(--accent); margin-left: 3px; } -.glyphPending::before { color: var(--fg-muted); content: '○'; } -.glyphRunning::before { color: var(--info); content: '▶'; } -.glyphSuccess::before { color: var(--success); content: '✓'; } -.glyphError::before { color: var(--danger); content: '✗'; } -.editor > :global(.monaco-editor) { position: absolute; inset: 0; } -.statementList { max-height: 64px; } diff --git a/chusql-web/frontend/src/core/changeSql.test.ts b/chusql-web/frontend/src/core/changeSql.test.ts deleted file mode 100644 index 8879a3a..0000000 --- a/chusql-web/frontend/src/core/changeSql.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { createEmptyChangeSet } from './changes'; -import { changeSql, changesSql, deleteSql, insertSql, schemaSql, sqlIdentifier, sqlLiteral, updateSql } from './changeSql'; - -// 覆盖展示用 SQL 的字面量、标识符与结构语句。 - -describe('sql literals and identifiers', () => { - it('doubles single quotes and keeps null, numbers and booleans bare', () => { - expect(sqlLiteral("O'Brien")).toBe("'O''Brien'"); - expect(sqlLiteral(7)).toBe('7'); - expect(sqlLiteral(false)).toBe('false'); - expect(sqlLiteral(null)).toBe('NULL'); - expect(sqlLiteral(undefined)).toBe('NULL'); - }); - - it('quotes identifiers only when they leave the whitelist', () => { - expect(sqlIdentifier('users')).toBe('users'); - expect(sqlIdentifier('we"ird')).toBe('"we""ird"'); - }); -}); - -describe('row statements', () => { - it('renders update, insert and delete', () => { - expect(updateSql('users', 1, 'name', "A'B")).toBe("UPDATE users SET name = 'A''B' WHERE id = 1;"); - expect(insertSql('users', { id: 9, name: 'Ada' })).toBe("INSERT INTO users (id, name) VALUES (9, 'Ada');"); - expect(deleteSql('users', 2)).toBe('DELETE FROM users WHERE id = 2;'); - }); -}); - -describe('schema statements', () => { - it('matches the statements the server Actions build', () => { - expect(schemaSql({ op: 'createTable', table: 'items', columns: [{ name: 'id', type: 'int' }, { name: 'title', type: 'str' }] })).toBe('CREATE TABLE items (id int, title str);'); - expect(schemaSql({ op: 'dropTable', table: 'items' })).toBe('DROP TABLE items;'); - expect(schemaSql({ op: 'createIndex', table: 'items', column: 'sku' })).toBe('CREATE INDEX ON items (sku);'); - expect(schemaSql({ op: 'dropIndex', table: 'items', column: 'sku' })).toBe('DROP INDEX ON items (sku);'); - }); -}); - -describe('changesSql', () => { - it('lists creates first, then row changes, then drops', () => { - const changes = createEmptyChangeSet(); - changes.schemas.set('dropTable:items', { op: 'dropTable', table: 'items' }); - changes.schemas.set('createTable:items', { op: 'createTable', table: 'items', columns: [{ name: 'id', type: 'int' }] }); - changes.updates.set('items:1:name', { table: 'items', pk: 1, column: 'name', oldValue: 'A', newValue: 'B' }); - expect(changesSql(changes)).toEqual([ - 'CREATE TABLE items (id int);', - "UPDATE items SET name = 'B' WHERE id = 1;", - 'DROP TABLE items;', - ]); - }); - - it('renders a single pending change through changeSql', () => { - expect(changeSql({ op: 'insert', table: 'users', values: { id: 3 } })).toBe('INSERT INTO users (id) VALUES (3);'); - }); -}); diff --git a/chusql-web/frontend/src/core/changeSql.ts b/chusql-web/frontend/src/core/changeSql.ts deleted file mode 100644 index 7acae35..0000000 --- a/chusql-web/frontend/src/core/changeSql.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { ChangeSet, PendingChange, SchemaChange } from '../types'; -import { pendingChanges } from './changes'; - -// 展示用 SQL 生成:标识符走白名单,字符串单引号翻倍。 - -const identifier = /^[A-Za-z_][A-Za-z0-9_]*$/; - -// 字面量:字符串加引号并翻倍单引号。 -export function sqlLiteral(value: unknown): string { - if (value === null || value === undefined) return 'NULL'; - if (typeof value === 'number' || typeof value === 'boolean') return String(value); - return `'${String(value).replaceAll("'", "''")}'`; -} - -// 标识符:白名单外才加双引号。 -export function sqlIdentifier(name: string): string { - return identifier.test(name) ? name : `"${name.replaceAll('"', '""')}"`; -} - -// 单元格修改语句。 -export function updateSql(table: string, pk: unknown, column: string, value: unknown): string { - return `UPDATE ${sqlIdentifier(table)} SET ${sqlIdentifier(column)} = ${sqlLiteral(value)} WHERE id = ${sqlLiteral(pk)};`; -} - -// 插入语句。 -export function insertSql(table: string, values: Record): string { - const columns = Object.keys(values); - return `INSERT INTO ${sqlIdentifier(table)} (${columns.map(sqlIdentifier).join(', ')}) VALUES (${columns.map((column) => sqlLiteral(values[column])).join(', ')});`; -} - -// 删除语句。 -export function deleteSql(table: string, pk: unknown): string { - return `DELETE FROM ${sqlIdentifier(table)} WHERE id = ${sqlLiteral(pk)};`; -} - -// 结构变更语句,与 Actions 拼法一致。 -export function schemaSql(change: SchemaChange): string { - if (change.op === 'createTable') { - const columns = change.columns.map((column) => `${sqlIdentifier(column.name)} ${column.type}`).join(', '); - return `CREATE TABLE ${sqlIdentifier(change.table)} (${columns});`; - } - if (change.op === 'dropTable') return `DROP TABLE ${sqlIdentifier(change.table)};`; - if (change.op === 'createIndex') return `CREATE INDEX ON ${sqlIdentifier(change.table)} (${sqlIdentifier(change.column)});`; - return `DROP INDEX ON ${sqlIdentifier(change.table)} (${sqlIdentifier(change.column)});`; -} - -// 渲染一条提交项为展示用 SQL。 -export function changeSql(change: PendingChange): string { - if (change.op === 'insert') return insertSql(change.table, change.values); - if (change.op === 'delete') return deleteSql(change.table, change.pk); - if (change.op === 'update') { - const assignments = Object.entries(change.set).map(([column, value]) => `${sqlIdentifier(column)} = ${sqlLiteral(value)}`).join(', '); - return `UPDATE ${sqlIdentifier(change.table)} SET ${assignments} WHERE id = ${sqlLiteral(change.pk)};`; - } - return schemaSql(change); -} - -// 按提交顺序渲染整套变更。 -export function changesSql(changes: ChangeSet): string[] { - return pendingChanges(changes).map(changeSql); -} diff --git a/chusql-web/frontend/src/core/changes.test.ts b/chusql-web/frontend/src/core/changes.test.ts deleted file mode 100644 index 7c15f6a..0000000 --- a/chusql-web/frontend/src/core/changes.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { changeCount, createEmptyChangeSet, pendingChanges, toCommitPayload } from './changes'; - -// 覆盖提交载荷的合并、顺序与结构变更。 - -describe('toCommitPayload', () => { - it('coalesces cell updates into one row update', () => { - const changes = createEmptyChangeSet(); - changes.updates.set('users:1:name', { table: 'users', pk: 1, column: 'name', oldValue: 'A', newValue: 'B' }); - changes.updates.set('users:1:age', { table: 'users', pk: 1, column: 'age', oldValue: 20, newValue: 21 }); - expect(toCommitPayload(changes)).toEqual([ - { op: 'update', table: 'users', pk: 1, set: { name: 'B', age: 21 } }, - ]); - }); - - it('keeps inserts and deletes in a stable order', () => { - const changes = createEmptyChangeSet(); - changes.inserts.set('tmp-1', { table: 'users', tempId: 'tmp-1', values: { id: 9, name: 'N' } }); - changes.deletes.set('users:2', { table: 'users', pk: 2, row: { id: 2, name: 'D' } }); - expect(toCommitPayload(changes)).toEqual([ - { op: 'insert', table: 'users', values: { id: 9, name: 'N' } }, - { op: 'delete', table: 'users', pk: 2 }, - ]); - }); - - it('leaves schema changes out of the row payload', () => { - const changes = createEmptyChangeSet(); - changes.schemas.set('createIndex:users:age', { op: 'createIndex', table: 'users', column: 'age' }); - expect(toCommitPayload(changes)).toEqual([]); - }); -}); - -describe('pendingChanges', () => { - it('puts creates first, then row changes, then drops', () => { - const changes = createEmptyChangeSet(); - changes.schemas.set('dropTable:items', { op: 'dropTable', table: 'items' }); - changes.schemas.set('createTable:items', { op: 'createTable', table: 'items', columns: [{ name: 'id', type: 'int' }] }); - changes.updates.set('items:1:name', { table: 'items', pk: 1, column: 'name', oldValue: 'A', newValue: 'B' }); - expect(pendingChanges(changes)).toEqual([ - { op: 'createTable', table: 'items', columns: [{ name: 'id', type: 'int' }] }, - { op: 'update', table: 'items', pk: 1, set: { name: 'B' } }, - { op: 'dropTable', table: 'items' }, - ]); - }); - - it('counts structural changes together with row changes', () => { - const changes = createEmptyChangeSet(); - changes.schemas.set('dropTable:items', { op: 'dropTable', table: 'items' }); - changes.schemas.set('createIndex:users:age', { op: 'createIndex', table: 'users', column: 'age' }); - changes.updates.set('users:1:name', { table: 'users', pk: 1, column: 'name', oldValue: 'A', newValue: 'B' }); - expect(changeCount(changes)).toBe(3); - }); -}); diff --git a/chusql-web/frontend/src/core/changes.ts b/chusql-web/frontend/src/core/changes.ts deleted file mode 100644 index 36395a7..0000000 --- a/chusql-web/frontend/src/core/changes.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { ChangeSet, CommitChange, PendingChange, SchemaChange } from '../types'; - -// 变更集工具:创建、克隆、计数与提交载荷组装。 - -// 建一个空的变更集。 -export function createEmptyChangeSet(): ChangeSet { - return { updates: new Map(), inserts: new Map(), deletes: new Map(), schemas: new Map() }; -} - -// 深拷贝变更集,供撤销栈留底。 -export function cloneChangeSet(changes: ChangeSet): ChangeSet { - return { - updates: new Map(changes.updates), - inserts: new Map(changes.inserts), - deletes: new Map(changes.deletes), - schemas: new Map(changes.schemas), - }; -} - -// 统计待提交项,含结构变更。 -export function changeCount(changes: ChangeSet): number { - return changes.updates.size + changes.inserts.size + changes.deletes.size + changes.schemas.size; -} - -// 结构变更的稳定标识,撤销与确认共用。 -export function schemaKey(change: SchemaChange): string { - return change.op === 'createIndex' || change.op === 'dropIndex' - ? `${change.op}:${change.table}:${change.column}` - : `${change.op}:${change.table}`; -} - -// 组装 DML 提交载荷:同行的多列修改并成一条。 -export function toCommitPayload(changes: ChangeSet): CommitChange[] { - const updates = new Map>(); - for (const update of changes.updates.values()) { - const key = `${update.table}:${JSON.stringify(update.pk)}`; - const existing = updates.get(key); - if (existing) { - existing.set[update.column] = update.newValue; - } else { - updates.set(key, { - op: 'update', - table: update.table, - pk: update.pk, - set: { [update.column]: update.newValue }, - }); - } - } - - return [ - ...updates.values(), - ...Array.from(changes.inserts.values(), (insert): CommitChange => ({ - op: 'insert', table: insert.table, values: { ...insert.values }, - })), - ...Array.from(changes.deletes.values(), (deleted): CommitChange => ({ - op: 'delete', table: deleted.table, pk: deleted.pk, - })), - ]; -} - -// 按执行顺序合并结构变更与 DML 变更。 -export function pendingChanges(changes: ChangeSet): PendingChange[] { - const schemas = Array.from(changes.schemas.values()); - const creates = schemas.filter((change) => change.op === 'createTable' || change.op === 'createIndex'); - const drops = schemas.filter((change) => change.op === 'dropIndex' || change.op === 'dropTable'); - return [...creates, ...toCommitPayload(changes), ...drops]; -} - -// 单元格变更的键,表 + 主键 + 列。 -export function changeKey(table: string, pk: unknown, column: string): string { - return `${table}:${JSON.stringify(pk)}:${column}`; -} diff --git a/chusql-web/frontend/src/core/filters.test.ts b/chusql-web/frontend/src/core/filters.test.ts deleted file mode 100644 index 70a8fae..0000000 --- a/chusql-web/frontend/src/core/filters.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { matchesFilter } from './filters'; - -// 覆盖筛选表达式的数值、通配与布尔匹配。 - -describe('matchesFilter', () => { - it('supports numeric comparisons', () => { - expect(matchesFilter(101, '>100')).toBe(true); - expect(matchesFilter(100, '>100')).toBe(false); - }); - - it('supports SQL-like wildcard matching without regular-expression injection', () => { - expect(matchesFilter('Alphabet', 'like %pha%')).toBe(true); - expect(matchesFilter('[abc]', 'like %[abc]%')).toBe(true); - expect(matchesFilter('aXXb', 'like a.b')).toBe(false); - }); - - it('matches booleans, null and plain text', () => { - expect(matchesFilter(true, 'true')).toBe(true); - expect(matchesFilter(null, 'null')).toBe(true); - expect(matchesFilter('Alice', 'alice')).toBe(true); - }); -}); diff --git a/chusql-web/frontend/src/core/filters.ts b/chusql-web/frontend/src/core/filters.ts deleted file mode 100644 index f2749a4..0000000 --- a/chusql-web/frontend/src/core/filters.ts +++ /dev/null @@ -1,39 +0,0 @@ -// 网格筛选表达式解析:比较、like 通配与纯文本匹配。 - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function likePattern(raw: string): RegExp { - const escaped = escapeRegExp(raw).replaceAll('%', '.*').replaceAll('_', '.'); - return new RegExp(`^${escaped}$`, 'i'); -} - -export function matchesFilter(value: unknown, rawExpression: string): boolean { - const expression = rawExpression.trim(); - if (!expression) return true; - const normalized = expression.toLowerCase(); - - if (normalized === 'null') return value === null || value === undefined; - if (normalized === 'true' || normalized === 'false') return value === (normalized === 'true'); - - const like = expression.match(/^like\s+(.+)$/i); - if (like) return likePattern(like[1]).test(String(value ?? '')); - - const comparison = expression.match(/^(>=|<=|>|<|=|!=)\s*(-?\d+(?:\.\d+)?)$/); - if (comparison) { - const left = Number(value); - const right = Number(comparison[2]); - if (!Number.isFinite(left)) return false; - switch (comparison[1]) { - case '>': return left > right; - case '<': return left < right; - case '>=': return left >= right; - case '<=': return left <= right; - case '!=': return left !== right; - default: return left === right; - } - } - - return String(value ?? '').toLocaleLowerCase().includes(normalized); -} diff --git a/chusql-web/frontend/src/core/sql.test.ts b/chusql-web/frontend/src/core/sql.test.ts deleted file mode 100644 index 340f10c..0000000 --- a/chusql-web/frontend/src/core/sql.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { splitSqlStatements } from './sql'; - -// 覆盖语句切分的分号、注释与行号计算。 - -describe('splitSqlStatements', () => { - it('splits a script into executable statements', () => { - expect(splitSqlStatements('SELECT 1; SELECT 2;').map((item) => item.sql)).toEqual([ - 'SELECT 1', - 'SELECT 2', - ]); - }); - - it('keeps semicolons inside strings and comments', () => { - const sql = "SELECT 'a;b'; -- keep ; here\nSELECT 2 /* and ; here */;"; - expect(splitSqlStatements(sql).map((item) => item.sql)).toEqual([ - "SELECT 'a;b'", - '-- keep ; here\nSELECT 2 /* and ; here */', - ]); - }); - - it('reports one-based source lines for gutter decorations', () => { - const statements = splitSqlStatements('\nSELECT 1;\n\nSELECT 2'); - expect(statements.map(({ startLine, endLine }) => [startLine, endLine])).toEqual([ - [2, 2], - [4, 4], - ]); - }); -}); diff --git a/chusql-web/frontend/src/core/sql.ts b/chusql-web/frontend/src/core/sql.ts deleted file mode 100644 index 58035c4..0000000 --- a/chusql-web/frontend/src/core/sql.ts +++ /dev/null @@ -1,76 +0,0 @@ -// SQL 脚本切分:识别字符串、注释并按分号拆分语句。 - -export interface SqlStatement { - id: string; - sql: string; - startLine: number; - endLine: number; -} - -function lineAt(source: string, index: number): number { - let line = 1; - for (let cursor = 0; cursor < index; cursor += 1) { - if (source[cursor] === '\n') line += 1; - } - return line; -} - -export function splitSqlStatements(source: string): SqlStatement[] { - const spans: Array<[number, number]> = []; - let start = 0; - let mode: 'normal' | 'single' | 'double' | 'line-comment' | 'block-comment' = 'normal'; - - for (let index = 0; index < source.length; index += 1) { - const char = source[index]; - const next = source[index + 1]; - if (mode === 'single') { - if (char === "'" && next === "'") index += 1; - else if (char === "'") mode = 'normal'; - continue; - } - if (mode === 'double') { - if (char === '"' && next === '"') index += 1; - else if (char === '"') mode = 'normal'; - continue; - } - if (mode === 'line-comment') { - if (char === '\n') mode = 'normal'; - continue; - } - if (mode === 'block-comment') { - if (char === '*' && next === '/') { - mode = 'normal'; - index += 1; - } - continue; - } - if (char === "'") mode = 'single'; - else if (char === '"') mode = 'double'; - else if (char === '-' && next === '-') { - mode = 'line-comment'; - index += 1; - } else if (char === '/' && next === '*') { - mode = 'block-comment'; - index += 1; - } else if (char === ';') { - spans.push([start, index]); - start = index + 1; - } - } - spans.push([start, source.length]); - - return spans.flatMap(([from, to], ordinal) => { - const raw = source.slice(from, to); - const leading = raw.search(/\S/); - if (leading < 0) return []; - const sql = raw.trim(); - const absoluteStart = from + leading; - const startLine = lineAt(source, absoluteStart); - return [{ - id: `statement-${ordinal + 1}`, - sql, - startLine, - endLine: startLine + (sql.match(/\n/g)?.length ?? 0), - }]; - }); -} diff --git a/chusql-web/frontend/src/core/sqlHighlight.test.ts b/chusql-web/frontend/src/core/sqlHighlight.test.ts deleted file mode 100644 index 99f7548..0000000 --- a/chusql-web/frontend/src/core/sqlHighlight.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { SQL_PLAIN_COLOR, SQL_TOKEN_COLORS, tokenizeSql } from './sqlHighlight'; - -// 覆盖 SQL 高亮切分与执行器配色常量。 - -describe('tokenizeSql', () => { - it('marks keywords, strings and numbers while keeping the text intact', () => { - const sql = "UPDATE users SET name = 'A''B' WHERE id = 12;"; - const tokens = tokenizeSql(sql); - expect(tokens.map((token) => token.text).join('')).toBe(sql); - expect(tokens.filter((token) => token.kind === 'keyword').map((token) => token.text)).toEqual(['UPDATE', 'SET', 'WHERE']); - expect(tokens.filter((token) => token.kind === 'string').map((token) => token.text)).toEqual(["'A''B'"]); - expect(tokens.filter((token) => token.kind === 'number').map((token) => token.text)).toEqual(['12']); - }); - - it('recognises line comments and leaves unknown words plain', () => { - expect(tokenizeSql('SELECT * FROM users -- note').at(-1)).toEqual({ text: '-- note', kind: 'comment' }); - expect(tokenizeSql('select users').map((token) => token.kind)).toEqual(['keyword', 'plain']); - }); - - it('splits schema statements used by the pending list', () => { - const tokens = tokenizeSql('CREATE TABLE items (id int, title str);'); - expect(tokens.filter((token) => token.kind === 'keyword').map((token) => token.text)).toEqual(['CREATE', 'TABLE', 'int', 'str']); - expect(tokens.map((token) => token.text).join('')).toBe('CREATE TABLE items (id int, title str);'); - }); -}); - -describe('SQL_TOKEN_COLORS', () => { - it('matches the colours the SQL console theme uses', () => { - expect(SQL_TOKEN_COLORS).toEqual({ keyword: '#569CD6', string: '#CE9178', number: '#B5CEA8', comment: '#6A9955' }); - }); - - it('paints everything that is not a keyword in the plain grey', () => { - expect(SQL_PLAIN_COLOR).toBe('rgb(200, 200, 200)'); - }); -}); diff --git a/chusql-web/frontend/src/core/sqlHighlight.ts b/chusql-web/frontend/src/core/sqlHighlight.ts deleted file mode 100644 index a28e3e0..0000000 --- a/chusql-web/frontend/src/core/sqlHighlight.ts +++ /dev/null @@ -1,76 +0,0 @@ -export const SQL_TOKEN_COLORS = { - keyword: '#569CD6', - string: '#CE9178', - number: '#B5CEA8', - comment: '#6A9955', -} as const; - -export const SQL_PLAIN_COLOR = 'rgb(200, 200, 200)'; - -export type SqlTokenKind = 'plain' | keyof typeof SQL_TOKEN_COLORS; - -export interface SqlToken { text: string; kind: SqlTokenKind } - -// SQL 词法高亮:与执行器共用同一套 token 配色。 - -const keywords = new Set([ - 'select', 'from', 'where', 'insert', 'into', 'values', 'update', 'set', 'delete', - 'create', 'table', 'index', 'on', 'drop', 'alter', 'and', 'or', 'not', 'null', - 'order', 'by', 'group', 'having', 'limit', 'offset', 'join', 'left', 'right', - 'inner', 'outer', 'as', 'distinct', 'primary', 'key', 'int', 'integer', 'str', - 'text', 'bool', 'boolean', 'true', 'false', -]); - -const wordChar = /[A-Za-z0-9_]/; - -// 切分 SQL 为关键字、字符串、数字、注释与普通片段。 -export function tokenizeSql(sql: string): SqlToken[] { - const tokens: SqlToken[] = []; - let plain = ''; - let index = 0; - const flush = () => { if (plain) { tokens.push({ text: plain, kind: 'plain' }); plain = ''; } }; - while (index < sql.length) { - const char = sql[index]; - if (char === "'") { - flush(); - let end = index + 1; - while (end < sql.length) { - if (sql[end] !== "'") { end += 1; continue; } - if (sql[end + 1] === "'") { end += 2; continue; } - end += 1; - break; - } - tokens.push({ text: sql.slice(index, end), kind: 'string' }); - index = end; - continue; - } - if (char === '-' && sql[index + 1] === '-') { - flush(); - const end = sql.indexOf('\n', index); - tokens.push({ text: end < 0 ? sql.slice(index) : sql.slice(index, end), kind: 'comment' }); - index = end < 0 ? sql.length : end; - continue; - } - if (char >= '0' && char <= '9') { - flush(); - let end = index; - while (end < sql.length && (wordChar.test(sql[end]) || sql[end] === '.')) end += 1; - tokens.push({ text: sql.slice(index, end), kind: 'number' }); - index = end; - continue; - } - if (wordChar.test(char)) { - let end = index; - while (end < sql.length && wordChar.test(sql[end])) end += 1; - const word = sql.slice(index, end); - if (keywords.has(word.toLowerCase())) { flush(); tokens.push({ text: word, kind: 'keyword' }); } - else plain += word; - index = end; - continue; - } - plain += char; - index += 1; - } - flush(); - return tokens; -} diff --git a/chusql-web/frontend/src/core/tabs.test.ts b/chusql-web/frontend/src/core/tabs.test.ts deleted file mode 100644 index 6c27abe..0000000 --- a/chusql-web/frontend/src/core/tabs.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { closeTab, openQueryTab, openSettingsTab, openTableTab, type Tab } from './tabs'; - -// 覆盖标签页打开与关闭的状态迁移。 - -const base: Tab[] = [ - { id: 'tab-1', kind: 'table', title: 'users', table: 'users' }, - { id: 'tab-2', kind: 'table', title: 'orders', table: 'orders' }, - { id: 'tab-3', kind: 'sql', title: '查询 1' }, -]; - -describe('openTableTab', () => { - it('activates an existing tab instead of appending a duplicate', () => { - const state = openTableTab(base, 'users', 'tab-9'); - expect(state.tabs).toHaveLength(3); - expect(state.activeId).toBe('tab-1'); - expect(state.tabs).toBe(base); - }); - - it('appends a new tab for a table that is not open yet', () => { - const state = openTableTab(base, 'products', 'tab-9'); - expect(state.tabs).toHaveLength(4); - expect(state.tabs[3]).toEqual({ id: 'tab-9', kind: 'table', title: 'products', table: 'products' }); - expect(state.activeId).toBe('tab-9'); - }); -}); - -describe('openQueryTab', () => { - it('always appends so queries can be opened multiple times', () => { - const first = openQueryTab(base, '查询 1', 'tab-9'); - const second = openQueryTab(first.tabs, '查询 2', 'tab-10', 'SELECT 1'); - expect(second.tabs).toHaveLength(5); - expect(second.activeId).toBe('tab-10'); - expect(second.tabs[4]).toEqual({ id: 'tab-10', kind: 'sql', title: '查询 2', sql: 'SELECT 1' }); - }); -}); - -describe('openSettingsTab', () => { - it('keeps a single settings tab and activates it', () => { - const first = openSettingsTab(base, '设置', 'tab-9'); - const second = openSettingsTab(first.tabs, '设置', 'tab-10'); - expect(second.tabs.filter((tab) => tab.kind === 'settings')).toHaveLength(1); - expect(second.tabs).toHaveLength(4); - expect(second.activeId).toBe('tab-9'); - }); -}); - -describe('closeTab', () => { - it('activates the tab that shifts in from the right', () => { - const state = closeTab(base, 'tab-1', 'tab-1'); - expect(state.tabs.map((tab) => tab.id)).toEqual(['tab-2', 'tab-3']); - expect(state.activeId).toBe('tab-2'); - }); - - it('falls back to the left neighbour when the last tab closes', () => { - const state = closeTab(base, 'tab-3', 'tab-3'); - expect(state.activeId).toBe('tab-2'); - }); - - it('keeps the active tab when a different one closes', () => { - const state = closeTab(base, 'tab-2', 'tab-3'); - expect(state.tabs).toHaveLength(2); - expect(state.activeId).toBe('tab-3'); - }); - - it('clears the active id when the only tab closes', () => { - const state = closeTab([base[0]], 'tab-1', 'tab-1'); - expect(state.tabs).toHaveLength(0); - expect(state.activeId).toBeUndefined(); - }); - - it('does not mutate the input array', () => { - const snapshot = JSON.stringify(base); - closeTab(base, 'tab-2', 'tab-2'); - openTableTab(base, 'products', 'tab-9'); - openQueryTab(base, '查询 2', 'tab-10'); - openSettingsTab(base, '设置', 'tab-11'); - expect(JSON.stringify(base)).toBe(snapshot); - }); -}); diff --git a/chusql-web/frontend/src/core/tabs.ts b/chusql-web/frontend/src/core/tabs.ts deleted file mode 100644 index 32f4931..0000000 --- a/chusql-web/frontend/src/core/tabs.ts +++ /dev/null @@ -1,35 +0,0 @@ -// 标签页状态迁移:打开与关闭均返回新状态,不改入参数组。 - -export type TabKind = 'table' | 'sql' | 'settings'; - -export interface Tab { id: string; kind: TabKind; title: string; table?: string; sql?: string } - -export interface TabState { tabs: Tab[]; activeId: string | undefined } - -// 打开表标签:同表已开则激活,否则追加。 -export function openTableTab(tabs: Tab[], name: string, id: string): TabState { - const existing = tabs.find((tab) => tab.kind === 'table' && tab.table === name); - if (existing) return { tabs, activeId: existing.id }; - return { tabs: [...tabs, { id, kind: 'table', title: name, table: name }], activeId: id }; -} - -// 打开查询标签:每次追加,可选初始 SQL。 -export function openQueryTab(tabs: Tab[], title: string, id: string, sql?: string): TabState { - return { tabs: [...tabs, { id, kind: 'sql', title, sql }], activeId: id }; -} - -// 打开设置标签:只保留一个,已存在则激活。 -export function openSettingsTab(tabs: Tab[], title: string, id: string): TabState { - const existing = tabs.find((tab) => tab.kind === 'settings'); - if (existing) return { tabs, activeId: existing.id }; - return { tabs: [...tabs, { id, kind: 'settings', title }], activeId: id }; -} - -// 关闭标签:关当前标签时激活右邻,无则左邻。 -export function closeTab(tabs: Tab[], id: string, activeId: string | undefined): TabState { - const index = tabs.findIndex((tab) => tab.id === id); - if (index < 0) return { tabs, activeId }; - const remaining = tabs.filter((tab) => tab.id !== id); - if (activeId !== id) return { tabs: remaining, activeId }; - return { tabs: remaining, activeId: (remaining[index] ?? remaining[index - 1])?.id }; -} diff --git a/chusql-web/frontend/src/core/uiSettings.test.ts b/chusql-web/frontend/src/core/uiSettings.test.ts deleted file mode 100644 index a21ad2e..0000000 --- a/chusql-web/frontend/src/core/uiSettings.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { DEFAULT_SETTINGS, useSettingsStore } from '../store/settingsStore'; -import { loadUiSettings, saveUiSettings, syncUiSettings } from './uiSettings'; - -// 覆盖远端设置的校验读取、写回与防抖同步。 - -describe('uiSettings', () => { - let stop: (() => void) | undefined; - - beforeEach(() => { - localStorage.clear(); - useSettingsStore.getState().reset(); - }); - - afterEach(() => { - stop?.(); - stop = undefined; - vi.unstubAllGlobals(); - localStorage.clear(); - }); - - it('keeps known keys only and clamps the remote payload', async () => { - const request = vi.fn().mockResolvedValue(new Response(JSON.stringify({ - uiFonts: ['', 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', ' '], - gridFonts: 'Consolas', - sqlFonts: ['Consolas'], - uiFontSize: 99, - gridFontSize: 9, - gridRowHeight: 17.4, - sqlFontSize: 13.5, - sqlLineHeight: 60, - sqlTabSize: 0, - pageSize: 2, - nullText: 'NULL', - sqlLineNumbers: false, - autocomplete: 'yes', - minimap: true, - unknownKey: 5, - }), { status: 200 })); - vi.stubGlobal('fetch', request); - - await expect(loadUiSettings()).resolves.toEqual({ - uiFonts: ['f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10'], - sqlFonts: ['Consolas'], - uiFontSize: 16, - gridFontSize: 10, - gridRowHeight: 18, - sqlFontSize: 14, - sqlLineHeight: 40, - sqlTabSize: 1, - pageSize: 10, - nullText: 'NULL', - sqlLineNumbers: false, - minimap: true, - }); - expect(request).toHaveBeenCalledWith('/api/ui-settings', { credentials: 'same-origin' }); - }); - - it('returns undefined instead of throwing when the read fails', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 401 }))); - await expect(loadUiSettings()).resolves.toBeUndefined(); - - vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))); - await expect(loadUiSettings()).resolves.toBeUndefined(); - - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('not json', { status: 200 }))); - await expect(loadUiSettings()).resolves.toBeUndefined(); - - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('[]', { status: 200 }))); - await expect(loadUiSettings()).resolves.toBeUndefined(); - }); - - it('puts the whole settings object back to the endpoint', async () => { - const request = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); - vi.stubGlobal('fetch', request); - - await expect(saveUiSettings(DEFAULT_SETTINGS)).resolves.toBe(true); - expect(request).toHaveBeenCalledTimes(1); - expect(request).toHaveBeenCalledWith('/api/ui-settings', { - method: 'PUT', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(DEFAULT_SETTINGS), - }); - }); - - it('reports a failed write as false', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 500 }))); - await expect(saveUiSettings(DEFAULT_SETTINGS)).resolves.toBe(false); - - vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))); - await expect(saveUiSettings(DEFAULT_SETTINGS)).resolves.toBe(false); - }); - - it('merges remote settings first and then debounces one write', async () => { - const request = vi.fn() - .mockResolvedValueOnce(new Response(JSON.stringify({ uiFonts: ['Consolas'], uiFontSize: 15, autocomplete: false }), { status: 200 })) - .mockResolvedValue(new Response(null, { status: 204 })); - vi.stubGlobal('fetch', request); - stop = syncUiSettings(); - - await vi.waitFor(() => expect(useSettingsStore.getState().settings.autocomplete).toBe(false)); - expect(useSettingsStore.getState().settings.uiFonts).toEqual(['Consolas']); - expect(useSettingsStore.getState().settings.uiFontSize).toBe(15); - expect(request).toHaveBeenCalledTimes(1); - - useSettingsStore.getState().setNumber('pageSize', 300); - useSettingsStore.getState().setNumber('pageSize', 320); - await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2), { timeout: 2000 }); - const [url, init] = request.mock.calls[1] as [string, RequestInit]; - expect(url).toBe('/api/ui-settings'); - expect(init.method).toBe('PUT'); - expect(JSON.parse(String(init.body)).pageSize).toBe(320); - }); -}); diff --git a/chusql-web/frontend/src/core/uiSettings.ts b/chusql-web/frontend/src/core/uiSettings.ts deleted file mode 100644 index f5ce604..0000000 --- a/chusql-web/frontend/src/core/uiSettings.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { useSettingsStore, type IdeSettings } from '../store/settingsStore'; - -// 远端 IDE 设置的读取、校验与防抖回写。 - -const endpoint = '/api/ui-settings'; -const debounceMs = 400; -const fontKeys = ['uiFonts', 'gridFonts', 'sqlFonts'] as const; -const booleanKeys = ['sqlLineNumbers', 'autocomplete', 'minimap'] as const; -const numberKeys = ['uiFontSize', 'gridFontSize', 'gridRowHeight', 'sqlFontSize', 'sqlLineHeight', 'sqlTabSize', 'pageSize'] as const; - -type NumberKey = (typeof numberKeys)[number]; - -const numberRanges: Record = { - uiFontSize: [11, 16], - gridFontSize: [10, 18], - gridRowHeight: [18, 40], - sqlFontSize: [10, 20], - sqlLineHeight: [16, 40], - sqlTabSize: [1, 8], - pageSize: [10, 500], -}; - -// 收敛字体链:去空串并截断到 10 项。 -function readFonts(value: unknown): string[] | undefined { - if (!Array.isArray(value)) return undefined; - return value.filter((font): font is string => typeof font === 'string' && font.trim().length > 0).slice(0, 10); -} - -// 只取已知键,数值夹紧,类型不符即丢弃。 -function parseUiSettings(body: unknown): Partial { - const parsed: Partial = {}; - if (!body || typeof body !== 'object' || Array.isArray(body)) return parsed; - const source = body as Record; - for (const key of fontKeys) { - const fonts = readFonts(source[key]); - if (fonts) parsed[key] = fonts; - } - for (const key of numberKeys) { - const value = source[key]; - if (typeof value !== 'number' || !Number.isFinite(value)) continue; - const [min, max] = numberRanges[key]; - parsed[key] = Math.min(max, Math.max(min, Math.round(value))); - } - for (const key of booleanKeys) { - const value = source[key]; - if (typeof value === 'boolean') parsed[key] = value; - } - if (typeof source.nullText === 'string') parsed.nullText = source.nullText; - return parsed; -} - -// 读取远端设置,失败时返回 undefined。 -export async function loadUiSettings(): Promise | undefined> { - try { - const response = await fetch(endpoint, { credentials: 'same-origin' }); - if (!response.ok) return undefined; - const body: unknown = await response.json(); - if (!body || typeof body !== 'object' || Array.isArray(body)) return undefined; - return parseUiSettings(body); - } catch { - return undefined; - } -} - -// 全量写回远端设置,失败时返回 false。 -export async function saveUiSettings(settings: IdeSettings): Promise { - try { - const response = await fetch(endpoint, { - method: 'PUT', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(settings), - }); - return response.ok; - } catch { - return false; - } -} - -// 先并入远端设置,再防抖回写后续变更。 -export function syncUiSettings(): () => void { - let stopped = false; - let unsubscribe: (() => void) | undefined; - let timer: ReturnType | undefined; - const cancel = () => { if (timer !== undefined) { clearTimeout(timer); timer = undefined; } }; - void (async () => { - const remote = await loadUiSettings(); - if (stopped) return; - if (remote) useSettingsStore.setState((state) => ({ settings: { ...state.settings, ...remote } })); - unsubscribe = useSettingsStore.subscribe((state, previous) => { - if (state.settings === previous.settings) return; - cancel(); - timer = setTimeout(() => { - timer = undefined; - void saveUiSettings(useSettingsStore.getState().settings); - }, debounceMs); - }); - })(); - return () => { - stopped = true; - unsubscribe?.(); - cancel(); - }; -} diff --git a/chusql-web/frontend/src/i18n/zh-CN.ts b/chusql-web/frontend/src/i18n/zh-CN.ts deleted file mode 100644 index dac5a5a..0000000 --- a/chusql-web/frontend/src/i18n/zh-CN.ts +++ /dev/null @@ -1,161 +0,0 @@ -// 中文界面文案表:全站 UI 文本的唯一来源。 - -export const zhCN = { - appName: 'ChuSQL 数据库终端', - signingIn: '正在检查登录状态…', - signInTitle: '登录 ChuSQL', - userName: '用户名', - password: '密码', - signIn: '登录', - signInFailed: '登录失败,请检查用户名和密码', - connection: '本地连接', - database: 'chusql', - explorer: '资源管理器', - databaseSection: '数据库', - activityBar: '活动栏', - selectionColumn: '选择', - changesSection: '变更集', - historySection: '历史', - sqlConsole: 'SQL 控制台', - newQuery: '新建查询', - runNext: '单步执行', - runAll: '全部执行', - runSelection: '执行选中', - stop: '停止', - statementPending: '待执行', - statementRunning: '执行中', - statementSuccess: '成功', - statementFailed: '失败', - executionStopped: '已停止;已发出的语句可能仍在服务端运行', - executionSuccess: (rows: number, duration: number) => `执行成功,返回 ${rows} 行 (${duration.toFixed(1)} ms)`, - executionAffected: (rows: number, duration: number) => `执行成功,影响 ${rows} 行 (${duration.toFixed(1)} ms)`, - resultLabel: (index: number, rows: number, duration: number) => `结果 ${index} (${rows} 行, ${duration.toFixed(1)} ms)`, - noStatement: '没有可执行的 SQL 语句', - queryHistory: '查询历史', - settings: '设置', - settingsAppearance: '外观与字体', - settingsBehavior: '数据与编辑器', - interfaceFont: '界面字体', - gridFont: '表格字体', - sqlFont: 'SQL 编辑器字体', - fontName: '字体名称', - addFallback: '添加备用字体', - removeFont: (font: string) => `删除字体 ${font}`, - moveFont: '拖动调整字体顺序', - fontSize: '字号', - rowHeight: '行高', - tabSize: 'Tab 宽度', - showLineNumbers: '显示行号', - pageSize: '分页大小', - nullDisplay: 'NULL 显示文本', - autocomplete: '自动补全', - minimap: 'Minimap', - restoreDefaults: '恢复默认', - preview: '实时预览', - previewText: 'ChuSQL 数据库终端 SELECT * FROM users;', - commit: (count: number) => `提交 (${count})`, - rollback: '回滚', - undo: '撤销', - redo: '重做', - insertRow: '插入行', - deleteRows: '删除选中行', - batchReplace: '批量替换', - findText: '查找内容', - replaceText: '替换为', - replaceSelected: '替换选中区域', - selectedRowsRequired: '请先选择要操作的行', - refresh: '刷新', - filter: (column: string) => `筛选 ${column}`, - openTable: (table: string) => `打开表 ${table}`, - closeTab: (table: string) => `关闭 ${table}`, - editCell: (column: string) => `编辑 ${column}`, - rowSelect: (row: number) => `选择第 ${row} 行`, - noRows: '没有符合条件的数据', - loading: '正在加载…', - loadError: '加载失败', - retry: '重试', - previousPage: '上一页', - nextPage: '下一页', - loadMore: '加载更多', - pendingChanges: '待提交变更', - updateChange: (table: string, column: string) => `修改 ${table}.${column}`, - insertChange: (table: string) => `向 ${table} 插入一行`, - deleteChange: (table: string) => `从 ${table} 删除一行`, - createTableChange: (table: string) => `新建表 ${table}`, - dropTableChange: (table: string) => `删除表 ${table}`, - createIndexChange: (table: string, column: string) => `为 ${table}.${column} 建索引`, - dropIndexChange: (table: string, column: string) => `删除 ${table}.${column} 的索引`, - undoChange: '撤销此变更', - noPendingChanges: '没有待提交变更', - noResults: '尚无查询结果', - results: '结果', - messages: '消息', - cancel: '取消', - committed: (count: number) => `已提交 ${count} 项变更`, - commitFailed: '提交失败,变更已保留', - rowCount: (count: number) => `${count} 行`, - pendingCount: (count: number) => `待提交 ${count} 项`, - nullValue: 'NULL', - emptyWorkspace: '从左侧资源管理器打开一张表,或打开 SQL 控制台。', - queryTab: (index: number) => `查询 ${index}`, - resizeSidebar: '拖动调整侧边栏宽度', - resizeDock: '拖动调整底部面板高度', - close: '关闭', - booleanTrue: '是', - booleanFalse: '否', - done: '完成', - clear: '清空', - remove: '移除', - newTable: '新建表', - expandColumns: (table: string) => `展开 ${table} 的列`, - manageIndexes: (table: string) => `管理 ${table} 的索引`, - dropTableAction: (table: string) => `删除表 ${table}`, - columnPrimaryKey: '主键列', - columnIndexed: '索引列', - columnOrdinary: '普通列', - newTableTitle: '新建表', - dropTableTitle: '删除表', - indexManagerTitle: '索引管理', - tableName: '表名', - columnName: (index: number) => `列名 ${index}`, - columnType: (index: number) => `列类型 ${index}`, - removeColumn: (index: number) => `移除第 ${index} 列`, - addColumn: '添加列', - createTable: '建表', - dropTablePermanently: '永久删除表', - typeInt: '整数', - typeStr: '文本', - typeBool: '布尔', - typeDate: '日期', - stagedMessage: (sql: string) => `已暂存:${sql}`, - builtinIndex: '内建主键索引', - uniqueIndex: '唯一索引', - noIndex: '无索引', - createIndex: '建索引', - deleteIndex: '删索引', - createTableHint: '作为待提交变更暂存,提交时建表。整数 id 固定为内建主键;至少填一列,并删掉多余的空列。', - dropTableHint: (table: string, rows: number) => `作为待提交变更暂存:提交后会删掉 ${table} 的全部 ${rows} 行、列定义与索引,且不可撤销。`, - indexHint: '索引变更先暂存,提交时才下发。目前只支持唯一整数索引,列里有重复值时提交会失败;内建 id 索引不能删除。', - invalidIdentifier: '表名与列名必须以字母或下划线开头,且只能包含字母、数字与下划线。', - duplicateColumns: '列名重复', - insertRowTitle: (table: string) => `插入行 · ${table}`, - addToPending: '加入待提交', - insertRowHint: '每个字段都要填。加入后仍可修改或撤销,点顶部的「提交」才会写入数据库。', - primaryKeyBadge: ' · 主键(必填且唯一)', - invalidInteger: (column: string) => `${column}:请输入合法整数`, - chooseDate: (column: string) => `${column}:请选择日期`, - selectRowsHint: '单击选中一行;Ctrl+单击多选,Shift+单击连选,空格键也可选中', - selectedRows: (count: number) => `已选 ${count} 行`, - rowStaged: '新行已加入待提交', - filterPlaceholder: '筛选表达式', - resizeColumn: (column: string) => `调整 ${column} 列宽`, -} as const; - -export const columnTypeLabels: Record = { - int: zhCN.typeInt, - str: zhCN.typeStr, - bool: zhCN.typeBool, - date: zhCN.typeDate, -}; - -export type Messages = typeof zhCN; diff --git a/chusql-web/frontend/src/main.tsx b/chusql-web/frontend/src/main.tsx deleted file mode 100644 index 7db7b9e..0000000 --- a/chusql-web/frontend/src/main.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import { App } from './App'; -import { MockAdapter } from './adapters/MockAdapter'; -import { RestAdapter } from './adapters/RestAdapter'; -import { AuthGate } from './components/common/AuthGate'; -import './styles/global.css'; - -// 前端入口:按环境选择 mock/rest 数据源并挂载根组件。 - -const adapterName = import.meta.env.VITE_CHUSQL_ADAPTER ?? (import.meta.env.DEV ? 'mock' : 'rest'); -const adapter = adapterName === 'rest' ? new RestAdapter() : new MockAdapter(); -const root = document.getElementById('root'); -if (!root) throw new Error('Root element is missing.'); -const application = ; -createRoot(root).render({adapterName === 'rest' ? {application} : application}); diff --git a/chusql-web/frontend/src/store/changeStore.test.ts b/chusql-web/frontend/src/store/changeStore.test.ts deleted file mode 100644 index d0d7c4a..0000000 --- a/chusql-web/frontend/src/store/changeStore.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { useChangeStore } from './changeStore'; - -// 覆盖变更记录、结构变更暂存与部分提交确认。 - -describe('changeStore', () => { - beforeEach(() => useChangeStore.getState().clear()); - it('removes successful inserts after a partial commit so retries cannot duplicate them', () => { - const store = useChangeStore.getState(); - store.insertRow('users', 'temp-a', { id: 901 }); - store.insertRow('users', 'temp-b', { id: 902 }); - const submitted = useChangeStore.getState().changes; - store.acknowledge(submitted, 1); - expect([...useChangeStore.getState().changes.inserts.keys()]).toEqual(['temp-b']); - store.undo(); - expect([...useChangeStore.getState().changes.inserts.keys()]).toEqual(['temp-b']); - }); - - it('records edits without writing through and supports undo/redo', () => { - const store = useChangeStore.getState(); - store.updateCell('users', 1, 'name', 'A', 'B'); - expect(useChangeStore.getState().changes.updates.size).toBe(1); - useChangeStore.getState().undo(); - expect(useChangeStore.getState().changes.updates.size).toBe(0); - useChangeStore.getState().redo(); - expect(useChangeStore.getState().changes.updates.get('users:1:name')?.newValue).toBe('B'); - }); - - it('removes a new row instead of staging a delete for it', () => { - const store = useChangeStore.getState(); - store.insertRow('users', 'temp-1', { name: 'N' }); - useChangeStore.getState().deleteRow('users', 'temp-1', { name: 'N' }, true); - expect(useChangeStore.getState().changes.inserts.size).toBe(0); - expect(useChangeStore.getState().changes.deletes.size).toBe(0); - }); - - it('stages structural changes and can undo a single one', () => { - const store = useChangeStore.getState(); - store.stageSchema({ op: 'createIndex', table: 'users', column: 'age' }); - store.stageSchema({ op: 'dropTable', table: 'items' }); - expect([...useChangeStore.getState().changes.schemas.keys()]).toEqual(['createIndex:users:age', 'dropTable:items']); - useChangeStore.getState().undoOne('schemas', 'createIndex:users:age'); - expect([...useChangeStore.getState().changes.schemas.keys()]).toEqual(['dropTable:items']); - useChangeStore.getState().undo(); - expect([...useChangeStore.getState().changes.schemas.keys()]).toEqual(['createIndex:users:age', 'dropTable:items']); - }); - - it('keeps unapplied changes when the commit stops after a structural change', () => { - const store = useChangeStore.getState(); - store.stageSchema({ op: 'createTable', table: 'items', columns: [{ name: 'id', type: 'int' }] }); - store.insertRow('users', 'temp-a', { id: 901 }); - store.acknowledge(useChangeStore.getState().changes, 1); - expect(useChangeStore.getState().changes.schemas.size).toBe(0); - expect([...useChangeStore.getState().changes.inserts.keys()]).toEqual(['temp-a']); - }); - - it('drops a structural change once the whole queue is acknowledged', () => { - const store = useChangeStore.getState(); - store.insertRow('users', 'temp-a', { id: 901 }); - store.stageSchema({ op: 'dropTable', table: 'items' }); - store.acknowledge(useChangeStore.getState().changes, 2); - expect(useChangeStore.getState().changes.schemas.size).toBe(0); - expect(useChangeStore.getState().changes.inserts.size).toBe(0); - }); -}); diff --git a/chusql-web/frontend/src/store/changeStore.ts b/chusql-web/frontend/src/store/changeStore.ts deleted file mode 100644 index 1cd3349..0000000 --- a/chusql-web/frontend/src/store/changeStore.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { create } from 'zustand'; -import { changeKey, cloneChangeSet, createEmptyChangeSet, pendingChanges, schemaKey } from '../core/changes'; -import type { ChangeSet, SchemaChange } from '../types'; - -// 待提交变更状态:增删改记录、撤销重做与提交确认。 - -interface ChangeState { - changes: ChangeSet; - undoStack: ChangeSet[]; - redoStack: ChangeSet[]; - updateCell: (table: string, pk: unknown, column: string, oldValue: unknown, newValue: unknown) => void; - insertRow: (table: string, tempId: string, values: Record) => void; - updateInsert: (tempId: string, values: Record) => void; - deleteRow: (table: string, pk: unknown, row: Record, isInserted?: boolean) => void; - stageSchema: (change: SchemaChange) => void; - undoOne: (kind: keyof ChangeSet, key: string) => void; - undo: () => void; - redo: () => void; - clear: () => void; - acknowledge: (submitted: ChangeSet, applied: number) => void; -} - -function withHistory(state: ChangeState, mutate: (next: ChangeSet) => void): Partial { - const next = cloneChangeSet(state.changes); - mutate(next); - return { - changes: next, - undoStack: [...state.undoStack, cloneChangeSet(state.changes)].slice(-100), - redoStack: [], - }; -} - -export const useChangeStore = create((set) => ({ - changes: createEmptyChangeSet(), - undoStack: [], - redoStack: [], - updateCell: (table, pk, column, oldValue, newValue) => set((state) => withHistory(state, (next) => { - const key = changeKey(table, pk, column); - if (Object.is(oldValue, newValue)) next.updates.delete(key); - else next.updates.set(key, { table, pk, column, oldValue, newValue }); - })), - insertRow: (table, tempId, values) => set((state) => withHistory(state, (next) => { - next.inserts.set(tempId, { table, tempId, values: { ...values } }); - })), - updateInsert: (tempId, values) => set((state) => withHistory(state, (next) => { - const insert = next.inserts.get(tempId); - if (insert) next.inserts.set(tempId, { ...insert, values: { ...values } }); - })), - deleteRow: (table, pk, row, isInserted = false) => set((state) => withHistory(state, (next) => { - if (isInserted) { - next.inserts.delete(String(pk)); - return; - } - for (const [key, update] of next.updates) { - if (update.table === table && Object.is(update.pk, pk)) next.updates.delete(key); - } - next.deletes.set(`${table}:${JSON.stringify(pk)}`, { table, pk, row: { ...row } }); - })), - stageSchema: (change) => set((state) => withHistory(state, (next) => { - next.schemas.set(schemaKey(change), change); - })), - undoOne: (kind, key) => set((state) => withHistory(state, (next) => { - next[kind].delete(key); - })), - undo: () => set((state) => { - const previous = state.undoStack.at(-1); - if (!previous) return state; - return { - changes: cloneChangeSet(previous), - undoStack: state.undoStack.slice(0, -1), - redoStack: [...state.redoStack, cloneChangeSet(state.changes)].slice(-100), - }; - }), - redo: () => set((state) => { - const next = state.redoStack.at(-1); - if (!next) return state; - return { - changes: cloneChangeSet(next), - undoStack: [...state.undoStack, cloneChangeSet(state.changes)].slice(-100), - redoStack: state.redoStack.slice(0, -1), - }; - }), - clear: () => set({ changes: createEmptyChangeSet(), undoStack: [], redoStack: [] }), - acknowledge: (submitted, applied) => set((state) => { - const next = cloneChangeSet(state.changes); - const completed = pendingChanges(submitted).slice(0, applied); - const inserted = Array.from(submitted.inserts.entries()); - let insertIndex = 0; - for (const operation of completed) { - if (operation.op === 'insert') { - const [key, value] = inserted[insertIndex++]; - if (next.inserts.get(key) === value) next.inserts.delete(key); - } else if (operation.op === 'delete') { - const key = `${operation.table}:${JSON.stringify(operation.pk)}`; - if (next.deletes.get(key) === submitted.deletes.get(key)) next.deletes.delete(key); - } else if (operation.op === 'update') { - for (const column of Object.keys(operation.set)) { - const key = changeKey(operation.table, operation.pk, column); - if (next.updates.get(key) === submitted.updates.get(key)) next.updates.delete(key); - } - } else { - const key = schemaKey(operation); - if (next.schemas.get(key) === submitted.schemas.get(key)) next.schemas.delete(key); - } - } - return { changes: next, undoStack: [], redoStack: [] }; - }), -})); diff --git a/chusql-web/frontend/src/store/settingsStore.test.ts b/chusql-web/frontend/src/store/settingsStore.test.ts deleted file mode 100644 index f1f0130..0000000 --- a/chusql-web/frontend/src/store/settingsStore.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { DEFAULT_SETTINGS, fontFamilyValue, useSettingsStore } from './settingsStore'; - -// 覆盖字体链转义与数值范围收敛。 - -describe('settingsStore', () => { - beforeEach(() => { - localStorage.clear(); - useSettingsStore.getState().reset(); - }); - - it('quotes a user-defined fallback chain safely', () => { - expect(fontFamilyValue(['JetBrains Mono', 'Consolas'])).toBe('"JetBrains Mono", "Consolas", monospace'); - expect(fontFamilyValue(['A"B', ' '])).toBe('"A\\"B", monospace'); - }); - - it('clamps numeric settings to the supported range', () => { - useSettingsStore.getState().setNumber('gridRowHeight', 200); - expect(useSettingsStore.getState().settings.gridRowHeight).toBe(40); - useSettingsStore.getState().setNumber('pageSize', 1); - expect(useSettingsStore.getState().settings.pageSize).toBe(10); - expect(DEFAULT_SETTINGS.uiFontSize).toBe(13); - }); -}); diff --git a/chusql-web/frontend/src/store/settingsStore.ts b/chusql-web/frontend/src/store/settingsStore.ts deleted file mode 100644 index f98596b..0000000 --- a/chusql-web/frontend/src/store/settingsStore.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; - -// IDE 设置状态:默认值、范围收敛与本地持久化。 - -export interface IdeSettings { - uiFonts: string[]; - uiFontSize: number; - gridFonts: string[]; - gridFontSize: number; - gridRowHeight: number; - sqlFonts: string[]; - sqlFontSize: number; - sqlLineHeight: number; - sqlTabSize: number; - sqlLineNumbers: boolean; - pageSize: number; - nullText: string; - autocomplete: boolean; - minimap: boolean; -} - -export const DEFAULT_SETTINGS: IdeSettings = Object.freeze({ - uiFonts: ['JetBrains Mono', 'Consolas', 'Cascadia Code'], - uiFontSize: 13, - gridFonts: ['JetBrains Mono', 'Consolas'], - gridFontSize: 12, - gridRowHeight: 24, - sqlFonts: ['JetBrains Mono', 'Consolas'], - sqlFontSize: 13, - sqlLineHeight: 20, - sqlTabSize: 2, - sqlLineNumbers: true, - pageSize: 200, - nullText: 'NULL', - autocomplete: true, - minimap: false, -}); - -const ranges: Record = { - uiFontSize: [11, 16], - gridFontSize: [10, 18], - gridRowHeight: [18, 40], - sqlFontSize: [10, 20], - sqlLineHeight: [16, 40], - sqlTabSize: [1, 8], - pageSize: [10, 500], -}; - -export function fontFamilyValue(chain: string[]): string { - const fonts = chain.map((font) => font.trim()).filter(Boolean).map((font) => `"${font.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`); - return [...fonts, 'monospace'].join(', '); -} - -interface SettingsState { - settings: IdeSettings; - setNumber: (key: keyof IdeSettings, value: number) => void; - setBoolean: (key: keyof IdeSettings, value: boolean) => void; - setText: (key: keyof IdeSettings, value: string) => void; - setFonts: (key: 'uiFonts' | 'gridFonts' | 'sqlFonts', value: string[]) => void; - reset: () => void; -} - -export const useSettingsStore = create()(persist((set) => ({ - settings: { ...DEFAULT_SETTINGS }, - setNumber: (key, value) => set((state) => { - const range = ranges[key]; - const next = range ? Math.min(range[1], Math.max(range[0], Math.round(value))) : value; - return { settings: { ...state.settings, [key]: next } }; - }), - setBoolean: (key, value) => set((state) => ({ settings: { ...state.settings, [key]: value } })), - setText: (key, value) => set((state) => ({ settings: { ...state.settings, [key]: value } })), - setFonts: (key, value) => set((state) => ({ settings: { ...state.settings, [key]: value.slice(0, 10) } })), - reset: () => set({ settings: { ...DEFAULT_SETTINGS } }), -}), { - name: 'chusql.ide.settings.v1', - partialize: (state) => ({ settings: state.settings }), -})); diff --git a/chusql-web/frontend/src/styles/global.css b/chusql-web/frontend/src/styles/global.css deleted file mode 100644 index 42eb450..0000000 --- a/chusql-web/frontend/src/styles/global.css +++ /dev/null @@ -1,48 +0,0 @@ -/* 全局样式:主题变量、基础重置与滚动条。 */ -:root { - --bg-base: #1e1e1e; - --bg-elevated: #252526; - --bg-overlay: #2d2d30; - --bg-active: #37373d; - --bg-titlebar: #3c3c3c; - --bg-statusbar: #007acc; - --border: #3c3c3c; - --border-subtle: #2d2d2d; - --fg-primary: #cccccc; - --fg-muted: #858585; - --fg-disabled: #5a5a5a; - --accent: #007acc; - --accent-2: #0e639c; - --success: #4ec9b0; - --warning: #cca700; - --danger: #f14c4c; - --modified: #e2c08d; - --info: #9cdcfe; - --syn-keyword: #569cd6; - --syn-control: #c586c0; - --syn-string: #ce9178; - --syn-number: #b5cea8; - --syn-comment: #6a9955; - --syn-function: #dcdcaa; - --syn-type: #4ec9b0; - --syn-variable: #9cdcfe; - --ui-font: "JetBrains Mono", Consolas, "Cascadia Code", monospace; - --grid-font: "JetBrains Mono", Consolas, monospace; - --grid-font-size: 12px; - --grid-row-height: 24px; - color: var(--fg-primary); - font-family: var(--ui-font); - font-size: 13px; - font-synthesis: none; -} - -* { box-sizing: border-box; } -html, body, #root { height: 100%; margin: 0; overflow: hidden; } -body { background: var(--bg-base); } -button, input, select, textarea { font-family: inherit; } -button { border-radius: 2px; } -button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; } -::-webkit-scrollbar { height: 10px; width: 10px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: var(--bg-active); } -@media (prefers-reduced-motion: no-preference) { button, input, select { transition: background-color 100ms ease-out, border-color 100ms ease-out; } } diff --git a/chusql-web/frontend/src/test/setup.ts b/chusql-web/frontend/src/test/setup.ts deleted file mode 100644 index 0af6ee6..0000000 --- a/chusql-web/frontend/src/test/setup.ts +++ /dev/null @@ -1,7 +0,0 @@ -import '@testing-library/jest-dom/vitest'; -import { cleanup } from '@testing-library/react'; -import { afterEach } from 'vitest'; - -// 测试环境初始化:注册 jest-dom 断言与自动清理。 - -afterEach(cleanup); diff --git a/chusql-web/frontend/src/types.ts b/chusql-web/frontend/src/types.ts deleted file mode 100644 index fcc57b2..0000000 --- a/chusql-web/frontend/src/types.ts +++ /dev/null @@ -1,92 +0,0 @@ -// 共享类型定义:数据源适配器接口、元数据与查询结果结构。 - -export type DataValue = string | number | boolean | null; - -export interface ColumnMeta { - name: string; - type: 'int' | 'str' | 'bool' | 'date'; - primaryKey?: boolean; - indexed?: boolean; - prime?: boolean; - distinct?: number; - statsCapped?: boolean; -} - -export interface TableMeta { - name: string; - rowCount: number; - columns: ColumnMeta[]; -} - -export interface FilterExpr { - column: string; - expression: string; -} - -export interface FetchRowsParams { - db: string; - table: string; - page: number; - pageSize: number; - sort: Array<{ column: string; dir: 'asc' | 'desc' }>; - filters: FilterExpr[]; - search?: string; -} - -export type SchemaChange = - | { op: 'createTable'; table: string; columns: Array> } - | { op: 'dropTable'; table: string } - | { op: 'createIndex'; table: string; column: string } - | { op: 'dropIndex'; table: string; column: string }; - -export type CommitChange = - | { op: 'update'; table: string; pk: unknown; set: Record } - | { op: 'insert'; table: string; values: Record } - | { op: 'delete'; table: string; pk: unknown }; - -export type PendingChange = CommitChange | SchemaChange; - -export interface StatementResult { - type: 'rows' | 'affected' | 'error'; - columns?: string[]; - rows?: unknown[][]; - affected?: number; - durationMs: number; - message?: string; -} - -export interface DataSourceAdapter { - createTable(name: string, columns: Array>): Promise; - dropTable(table: string): Promise; - createIndex(table: string, column: string): Promise; - dropIndex(table: string, column: string): Promise; - listTables(db: string): Promise; - getColumns(db: string, table: string): Promise; - fetchRows(params: FetchRowsParams): Promise<{ rows: Record[]; total: number }>; - commitChanges(payload: { - db: string; - changes: CommitChange[]; - }): Promise<{ ok: boolean; applied: number; errors?: Array<{ index: number; message: string }> }>; - execStatement(params: { db: string; sql: string }): Promise; -} - -export interface ChangeSet { - updates: Map; - inserts: Map; - }>; - deletes: Map; - }>; - schemas: Map; -} diff --git a/chusql-web/frontend/src/vite-env.d.ts b/chusql-web/frontend/src/vite-env.d.ts deleted file mode 100644 index f9ae3ea..0000000 --- a/chusql-web/frontend/src/vite-env.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -/// - -// Vite 环境类型声明:客户端环境变量与 CSS Modules 模块。 - -declare module '*.module.css' { - const classes: Record; - export default classes; -} diff --git a/chusql-web/frontend/tsconfig.app.json b/chusql-web/frontend/tsconfig.app.json deleted file mode 100644 index 613844c..0000000 --- a/chusql-web/frontend/tsconfig.app.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "useDefineForClassFields": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "allowJs": false, - "skipLibCheck": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "module": "ESNext", - "moduleResolution": "Bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - "types": ["vitest/globals", "@testing-library/jest-dom"] - }, - "include": ["src"] -} diff --git a/chusql-web/frontend/tsconfig.json b/chusql-web/frontend/tsconfig.json deleted file mode 100644 index 1ffef60..0000000 --- a/chusql-web/frontend/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] -} diff --git a/chusql-web/frontend/tsconfig.node.json b/chusql-web/frontend/tsconfig.node.json deleted file mode 100644 index 60e386a..0000000 --- a/chusql-web/frontend/tsconfig.node.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "skipLibCheck": true, - "module": "ESNext", - "moduleResolution": "Bundler", - "allowImportingTsExtensions": true, - "strict": true, - "noEmit": true, - "types": ["node"] - }, - "include": ["vite.config.ts", "vitest.config.ts"] -} diff --git a/chusql-web/frontend/vite.config.ts b/chusql-web/frontend/vite.config.ts deleted file mode 100644 index 281e162..0000000 --- a/chusql-web/frontend/vite.config.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { fileURLToPath, URL } from 'node:url'; -import react from '@vitejs/plugin-react'; -import { defineConfig } from 'vite'; - -// Vite 配置:React 插件、Monaco nonce 注入与构建输出。 - -export default defineConfig({ - plugins: [react(), { - name: 'monaco-style-nonce', - enforce: 'pre', - transform(code, id) { - if (!id.split('\\').join('/').includes('/monaco-editor/esm/')) return; - const source = "document.createElement('style')"; - if (!code.includes(source)) return; - return code.split(source).join(`Object.assign(document.createElement('style'), { nonce: document.querySelector('meta[name="chusql-style-nonce"]')?.content || '' })`); - }, - }], - base: '/static/', - resolve: { - alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, - }, - build: { - outDir: '../static', - emptyOutDir: true, - assetsDir: '', - rollupOptions: { - output: { - entryFileNames: 'app.js', - chunkFileNames: '[name].js', - assetFileNames: (asset) => asset.names?.some((name) => name.endsWith('.css')) ? 'style.css' : '[name][extname]', - }, - }, - }, -}); diff --git a/chusql-web/frontend/vitest.config.ts b/chusql-web/frontend/vitest.config.ts deleted file mode 100644 index 950d66d..0000000 --- a/chusql-web/frontend/vitest.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -// Vitest 配置:jsdom 环境与测试初始化脚本。 - -export default defineConfig({ - test: { - environment: 'jsdom', - setupFiles: './src/test/setup.ts', - restoreMocks: true, - }, -}); diff --git a/chusql-web/src/ChuSQL/Web/Accounts.hs b/chusql-web/src/ChuSQL/Web/Accounts.hs new file mode 100644 index 0000000..dc3ec51 --- /dev/null +++ b/chusql-web/src/ChuSQL/Web/Accounts.hs @@ -0,0 +1,257 @@ +{-# LANGUAGE OverloadedStrings #-} + +module ChuSQL.Web.Accounts ( + Accounts, AccountError (..), Principal (..), PasswordPolicy (..), defaultPasswordPolicy, + newAccounts, accountsPolicy, setAccountsPolicy, passwordAllowed, + rootUserName, principalName, principalIsRoot, + authenticate, currentPrincipal, + listAccounts, findAccount, AccountCommand (..), accountCommand, runAccountCommand, +) where + +import ChuSQL.Storage.IPC (Account (..), Request (..)) +import ChuSQL.Syntax.AST (Statement (..)) +import ChuSQL.Web.Auth +import ChuSQL.Web.Backend (Backend (..), currentStamp) +import Control.Concurrent.MVar (MVar, newMVar, withMVar) +import Data.Char (isAlphaNum, isAscii, isAsciiLower, isAsciiUpper, isDigit) +import Data.IORef (IORef, newIORef, readIORef, writeIORef) +import Data.Text (Text) +import qualified Data.Text as T + +-- 账号服务:root 来自配置,普通账号由 root 用 CREATE/ALTER/DROP USER 管,口令在这里做策略检查与 PBKDF2 派生。 + +data PasswordPolicy = PasswordPolicy + { ppMinLength :: Int + , ppClasses :: Int + } + deriving (Show, Eq) + +-- | 默认:至少 12 个字符、至少两类字符 +defaultPasswordPolicy :: PasswordPolicy +defaultPasswordPolicy = PasswordPolicy 12 2 + +data AccountError = AccountError Text Text deriving (Show, Eq) + +-- | 当前身份:配置里的管理员,或系统表里的一个普通账号 +data Principal + = Root Text + | Ordinary Account + deriving (Eq) + +instance Show Principal where + show (Root name) = "root " ++ show name + show (Ordinary a) = "account " ++ show (accountUser a) + +-- | 这个身份叫什么 +principalName :: Principal -> Text +principalName (Root name) = name +principalName (Ordinary a) = accountUser a + +-- | 是不是配置里的管理员 +principalIsRoot :: Principal -> Bool +principalIsRoot (Root _) = True +principalIsRoot (Ordinary _) = False + +data Accounts = Accounts + { acBackend :: Backend + , acSessions :: SessionStore + , acLock :: MVar () + , acPolicy :: IORef PasswordPolicy + , acRoot :: Credential + } + +-- | 只记下配置根账号;root 不写系统表,所以启动时不需要任何持久化动作 +newAccounts :: Backend -> SessionStore -> Credential -> IO Accounts +newAccounts backend sessions credential = do + lock <- newMVar () + policy <- newIORef defaultPasswordPolicy + pure (Accounts backend sessions lock policy credential) + +-- | 账号名统一小写去空白 +normalize :: Text -> Text +normalize = T.toLower . T.strip + +-- | 配置里的管理员叫什么 +rootName :: Accounts -> Text +rootName = normalize . credUser . acRoot + +-- | 管理员名字(界面显示用) +rootUserName :: Accounts -> Text +rootUserName = rootName + +accountsPolicy :: Accounts -> IO PasswordPolicy +accountsPolicy = readIORef . acPolicy + +setAccountsPolicy :: Accounts -> PasswordPolicy -> IO () +setAccountsPolicy service = writeIORef (acPolicy service) + +-- | 口令策略:长度 + 字符类别(历史与有效期随自助改密一起取消) +passwordAllowed :: PasswordPolicy -> Text -> Either AccountError () +passwordAllowed policy password + | T.length password < ppMinLength policy || T.length password > 256 = bad "password length does not meet policy" + | classes < ppClasses policy = bad "password needs more character classes" + | otherwise = Right () + where + bad = Left . AccountError "bad_request" + classes = + length + ( filter id + [ T.any isAsciiLower password + , T.any isAsciiUpper password + , T.any isDigit password + , T.any (not . isAlphaNum) password + ] + ) + +-- | 账号名规则:ASCII 字母数字与点、短横线、下划线 +validateUserName :: Text -> Either AccountError () +validateUserName name + | T.null name || T.length name > 64 || not (T.all allowed name) = + Left (AccountError "bad_request" "invalid account name") + | otherwise = Right () + where + allowed c = isAscii c && (isAlphaNum c || c `elem` ("_.-" :: String)) + +-- | 新建/改口令时的额外限制:管理员的名字保留给配置里的 root +validateNewName :: Accounts -> Text -> Either AccountError () +validateNewName service name = do + validateUserName name + if normalize name == rootName service + then Left (AccountError "bad_request" "the administrator name is reserved") + else Right () + +storageFailure :: AccountError +storageFailure = AccountError "storage_error" "account storage unavailable" + +unauthorized :: AccountError +unauthorized = AccountError "unauthorized" "invalid account or session" + +-- | 存储侧报错翻成账号错误 +storageResult :: Either String [Account] -> Either AccountError () +storageResult (Right _) = Right () +storageResult (Left "account already exists") = Left (AccountError "conflict" "account already exists") +storageResult (Left "unknown account") = Left (AccountError "not_found" "unknown account") +storageResult (Left _) = Left storageFailure + +-- | 读系统表里的普通账号 +readAccounts :: Accounts -> IO (Either AccountError [Account]) +readAccounts service = fmap (either (const (Left storageFailure)) Right) (beAccounts (acBackend service) ReqAccountsList) + +-- | 当前令牌对应的身份;会话版本与账号 revision 对不上就失效 +currentUnlocked :: Accounts -> Text -> IO (Either AccountError Principal) +currentUnlocked service token = do + session <- lookupVersionedSession (acSessions service) token + case session of + Nothing -> pure (Left unauthorized) + Just (user, revision) + | user == rootName service && revision == Nothing -> pure (Right (Root (rootName service))) + | otherwise -> do + stored <- readAccounts service + pure $ do + accounts <- stored + account <- case filter ((== user) . accountUser) accounts of + [a] -> Right a + _ -> Left unauthorized + if revision == Just (accountRevision account) + then Right (Ordinary account) + else Left unauthorized + +currentPrincipal :: Accounts -> Text -> IO (Either AccountError Principal) +currentPrincipal service token = withMVar (acLock service) $ \_ -> currentUnlocked service token + +-- | 登录:先比配置里的 root,再查系统表里的普通账号(没有切换身份的入口)。 +-- 配置里的口令留空即"免密":只有管理员能用空口令进来,普通账号一律拒绝。 +authenticate :: Accounts -> Text -> Text -> IO (Either AccountError Text) +authenticate service user password = withMVar (acLock service) $ \_ -> + if normalize user == rootName service + then signInRoot + else + if passwordless + then pure (Left (AccountError "forbidden" "this server only accepts the administrator")) + else signInOrdinary + where + passwordless = T.null (credEncoded (acRoot service)) + signInRoot + | passwordless = + if T.null password + then Right <$> createVersionedSession (acSessions service) (rootName service) Nothing + else pure (Left unauthorized) + | verifyPassword (credEncoded (acRoot service)) password = + Right <$> createVersionedSession (acSessions service) (rootName service) Nothing + | otherwise = pure (Left unauthorized) + signInOrdinary = do + stored <- readAccounts service + case stored of + Left err -> pure (Left err) + Right accounts -> case filter ((== normalize user) . accountUser) accounts of + [a] | verifyPassword (accountHash a) password -> do + stamp <- currentStamp + written <- beAccounts (acBackend service) (ReqAccountLogin (accountUser a) (Just stamp)) + case written of + Left _ -> pure (Left storageFailure) + Right _ -> Right <$> createVersionedSession (acSessions service) (accountUser a) (Just (accountRevision a)) + _ -> pure (Left unauthorized) + +-- | 系统表里的普通账号清单(root 专用) +listAccounts :: Accounts -> IO (Either AccountError [Account]) +listAccounts service = withMVar (acLock service) $ \_ -> readAccounts service + +-- | 按用户名找一个普通账号 +findAccount :: Accounts -> Text -> IO (Either AccountError Account) +findAccount service name = withMVar (acLock service) $ \_ -> do + stored <- readAccounts service + pure $ do + accounts <- stored + case filter ((== normalize name) . accountUser) accounts of + [a] -> Right a + [] -> Left (AccountError "not_found" "unknown account") + _ -> Left storageFailure + +-- | 建一个普通账号 +createUser :: Accounts -> Text -> Text -> IO (Either AccountError ()) +createUser service name password = withMVar (acLock service) $ \_ -> do + policy <- accountsPolicy service + case validateNewName service name >> passwordAllowed policy password of + Left err -> pure (Left err) + Right () -> do + encoded <- hashPassword password + storageResult <$> beAccounts (acBackend service) (ReqAccountCreate (normalize name) encoded) + +-- | 改一个普通账号的口令(管理员重置,不受历史/有效期限制) +resetPassword :: Accounts -> Text -> Text -> IO (Either AccountError ()) +resetPassword service name password = withMVar (acLock service) $ \_ -> do + policy <- accountsPolicy service + case validateNewName service name >> passwordAllowed policy password of + Left err -> pure (Left err) + Right () -> do + encoded <- hashPassword password + storageResult <$> beAccounts (acBackend service) (ReqAccountReset (normalize name) encoded) + +-- | 删一个普通账号 +dropUser :: Accounts -> Text -> IO (Either AccountError ()) +dropUser service name = withMVar (acLock service) $ \_ -> + case validateUserName name of + Left err -> pure (Left err) + Right () -> storageResult <$> beAccounts (acBackend service) (ReqAccountDrop (normalize name)) + +-- | 账号管理命令:SQL 语句与一键接口在这上面汇合 +data AccountCommand + = CreateAccount Text Text + | ResetAccountPassword Text Text + | DropAccount Text + deriving (Show, Eq) + +-- | 一条语句是不是账号管理语句 +accountCommand :: Statement -> Maybe AccountCommand +accountCommand (CreateUser name password) = Just (CreateAccount (T.pack name) (T.pack password)) +accountCommand (AlterUser name password) = Just (ResetAccountPassword (T.pack name) (T.pack password)) +accountCommand (DropUser name) = Just (DropAccount (T.pack name)) +accountCommand _ = Nothing + +-- | 执行一条账号管理命令;普通账号一律拒绝 +runAccountCommand :: Accounts -> Principal -> AccountCommand -> IO (Either AccountError ()) +runAccountCommand _ (Ordinary _) _ = pure (Left (AccountError "forbidden" "administrator required")) +runAccountCommand service (Root _) command = case command of + CreateAccount name password -> createUser service name password + ResetAccountPassword name password -> resetPassword service name password + DropAccount name -> dropUser service name diff --git a/chusql-web/src/ChuSQL/Web/Actions.hs b/chusql-web/src/ChuSQL/Web/Actions.hs index 335dcd4..38fbf23 100644 --- a/chusql-web/src/ChuSQL/Web/Actions.hs +++ b/chusql-web/src/ChuSQL/Web/Actions.hs @@ -9,6 +9,8 @@ module ChuSQL.Web.Actions ( coerceValue, sqlLiteral, escapeStringLiteral, + createDatabaseSql, + dropDatabaseSql, createTableSql, dropTableSql, createIndexSql, @@ -19,9 +21,13 @@ module ChuSQL.Web.Actions ( updateRowSql, deleteRowSql, selectRowsSql, + createUserSql, + alterUserSql, + dropUserSql, ) where -import ChuSQL.Model (Column (..), Value (..)) +import ChuSQL.Model (Column (..), ColumnType (..), Value (..), integerType, numericType, parseColumnType, plainColumn, typeName) +import qualified ChuSQL.Model as M import qualified Data.Aeson as A import Data.Char (isAsciiLower, isAsciiUpper, isDigit) import Data.List (intercalate) @@ -69,21 +75,11 @@ requireNonEmpty what xs -- | 线上列类型名(和数据字典里的写法一致)转本地类型 columnTypeOf :: Text -> Maybe Column -columnTypeOf t = case T.toLower (T.strip t) of - "int" -> Just TInt - "integer" -> Just TInt - "str" -> Just TStr - "text" -> Just TStr - "varchar" -> Just TStr - "bool" -> Just TBool - "boolean" -> Just TBool - _ -> Nothing +columnTypeOf = fmap plainColumn . parseColumnType . T.unpack . T.strip -- | 本地类型转线上名字 columnTypeName :: Column -> Text -columnTypeName TInt = "int" -columnTypeName TStr = "str" -columnTypeName TBool = "bool" +columnTypeName = T.pack . typeName . columnType -- | 字符串字面量转义:单引号翻倍(引擎的词法器就是这么折回去的) escapeStringLiteral :: String -> String @@ -91,31 +87,48 @@ escapeStringLiteral = concatMap (\c -> if c == '\'' then "''" else [c]) -- | 值转 SQL 字面量 sqlLiteral :: Value -> String +sqlLiteral VNull = "NULL" sqlLiteral (VInt n) = show n +sqlLiteral (VFloat d) = show d sqlLiteral (VStr s) = "'" ++ escapeStringLiteral s ++ "'" sqlLiteral (VBool True) = "TRUE" sqlLiteral (VBool False) = "FALSE" -- | JSON 值按列类型转成引擎的值;类型对不上就报错 coerceValue :: Column -> A.Value -> Either String Value -coerceValue TInt v = case v of +coerceValue col v = case v of + A.Null -> Right VNull + A.Bool b -> M.coerceValue t (VBool b) A.Number n -> case floatingOrInteger n :: Either Double Integer of - Right i -> Right (VInt (fromIntegral i)) - Left _ -> Left "expected an integer" - A.String s -> case readMaybe (T.unpack (T.strip s)) :: Maybe Int of - Just i -> Right (VInt i) - Nothing -> Left ("expected an integer, got: " ++ T.unpack s) - _ -> Left "expected an integer" -coerceValue TStr v = case v of - A.String s -> Right (VStr (T.unpack s)) - _ -> Left "expected a string" -coerceValue TBool v = case v of - A.Bool b -> Right (VBool b) - A.String s -> case T.toLower (T.strip s) of - "true" -> Right (VBool True) - "false" -> Right (VBool False) - _ -> Left ("expected true/false, got: " ++ T.unpack s) - _ -> Left "expected true/false" + Right i -> M.coerceValue t (VInt (fromIntegral i)) + Left d -> M.coerceValue t (VFloat d) + A.String s + | integerType t -> case readMaybe (T.unpack (T.strip s)) :: Maybe Int of + Just i -> M.coerceValue t (VInt i) + Nothing -> Left ("expected an integer, got: " ++ T.unpack s) + | numericType t -> case readMaybe (T.unpack (T.strip s)) :: Maybe Double of + Just d -> M.coerceValue t (VFloat d) + Nothing -> Left ("expected a number, got: " ++ T.unpack s) + | t == CBool -> case T.toLower (T.strip s) of + "true" -> Right (VBool True) + "false" -> Right (VBool False) + _ -> Left ("expected true/false, got: " ++ T.unpack s) + | otherwise -> M.coerceValue t (VStr (T.unpack s)) + _ -> Left "unsupported value" + where + t = columnType col + +-- | 构造 CREATE DATABASE +createDatabaseSql :: Text -> Either String String +createDatabaseSql name = do + db <- checkIdent "database" name + pure ("CREATE DATABASE " ++ T.unpack db) + +-- | 构造 DROP DATABASE +dropDatabaseSql :: Text -> Either String String +dropDatabaseSql name = do + db <- checkIdent "database" name + pure ("DROP DATABASE " ++ T.unpack db) -- | 构造 CREATE TABLE 语句 createTableSql :: CreateTableSpec -> Either String String @@ -235,3 +248,38 @@ orderClause Nothing = pure "" orderClause (Just (column, ascending)) = do col <- checkIdent "column" column pure (" ORDER BY " ++ T.unpack col ++ (if ascending then " ASC" else " DESC")) + +-- | 账号名:与普通标识符同一套规则,另允许点与短横线(写成字符串形式) +accountName :: Text -> Either String Text +accountName name + | T.null name || T.length name > 64 = Left "account name is empty or too long" + | not (T.all allowed name) = Left ("account name may only contain ASCII letters, digits, dot, dash or underscore: " ++ T.unpack name) + | otherwise = Right name + where + allowed c = isAsciiLower c || isAsciiUpper c || isDigit c || c `elem` ("_.-" :: String) + +-- | 单引号字符串字面量 +quoted :: Text -> String +quoted value = "'" ++ escapeStringLiteral (T.unpack value) ++ "'" + +-- | 构造 CREATE USER(口令是明文,账号服务负责加盐派生) +createUserSql :: Text -> Text -> Either String String +createUserSql name password = do + user <- accountName name + if T.null password || T.length password > 256 + then Left "password must not be empty or longer than 256 characters" + else pure ("CREATE USER " ++ T.unpack user ++ " IDENTIFIED BY " ++ quoted password) + +-- | 构造 ALTER USER(改一个普通账号的口令) +alterUserSql :: Text -> Text -> Either String String +alterUserSql name password = do + user <- accountName name + if T.null password || T.length password > 256 + then Left "password must not be empty or longer than 256 characters" + else pure ("ALTER USER " ++ T.unpack user ++ " IDENTIFIED BY " ++ quoted password) + +-- | 构造 DROP USER +dropUserSql :: Text -> Either String String +dropUserSql name = do + user <- accountName name + pure ("DROP USER " ++ T.unpack user) diff --git a/chusql-web/src/ChuSQL/Web/Api.hs b/chusql-web/src/ChuSQL/Web/Api.hs index de38174..9ee7e9c 100644 --- a/chusql-web/src/ChuSQL/Web/Api.hs +++ b/chusql-web/src/ChuSQL/Web/Api.hs @@ -1,33 +1,42 @@ {-# LANGUAGE OverloadedStrings #-} -module ChuSQL.Web.Api ( +module ChuSQL.Web.API ( AppEnv (..), Live (..), defaultLive, setLive, readLive, applyLiveKeys, + configurePasswordPolicy, webApp, sessionCookieName, parseCookieHeader, isIdentifier, renderRow, tableInfoJson, + systemTableInfo, apiErrorJson, newAppEnv, + newAppEnvAt, ) where -import ChuSQL.Model (Column (..), Row, Value (..)) -import ChuSQL.Storage.IPC (SchemaColumn (..), TableInfo (..)) +import ChuSQL.Model (Row, Value (..)) +import ChuSQL.Semantic (check) +import ChuSQL.Storage.IPC (Account (..), SchemaColumn (..), TableInfo (..)) +import ChuSQL.Syntax.AST (Statement (..)) +import ChuSQL.Syntax.Parser (parseStatement) +import ChuSQL.Web.Accounts import ChuSQL.Web.Actions ( ColumnSpec (..), CreateTableSpec (..), coerceValue, columnTypeOf, + createDatabaseSql, createIndexSql, createTableSql, deleteRowSql, dropColumnSql, + dropDatabaseSql, dropIndexSql, dropTableSql, insertRowSql, @@ -39,38 +48,46 @@ import ChuSQL.Web.Auth ( Credential (..), SessionPolicy (..), SessionStore, - createSession, - deleteOtherSessions, deleteSession, - hashLooksValid, - hashPassword, - lookupSession, setSessionPolicy, sessionToken, - verifyPassword, ) -import ChuSQL.Web.Backend (Backend, StatementResult (..), beCatalog, bePing, beStatement) +import ChuSQL.Web.Backend (Backend, StatementResult (..), beCatalog, bePing, beStatement, beWithDatabase, beDatabases, statementNeedsDatabase) import ChuSQL.Web.Demo (SeedReport (..), seedDemo) +import ChuSQL.Web.Privileges ( + Grant (..), + PrivilegeCommand (..), + PrivilegeError (..), + Privileges, + RoleView (..), + authorize, + authorizeTables, + filterTables, + listRoleViews, + newPrivileges, + privilegeCommand, + runPrivilegeCommand, + ) import ChuSQL.Web.RateLimit (RateLimiter, rateLimitBlock, rateLimitClear, rateLimitRecord, setRateLimit) import ChuSQL.Web.Secure (bodyLimitDynamic, sameOriginOnly, securityHeaders, contentSecurityPolicy) import ChuSQL.Web.Settings ( SettingItem (..), applySettings, defaultOf, + isLockedSetting, liveKeys, readSettingsFile, - resolveSettingsFile, settingCatalogue, - settingsFileCandidates, writeSettingsFile, ) import ChuSQL.Web.Static (contentTypeOf, readStatic, safeRelative) -import ChuSQL.Web.UiSettings ( - readUiSettings, - resolveUiSettingsFile, +import ChuSQL.Web.TOML (defaultConfigFile) +import ChuSQL.Web.UISettings ( + readUISettings, + resolveUISettingsFile, uiSettingsFileCandidates, - validateUiSettings, - writeUiSettings, + validateUISettings, + writeUISettings, ) import Control.Exception (IOException, try) import Data.Aeson (FromJSON (..), eitherDecode, object, withObject, (.:), (.=)) @@ -80,16 +97,17 @@ import qualified Data.Aeson.KeyMap as KM import qualified Data.ByteString.Lazy as BL import qualified Data.ByteString as BS import Data.IORef (IORef, newIORef, readIORef, writeIORef) +import Data.List (sortBy) import qualified Data.Map.Strict as Map import Data.Maybe (catMaybes, fromMaybe) +import Data.Scientific (fromFloatDigits) import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.Encoding as TE import qualified Data.Text.Lazy as TL import qualified Data.Vector as V -import Network.HTTP.Types (status204, status400, status401, status403, status404, status413, status429, status500) +import Network.HTTP.Types (status204, status400, status401, status403, status404, status409, status413, status429, status500) import Network.Wai (Application) -import System.Environment (lookupEnv) import Text.Read (readMaybe) import Web.Scotty @@ -126,14 +144,16 @@ defaultLive = data AppEnv = AppEnv { aeBackend :: Backend , aeSessions :: SessionStore - , aeCredential :: IORef Credential + , aeAccounts :: Accounts + , aePrivileges :: Privileges , aeLimiter :: RateLimiter , aeStaticDir :: FilePath , aeCookieSecure :: Bool , aeLive :: IORef Live , aeSettingsFile :: FilePath - , aeUiSettingsFile :: FilePath + , aeUISettingsFile :: FilePath , aeEffective :: Map.Map Text Text + , aeDatabase :: Text , aeLog :: Text -> IO () } @@ -141,47 +161,77 @@ data AppEnv = AppEnv sessionCookieName :: Text sessionCookieName = "chusql_session" --- | 参数齐全地造一份环境(各项上限用内置默认,调用方按配置改) +-- | 参数齐全地造一份环境(各项上限用内置默认,调用方按配置改);配置文件用固定路径 newAppEnv :: Backend -> SessionStore -> Credential -> RateLimiter -> FilePath -> IO AppEnv newAppEnv backend sessions cred limiter staticDir = do - credRef <- newIORef cred - liveRef <- newIORef defaultLive settingsFile <- resolveSettingsFileSafe - uiSettingsFile <- resolveUiSettingsFileSafe + newAppEnvAt settingsFile backend sessions cred limiter staticDir + +-- | 同上,但明说读写哪份配置文件(--config 走这里,保证读写的和启动读的是同一份) +newAppEnvAt :: FilePath -> Backend -> SessionStore -> Credential -> RateLimiter -> FilePath -> IO AppEnv +newAppEnvAt settingsFile backend sessions cred limiter staticDir = do + accounts <- newAccounts backend sessions cred + privileges <- newPrivileges backend + liveRef <- newIORef defaultLive + configurePasswordPolicy accounts settingsFile + uiSettingsFile <- resolveUISettingsFileSafe pure AppEnv { aeBackend = backend , aeSessions = sessions - , aeCredential = credRef + , aeAccounts = accounts + , aePrivileges = privileges , aeLimiter = limiter , aeStaticDir = staticDir , aeCookieSecure = False , aeLive = liveRef , aeSettingsFile = settingsFile - , aeUiSettingsFile = uiSettingsFile + , aeUISettingsFile = uiSettingsFile , aeEffective = Map.empty + , aeDatabase = "" , aeLog = const (pure ()) } --- | 定位设置文件,找不到就定下首个候选路径 +-- | 系统库:账号表住在这里,其中的表都是系统表,只有管理员能进。 +systemDatabase :: Text +systemDatabase = "system" + +-- | 保留库:只有系统库删不掉、也建不了。 +reservedDatabase :: Text -> Bool +reservedDatabase name = name == systemDatabase + +configurePasswordPolicy :: Accounts -> FilePath -> IO () +configurePasswordPolicy accounts path = do + saved <- readSettingsFile path + let values = Map.fromList [(key, Map.findWithDefault (defaultOf key) key saved) | key <- passwordKeys] + case applySettings Map.empty values of + Left err -> ioError (userError err) + Right valid -> setAccountsPolicy accounts (policyFromValues defaultPasswordPolicy valid) + +passwordKeys :: [Text] +passwordKeys = ["password-min-length", "password-classes"] + +policyFromValues :: PasswordPolicy -> Map.Map Text Text -> PasswordPolicy +policyFromValues before values = PasswordPolicy + (number "password-min-length" (ppMinLength before)) (number "password-classes" (ppClasses before)) + where + number key fallback = case Map.lookup key values of + Nothing -> fallback + Just value -> fromMaybe fallback (readMaybe (T.unpack (if T.null value then defaultOf key else value))) + +-- | 配置文件位置是固定的,这里只兜住「拿不到用户目录」的极端情况 resolveSettingsFileSafe :: IO FilePath resolveSettingsFileSafe = do - found <- try resolveSettingsFile :: IO (Either IOException FilePath) - pure (either (const fallbackSettingsFile) id found) - where - -- | 候选清单里的第一个 - fallbackSettingsFile = case settingsFileCandidates of - (p : _) -> p - [] -> "chusql.settings.json" + found <- try defaultConfigFile :: IO (Either IOException FilePath) + pure (either (const "chusql.toml") id found) -- | 定位 IDE 设置文件,找不到就定下首个候选路径 -resolveUiSettingsFileSafe :: IO FilePath -resolveUiSettingsFileSafe = do - found <- try resolveUiSettingsFile :: IO (Either IOException FilePath) - pure (either (const fallbackUiSettingsFile) id found) +resolveUISettingsFileSafe :: IO FilePath +resolveUISettingsFileSafe = do + found <- try resolveUISettingsFile :: IO (Either IOException FilePath) + pure (either (const fallbackUISettingsFile) id found) where - -- | 候选清单里的第一个 - fallbackUiSettingsFile = case uiSettingsFileCandidates of + fallbackUISettingsFile = case uiSettingsFileCandidates of (p : _) -> p [] -> "chusql.ui.settings.json" @@ -216,23 +266,86 @@ routes env = do get "/api/settings" (settingsH env) put "/api/settings" (putSettingsH env) get "/api/ui-settings" (uiSettingsH env) - put "/api/ui-settings" (putUiSettingsH env) - post "/api/account/password" (changePasswordH env) - get "/api/tables" (tablesH env) - post "/api/tables" (createTableH env) - delete "/api/tables/:t" (dropTableH env) - get "/api/tables/:t" (tableH env) - get "/api/tables/:t/rows" (rowsH env) - post "/api/tables/:t/rows" (insertRowH env) - patch "/api/tables/:t/rows/:id" (updateRowH env) - delete "/api/tables/:t/rows/:id" (deleteRowH env) - post "/api/tables/:t/indexes" (createIndexH env) - delete "/api/tables/:t/indexes/:col" (dropIndexH env) - delete "/api/tables/:t/columns/:col" (dropColumnH env) - post "/api/demo-data" (demoDataH env) - post "/api/query" (queryH env) + put "/api/ui-settings" (putUISettingsH env) + get "/api/databases" (databasesH env) + post "/api/databases" (withDatabase env createDatabaseH) + delete "/api/databases/:name" (withDatabase env dropDatabaseH) + get "/api/tables" (withDatabase env tablesH) + post "/api/tables" (withDatabase env createTableH) + delete "/api/tables/:t" (withDatabase env dropTableH) + get "/api/tables/:t" (withDatabase env tableH) + get "/api/tables/:t/rows" (withDatabase env rowsH) + post "/api/tables/:t/rows" (withDatabase env insertRowH) + patch "/api/tables/:t/rows/:id" (withDatabase env updateRowH) + delete "/api/tables/:t/rows/:id" (withDatabase env deleteRowH) + post "/api/tables/:t/indexes" (withDatabase env createIndexH) + delete "/api/tables/:t/indexes/:col" (withDatabase env dropIndexH) + delete "/api/tables/:t/columns/:col" (withDatabase env dropColumnH) + post "/api/demo-data" (withDatabase env demoDataH) + get "/api/roles" (withDatabase env rolesH) + post "/api/roles" (withDatabase env createRoleH) + delete "/api/roles/:name" (withDatabase env dropRoleH) + post "/api/roles/:name/grants" (withDatabase env grantRoleH) + delete "/api/roles/:name/grants" (withDatabase env revokeRoleH) + post "/api/roles/:name/members" (withDatabase env addRoleMemberH) + delete "/api/roles/:name/members/:user" (withDatabase env removeRoleMemberH) + post "/api/query" (withDatabase env queryH) notFound (notFoundH env) +-- | A request carries its own immutable database context; shared IPC never stores USE state. +-- 没带 `X-ChuSQL-Database` 就是"没选库":绑定空库,需要库的接口自己报 no database selected。 +withDatabase :: AppEnv -> (AppEnv -> ActionM ()) -> ActionM () +withDatabase env action = do + principal <- requirePrincipal env + chosen <- maybe "" (T.toLower . TL.toStrict) <$> header "X-ChuSQL-Database" + if T.null chosen + then action env{aeBackend = beWithDatabase (aeBackend env) "", aeDatabase = ""} + else + if not (isIdentifier chosen) || T.length chosen > 64 + then reply400 "bad_request" "invalid database name" + else + if chosen == systemDatabase && not (principalIsRoot principal) + then reply403 "the system database is only available to the administrator" + else action env {aeBackend = beWithDatabase (aeBackend env) (T.unpack chosen), aeDatabase = chosen} + +-- | 需要当前库的接口先过这一关(裸表名/建表都要先选库)。 +-- 走 accountError 而不是 reply400:它会 finish,否则 handler 还会往下跑并覆盖这个错误。 +requireDatabase :: AppEnv -> ActionM () +requireDatabase env = + if T.null (aeDatabase env) + then accountError (AccountError "no_database" "no database selected") + else pure () + +-- | 数据库清单;系统库 system 只给管理员看。 +databasesH :: AppEnv -> ActionM () +databasesH env = do + principal <- requirePrincipal env + result <- liftIO (beDatabases (aeBackend env)) + case result of + Left e -> engineError e + Right names -> json (if principalIsRoot principal then names else filter (/= T.unpack systemDatabase) names) + +-- | 新建数据库:名字过标识符白名单,真正建库交给存储层。 +createDatabaseH :: AppEnv -> ActionM () +createDatabaseH env = do + requireAdmin env + withJsonObject env $ \o -> case textField "name" o of + Left err -> reply400 "bad_request" err + Right name -> case createDatabaseSql name of + Left err -> reply400 "bad_request" (T.pack err) + Right sql -> runWrite env sql + +-- | 删除数据库;系统库 system 一律拒绝。 +dropDatabaseH :: AppEnv -> ActionM () +dropDatabaseH env = do + requireAdmin env + name <- pathParam "name" + if reservedDatabase name + then reply400 "bad_request" "the system database cannot be dropped" + else case dropDatabaseSql name of + Left err -> reply400 "bad_request" (T.pack err) + Right sql -> runWrite env sql + -- | 首页:登录与管理界面同一个 HTML indexH :: AppEnv -> ActionM () indexH env = do @@ -295,8 +408,9 @@ loginH env = do Right reqBody -> do let user = T.strip (lrUser reqBody) password = lrPassword reqBody - if T.null user || T.length user > 64 || T.null password || T.length password > 256 - then reply400 "bad_request" "user name or password is empty or too long" + -- 口令允许为空:配置里口令留空时管理员就是免密登录 + if T.null user || T.length user > 64 || T.length password > 256 + then reply400 "bad_request" "user name is empty or too long" else do blocked <- liftIO (rateLimitBlock (aeLimiter env) user) case blocked of @@ -305,18 +419,16 @@ loginH env = do status status429 json (apiErrorJson "too_many_attempts" "too many failed sign-in attempts, try again later") Nothing -> do - cred <- liftIO (readCredential env) live <- liftIO (readLive env) - let hashOk = verifyPassword (credEncoded cred) password - userOk = T.toLower user == T.toLower (credUser cred) - if userOk && hashOk - then do + result <- liftIO (authenticate (aeAccounts env) user password) + case result of + Right token -> do liftIO (rateLimitClear (aeLimiter env) user) - token <- liftIO (createSession (aeSessions env) (credUser cred)) setHeader "Set-Cookie" (TL.fromStrict (sessionCookie env token (Just (lvSessionMax live)))) - liftIO (aeLog env ("sign-in ok user=" <> credUser cred)) + liftIO (aeLog env ("sign-in ok user=" <> user)) status status204 - else do + Left err@(AccountError code _) | code /= "unauthorized" -> accountError err + Left _ -> do liftIO (rateLimitRecord (aeLimiter env) user) liftIO (aeLog env ("sign-in failed user=" <> user)) status status401 @@ -335,29 +447,29 @@ logoutH env = do -- | 当前登录的是谁 sessionH :: AppEnv -> ActionM () sessionH env = do - user <- requireUser env - json (object ["user" .= user]) + principal <- requirePrincipal env + policy <- liftIO (accountsPolicy (aeAccounts env)) + json (object ["user" .= principalName principal, "administrator" .= principalIsRoot principal, + "policy" .= object ["minLength" .= ppMinLength policy, "classes" .= ppClasses policy]]) -- | 读设置:文件值、启动生效值与内置默认一起给出 settingsH :: AppEnv -> ActionM () settingsH env = do - user <- requireUser env - cred <- liftIO (readCredential env) + principal <- requirePrincipal env fileValues <- liftIO (readSettingsFile (aeSettingsFile env)) - envSet <- liftIO (mapM (isSetEnv . siEnv) settingCatalogue) - let owner = sameAccount user (credUser cred) + let owner = principalIsRoot principal json ( object [ "file" .= aeSettingsFile env - , "account" .= credUser cred + , "account" .= principalName principal , "owner" .= owner - , "items" .= zipWith (itemJson (aeEffective env) fileValues owner) settingCatalogue envSet + , "items" .= map (itemJson (aeEffective env) fileValues owner) settingCatalogue ] ) -- | 一个配置项在界面上的样子 -itemJson :: Map.Map Text Text -> Map.Map Text Text -> Bool -> SettingItem -> Bool -> A.Value -itemJson effectiveValues fileValues owner spec envSet = +itemJson :: Map.Map Text Text -> Map.Map Text Text -> Bool -> SettingItem -> A.Value +itemJson effectiveValues fileValues owner spec = object [ "key" .= siKey spec , "label" .= siLabel spec @@ -367,7 +479,8 @@ itemJson effectiveValues fileValues owner spec envSet = , "value" .= shownValue , "source" .= source , "rootOnly" .= siRootOnly spec - , "editable" .= (owner || not (siRootOnly spec)) + , "editable" .= (not (isLockedSetting (siKey spec)) && (owner || not (siRootOnly spec))) + , "locked" .= isLockedSetting (siKey spec) , "restart" .= siRestart spec , "live" .= (siKey spec `elem` liveKeys) , "set" .= hasValue @@ -378,20 +491,13 @@ itemJson effectiveValues fileValues owner spec envSet = fromProcess = Map.lookup (siKey spec) effectiveValues effectiveValue = fromMaybe (defaultOf (siKey spec)) fromProcess shownValue = if secret then "" else fromMaybe effectiveValue fromFile - hasValue = fromFile /= Nothing || envSet + hasValue = fromFile /= Nothing source :: Text source | secret = if hasValue then "configured" else "not set" | fromFile /= Nothing = "settings file" - | envSet = "environment" | otherwise = "default" --- | 这个环境变量现在是设着的吗 -isSetEnv :: Text -> IO Bool -isSetEnv name = do - found <- lookupEnv (T.unpack name) - pure (maybe False (not . null) found) - -- | 改设置:写配置文件并让可热改项立即生效 putSettingsH :: AppEnv -> ActionM () putSettingsH env = do @@ -399,46 +505,50 @@ putSettingsH env = do withJsonObject env $ \o -> case settingsUpdates o of Left err -> reply400 "bad_request" err Right updates -> do - cred <- liftIO (readCredential env) - let forbidden = + principal <- requirePrincipal env + let locked = [k | k <- Map.keys updates, isLockedSetting k] + forbidden = [ k | k <- Map.keys updates , maybe False siRootOnly (findSetting k) - , not (sameAccount user (credUser cred)) + , not (principalIsRoot principal) ] - if not (null forbidden) - then reply403 ("only the configured account (" <> credUser cred <> ") may change: " <> T.intercalate ", " forbidden) - else do - current <- liftIO (readSettingsFile (aeSettingsFile env)) - case applySettings current updates of - Left err -> reply400 "bad_request" (T.pack err) - Right merged -> do - written <- liftIO (writeSettingsFile (aeSettingsFile env) merged) - case written of - Left err -> reply500 (T.pack err) - Right () -> do - applied <- liftIO (applyLiveKeys env updates) - let needsRestart = [k | k <- Map.keys updates, not (k `elem` applied), not (T.null (Map.findWithDefault "" k updates))] - liftIO (aeLog env ("settings updated by " <> user <> ": " <> T.intercalate ", " (Map.keys updates))) - json - ( object - [ "ok" .= True - , "file" .= aeSettingsFile env - , "applied" .= applied - , "restartRequired" .= needsRestart - ] - ) + if not (null locked) + then reply403 ("administrator credentials live in the settings file and cannot be changed here: " <> T.intercalate ", " locked) + else + if not (null forbidden) + then reply403 ("administrator required to change: " <> T.intercalate ", " forbidden) + else do + current <- liftIO (readSettingsFile (aeSettingsFile env)) + case applySettings current updates of + Left err -> reply400 "bad_request" (T.pack err) + Right merged -> do + written <- liftIO (writeSettingsFile (aeSettingsFile env) merged) + case written of + Left err -> reply500 (T.pack err) + Right () -> do + applied <- liftIO (applyLiveKeys env updates) + let needsRestart = [k | k <- Map.keys updates, not (k `elem` applied), not (T.null (Map.findWithDefault "" k updates))] + liftIO (aeLog env ("settings updated by " <> user <> ": " <> T.intercalate ", " (Map.keys updates))) + json + ( object + [ "ok" .= True + , "file" .= aeSettingsFile env + , "applied" .= applied + , "restartRequired" .= needsRestart + ] + ) -- | 读前端 IDE 设置(放在自己的文件里,与服务配置无关) uiSettingsH :: AppEnv -> ActionM () uiSettingsH env = do _ <- requireUser env - value <- liftIO (readUiSettings (aeUiSettingsFile env)) + value <- liftIO (readUISettings (aeUISettingsFile env)) json value -- | 整份覆盖前端 IDE 设置,校验不过一律 400 -putUiSettingsH :: AppEnv -> ActionM () -putUiSettingsH env = do +putUISettingsH :: AppEnv -> ActionM () +putUISettingsH env = do _ <- requireUser env rawBody <- body limit <- liftIO (liveBodyLimit env) @@ -446,63 +556,223 @@ putUiSettingsH env = do then reply413 else case eitherDecode rawBody of Left _ -> reply400 "bad_request" "request body is not valid JSON" - Right payload -> case validateUiSettings payload of + Right payload -> case validateUISettings payload of Left err -> reply400 "bad_request" (T.pack err) Right valid -> do - written <- liftIO (writeUiSettings (aeUiSettingsFile env) valid) + written <- liftIO (writeUISettings (aeUISettingsFile env) valid) case written of Left err -> reply500 (T.pack err) Right () -> status status204 --- | 改口令:验原口令,并踢掉其它会话 -changePasswordH :: AppEnv -> ActionM () -changePasswordH env = do - user <- requireUser env - token <- requestSessionToken - withJsonObject env $ \o -> do - cred <- liftIO (readCredential env) - case (textField "current" o, textField "next" o) of - (Left err, _) -> reply400 "bad_request" err - (_, Left err) -> reply400 "bad_request" err - (Right current, Right newPassword) - | not (sameAccount user (credUser cred)) -> reply403 "only the configured account can change its password" - | not (verifyPassword (credEncoded cred) current) -> reply400 "bad_password" "the current password is incorrect" - | T.length newPassword < minPasswordLength -> reply400 "bad_request" (T.pack ("the new password must be at least " ++ show minPasswordLength ++ " characters")) - | T.length newPassword > 256 -> reply400 "bad_request" "the new password is too long" - | newPassword == current -> reply400 "bad_request" "the new password must differ from the current one" - | otherwise -> do - encoded <- liftIO (hashPassword newPassword) - if not (hashLooksValid encoded) - then reply500 "could not hash the new password" - else do - currentFile <- liftIO (readSettingsFile (aeSettingsFile env)) - let merged = Map.insert "password-hash" encoded (Map.insert "password" "" currentFile) - written <- liftIO (writeSettingsFile (aeSettingsFile env) merged) - case written of - Left err -> reply500 (T.pack err) - Right () -> do - liftIO (writeCredential env cred{credEncoded = encoded}) - removed <- case token of - Nothing -> pure 0 - Just t -> liftIO (deleteOtherSessions (aeSessions env) t) - liftIO (aeLog env ("password changed by " <> user <> " (" <> T.pack (show removed) <> " other sessions signed out)")) - status status204 +-- | 账号表的名字:网页里像普通表一样浏览与编辑,但写入走账号命令 +accountTableName :: String +accountTableName = "__chusql_users" --- | 口令最短长度(太短的口令不值得存) -minPasswordLength :: Int -minPasswordLength = 8 +-- | 这张表是不是账号表 +isAccountTable :: Text -> Bool +isAccountTable name = T.toLower name == T.pack accountTableName --- | 两个账号名是不是同一个(大小写不敏感) -sameAccount :: Text -> Text -> Bool -sameAccount a b = T.toLower a == T.toLower b +-- | 账号表只住在系统库 system 里:在别的库上点名它一律 400。 +accountRoute :: AppEnv -> Text -> ActionM Bool +accountRoute env name + | not (isAccountTable name) = pure False + | aeDatabase env /= systemDatabase = do + reply400 "bad_request" "the account table lives in the system database" + finish + | otherwise = pure True --- | 现在用的凭据 -readCredential :: AppEnv -> IO Credential -readCredential = readIORef . aeCredential +-- | 口令列:密文只在服务端产生,界面不支持排序与筛选 +passwordColumn :: String +passwordColumn = "password" --- | 换掉进程里的凭据(改口令) -writeCredential :: AppEnv -> Credential -> IO () -writeCredential env = writeIORef (aeCredential env) +-- | 账号表的列:id 主键、用户名唯一索引、口令密文、注册时间与最近登录时间 +accountColumns :: [SchemaColumn] +accountColumns = + [ accountColumnSpec "id" "int" False True True False + , accountColumnSpec "user" "varchar(64)" False False False True + , accountColumnSpec passwordColumn "varchar(256)" False False False False + , accountColumnSpec "registered_at" "timestamp" False False False False + , accountColumnSpec "last_login_at" "timestamp" True False False False + ] + +-- | 账号表的列(约束由账号服务管) +accountColumnSpec :: String -> String -> Bool -> Bool -> Bool -> Bool -> SchemaColumn +accountColumnSpec name ty nullable autoIncrement primary unique = + SchemaColumn name ty nullable Nothing autoIncrement primary unique Nothing + +-- | 账号行:平铺的各列 +accountRowsOf :: [Account] -> [Row] +accountRowsOf = map accountRow + where + accountRow a = + [ ("id", VInt (fromIntegral (accountId a))) + , ("user", VStr (T.unpack (accountUser a))) + , (passwordColumn, VStr (T.unpack (accountHash a))) + , ("registered_at", VStr (T.unpack (accountRegisteredAt a))) + , ("last_login_at", maybe VNull (VStr . T.unpack) (accountLastLoginAt a)) + ] + +-- | 账号表的线上信息(列与普通表同构) +systemTableJson :: Int -> A.Value +systemTableJson count = + object + [ "table" .= (accountTableName :: String) + , "kind" .= ("system" :: Text) + , "rowCount" .= count + , "indexes" .= [object ["column" .= ("user" :: Text), "builtIn" .= False]] + , "columns" .= map accountColumnJson accountColumns + ] + where + accountColumnJson sc = + object + [ "name" .= scName sc + , "type" .= scType sc + , "primaryKey" .= scPrimaryKey sc + , "indexed" .= (scPrimaryKey sc || scUnique sc) + , "prime" .= (scPrimaryKey sc || scUnique sc) + , "unique" .= scUnique sc + , "nullable" .= scNullable sc + , "distinct" .= (-1 :: Int) + , "statsCapped" .= False + , "secret" .= (scName sc == passwordColumn) + ] + +-- | 读一遍账号表,读不到就 500 +requireAccounts :: AppEnv -> ActionM [Account] +requireAccounts env = do + result <- liftIO (listAccounts (aeAccounts env)) + either accountError pure result + +-- | 口令列不接受排序与筛选 +rejectSecretColumn :: Maybe (Text, Bool) -> [(Text, Value)] -> Either Text () +rejectSecretColumn order filters + | Just (col, _) <- order, col == T.pack passwordColumn = Left "the password column cannot be sorted" + | any ((== T.pack passwordColumn) . fst) filters = Left "the password column cannot be filtered" + | otherwise = Right () + +-- | 只有管理员能用账号表 +rootOnly :: Principal -> ActionM () +rootOnly principal = + if principalIsRoot principal + then pure () + else accountError (AccountError "forbidden" "administrator required") + +-- | 账号表的一页数据(排序与筛选都在内存里做) +baseRowsH :: AppEnv -> ActionM () +baseRowsH env = do + principal <- requirePrincipal env + rootOnly principal + live <- liftIO (readLive env) + limit <- intParam "limit" (lvPageSize live) 1 (lvMaxPageSize live) + offset <- intParam "offset" 0 0 1000000 + rawFilter <- (queryParamMaybe "filter" :: ActionM (Maybe Text)) + order <- sortParam systemTableInfo + case (order, parseFilters systemTableInfo rawFilter) of + (Left err, _) -> reply400 "bad_request" err + (_, Left err) -> reply400 "bad_request" err + (Right sortOrder, Right filters) -> case rejectSecretColumn sortOrder filters of + Left err -> reply400 "bad_request" err + Right () -> do + accounts <- requireAccounts env + let rows = filterAccountRows filters (sortAccountRows sortOrder (accountRowsOf accounts)) + page = take limit (drop offset rows) + cols = map scName accountColumns + (sortColumn, ascending) = splitSort sortOrder + json + ( object + [ "table" .= accountTableName + , "columns" .= cols + , "rows" .= map (renderRow cols) page + , "total" .= length rows + , "limit" .= limit + , "offset" .= offset + , "sort" .= maybe A.Null A.String sortColumn + , "dir" .= (if ascending then "asc" else "desc" :: Text) + , "filters" .= map fst filters + ] + ) + +-- | 账号表的结构(排序校验用) +systemTableInfo :: TableInfo +systemTableInfo = TableInfo accountTableName accountColumns 0 [] [] + +-- | 按用户名排序(其余列不支持排序) +sortAccountRows :: Maybe (Text, Bool) -> [Row] -> [Row] +sortAccountRows Nothing rows = rows +sortAccountRows (Just (column, ascending)) rows = + sortBy compareUser rows + where + compareUser a b = case (accountCell a column, accountCell b column) of + (Just x, Just y) -> if ascending then compare x y else compare y x + _ -> EQ + accountCell row name = case lookup (T.unpack name) row of + Just VNull -> Just "" + Just (VStr s) -> Just s + Just (VInt n) -> Just (show n) + Just (VFloat d) -> Just (show d) + _ -> Nothing + +-- | 按等值条件筛账号行 +filterAccountRows :: [(Text, Value)] -> [Row] -> [Row] +filterAccountRows [] rows = rows +filterAccountRows filters rows = [r | r <- rows, all (matches r) filters] + where + matches row (column, wanted) = case lookup (T.unpack column) row of + Just value -> value == wanted + Nothing -> False + +-- | 账号表写入:插一行 = CREATE USER +baseInsertH :: AppEnv -> ActionM () +baseInsertH env = do + principal <- requirePrincipal env + rootOnly principal + withJsonObject env $ \o -> case valuesField o >>= readRowValues False accountColumns of + Left err -> reply400 "bad_request" err + Right values -> case (lookupValue "user" values, lookupValue (T.pack passwordColumn) values) of + (Just (VStr user), Just (VStr password)) -> runAccountWrite env principal (CreateAccount (T.pack user) (T.pack password)) + _ -> reply400 "bad_request" "user and password are required" + +-- | 账号表写入:改一行 = ALTER USER(只改口令,不许改名) +baseUpdateH :: AppEnv -> Text -> ActionM () +baseUpdateH env target = do + principal <- requirePrincipal env + rootOnly principal + withJsonObject env $ \o -> case valuesField o >>= readRowValues False accountColumns of + Left err -> reply400 "bad_request" err + Right values -> case (values, lookupValue (T.pack passwordColumn) values) of + ([(_, VStr password)], Just _) -> runAccountWrite env principal (ResetAccountPassword target (T.pack password)) + _ -> reply400 "bad_request" "only the password of an existing account can be changed" + +-- | 账号表写入:删一行 = DROP USER +baseDeleteH :: AppEnv -> Text -> ActionM () +baseDeleteH env target = do + principal <- requirePrincipal env + rootOnly principal + runAccountWrite env principal (DropAccount target) + +-- | 账号写入:SQL 控制台与基表行接口共用的执行路径 +runAccountWrite :: AppEnv -> Principal -> AccountCommand -> ActionM () +runAccountWrite env principal command = do + result <- liftIO (runAccountCommand (aeAccounts env) principal command) + case result of + Left err -> accountError err + Right () -> json (object ["ok" .= True]) + +-- | 取一个列值 +lookupValue :: Text -> [(String, Value)] -> Maybe Value +lookupValue name values = lookup (T.unpack name) values + +accountError :: AccountError -> ActionM a +accountError (AccountError code message) = do + status $ case code of + "unauthorized" -> status401 + "forbidden" -> status403 + "not_found" -> status404 + "conflict" -> status409 + "storage_error" -> status500 + _ -> status400 + json (apiErrorJson code message) + finish -- | 按 key 找目录项 findSetting :: Text -> Maybe SettingItem @@ -513,6 +783,8 @@ findSetting key = case [i | i <- settingCatalogue, siKey i == key] of -- | 把能热改的设置应用到当前进程 applyLiveKeys :: AppEnv -> Map.Map Text Text -> IO [Text] applyLiveKeys env updates = do + beforePolicy <- accountsPolicy (aeAccounts env) + setAccountsPolicy (aeAccounts env) (policyFromValues beforePolicy updates) before <- readLive env let wanted = [k | k <- liveKeys, Map.member k updates] intOf key = case T.strip (Map.findWithDefault "" key updates) of @@ -545,75 +817,105 @@ effectiveValueOf effectiveValues key = fromMaybe (defaultOf key) (Map.lookup key intFromText :: Text -> Int intFromText given = fromMaybe 0 (readMaybe (T.unpack (T.strip given))) --- | 表清单(列 / 行数 / 索引 / 列统计) +-- | 表清单(列 / 行数 / 索引 / 列统计);普通身份只看得到自己有 SELECT 的表,管理员额外看到账号表 tablesH :: AppEnv -> ActionM () tablesH env = do - _ <- requireUser env + requireDatabase env + principal <- requirePrincipal env result <- liftIO (beCatalog (aeBackend env)) case result of Left e -> engineError e - Right infos -> json (map tableInfoJson infos) + Right infos -> do + readable <- readableTables env principal infos + extra <- if aeDatabase env == systemDatabase && principalIsRoot principal + then (: []) . systemTableJson . length <$> requireAccounts env + else pure [] + json (map tableInfoJson readable ++ extra) + +-- | 普通身份的表清单按 SELECT 权限过滤 +readableTables :: AppEnv -> Principal -> [TableInfo] -> ActionM [TableInfo] +readableTables env principal infos + | principalIsRoot principal = pure infos + | otherwise = do + result <- liftIO (filterTables (aePrivileges env) principal (aeDatabase env) (map (T.pack . tiTable) infos)) + case result of + Left err -> privilegeError err + Right allowed -> pure (filter (\info -> T.pack (tiTable info) `elem` allowed) infos) -- | 单表结构 tableH :: AppEnv -> ActionM () tableH env = do - _ <- requireUser env + requireDatabase env + principal <- requirePrincipal env name <- pathParam "t" - if not (isIdentifier name) - then reply400 "bad_request" "invalid table name" - else do - result <- liftIO (beCatalog (aeBackend env)) - case result of - Left e -> engineError e - Right infos -> case findTable name infos of - Nothing -> reply404 - Just info -> json (tableInfoJson info) + account <- accountRoute env name + if account + then do + rootOnly principal + accounts <- requireAccounts env + json (systemTableJson (length accounts)) + else + if not (isIdentifier name) + then reply400 "bad_request" "invalid table name" + else do + requirePrivilege env [(name, "select")] + result <- liftIO (beCatalog (aeBackend env)) + case result of + Left e -> engineError e + Right infos -> case findTable name infos of + Nothing -> reply404 + Just info -> json (tableInfoJson info) --- | 分页浏览一张表,支持排序与等值过滤 +-- | 分页浏览一张表,支持排序与等值过滤;账号表走内存里的账号行 rowsH :: AppEnv -> ActionM () rowsH env = do - _ <- requireUser env + requireDatabase env name <- pathParam "t" - if not (isIdentifier name) - then reply400 "bad_request" "invalid table name" + account <- accountRoute env name + if account + then baseRowsH env else do - catalogResult <- liftIO (beCatalog (aeBackend env)) - case catalogResult of - Left e -> engineError e - Right infos -> case findTable name infos of - Nothing -> reply404 - Just info -> do - live <- liftIO (readLive env) - limit <- intParam "limit" (lvPageSize live) 1 (lvMaxPageSize live) - offset <- intParam "offset" 0 0 1000000 - rawFilter <- (queryParamMaybe "filter" :: ActionM (Maybe Text)) - order <- sortParam info - case (order, parseFilters info rawFilter) of - (Left err, _) -> reply400 "bad_request" err - (_, Left err) -> reply400 "bad_request" err - (Right sortOrder, Right filters) -> case selectRowsSql name sortOrder filters of - Left err -> reply400 "bad_request" (T.pack err) - Right sql -> do - result <- liftIO (beStatement (aeBackend env) sql) - case result of - Left e -> engineError e - Right res -> do - let cols = map scName (tiColumns info) - page = take limit (drop offset (srRows res)) - (sortColumn, ascending) = splitSort sortOrder - json - ( object - [ "table" .= tiTable info - , "columns" .= cols - , "rows" .= map (renderRow cols) page - , "total" .= length (srRows res) - , "limit" .= limit - , "offset" .= offset - , "sort" .= maybe A.Null A.String sortColumn - , "dir" .= (if ascending then "asc" else "desc" :: Text) - , "filters" .= map fst filters - ] - ) + requirePrivilege env [(name, "select")] + if not (isIdentifier name) + then reply400 "bad_request" "invalid table name" + else do + catalogResult <- liftIO (beCatalog (aeBackend env)) + case catalogResult of + Left e -> engineError e + Right infos -> case findTable name infos of + Nothing -> reply404 + Just info -> do + live <- liftIO (readLive env) + limit <- intParam "limit" (lvPageSize live) 1 (lvMaxPageSize live) + offset <- intParam "offset" 0 0 1000000 + rawFilter <- (queryParamMaybe "filter" :: ActionM (Maybe Text)) + order <- sortParam info + case (order, parseFilters info rawFilter) of + (Left err, _) -> reply400 "bad_request" err + (_, Left err) -> reply400 "bad_request" err + (Right sortOrder, Right filters) -> case selectRowsSql name sortOrder filters of + Left err -> reply400 "bad_request" (T.pack err) + Right sql -> do + result <- liftIO (beStatement (aeBackend env) sql) + case result of + Left e -> engineError e + Right res -> do + let cols = map scName (tiColumns info) + page = take limit (drop offset (srRows res)) + (sortColumn, ascending) = splitSort sortOrder + json + ( object + [ "table" .= tiTable info + , "columns" .= cols + , "rows" .= map (renderRow cols) page + , "total" .= length (srRows res) + , "limit" .= limit + , "offset" .= offset + , "sort" .= maybe A.Null A.String sortColumn + , "dir" .= (if ascending then "asc" else "desc" :: Text) + , "filters" .= map fst filters + ] + ) data RowFilter = RowFilter Text Text @@ -657,7 +959,6 @@ sortParam info = do | column `notElem` map (T.pack . scName) (tiColumns info) -> Left ("unknown column: " <> column) | otherwise -> Right (Just (column, dirOf rawDir)) where - -- | 只认 asc/desc,否则算 asc dirOf given = case fmap (T.toLower . T.strip) given of Just "desc" -> False _ -> True @@ -665,104 +966,142 @@ sortParam info = do -- | 建表:按列结构拼 CREATE TABLE createTableH :: AppEnv -> ActionM () createTableH env = do - _ <- requireUser env + requireDatabase env + requireAdmin env withJsonObject env $ \o -> case decodeCreateTable o of Left err -> reply400 "bad_request" err Right spec -> case createTableSql spec of Left err -> reply400 "bad_request" (T.pack err) Right sql -> runWrite env sql --- | 删表 +-- | 删表;账号表不许删 dropTableH :: AppEnv -> ActionM () dropTableH env = do - _ <- requireUser env + requireDatabase env + requireAdmin env name <- pathParam "t" - case dropTableSql name of - Left err -> reply400 "bad_request" (T.pack err) - Right sql -> runWrite env sql + account <- accountRoute env name + if account + then reply400 "bad_request" "the account table cannot be dropped" + else case dropTableSql name of + Left err -> reply400 "bad_request" (T.pack err) + Right sql -> runWrite env sql --- | 插一行,所有列必须给全 +-- | 插一行,所有列必须给全;账号表上等于 CREATE USER insertRowH :: AppEnv -> ActionM () insertRowH env = do - _ <- requireUser env + requireDatabase env name <- pathParam "t" - info <- requireTable env name - withJsonObject env $ \o -> case valuesField o >>= readRowValues True (tiColumns info) of - Left err -> reply400 "bad_request" err - Right row -> case insertRowSql name row of - Left err -> reply400 "bad_request" (T.pack err) - Right sql -> runWrite env sql - --- | 改一行,只改给出来的列 -updateRowH :: AppEnv -> ActionM () -updateRowH env = do - _ <- requireUser env - name <- pathParam "t" - key <- rowIdParam - info <- requireTable env name - withJsonObject env $ \o -> case valuesField o >>= readRowValues False (tiColumns info) of - Left err -> reply400 "bad_request" err - Right assigns -> case updateRowSql name key assigns of - Left err -> reply400 "bad_request" (T.pack err) - Right sql -> runWrite env sql - --- | 删一行 -deleteRowH :: AppEnv -> ActionM () -deleteRowH env = do - _ <- requireUser env - name <- pathParam "t" - key <- rowIdParam - _ <- requireTable env name - case deleteRowSql name key of - Left err -> reply400 "bad_request" (T.pack err) - Right sql -> runWrite env sql - --- | 建索引,索引列必须整数且唯一 -createIndexH :: AppEnv -> ActionM () -createIndexH env = do - _ <- requireUser env - name <- pathParam "t" - info <- requireTable env name - withJsonObject env $ \o -> case textField "column" o of - Left err -> reply400 "bad_request" err - Right column -> case columnTypeOf (columnTypeText (tiColumns info) column) of - Nothing -> reply400 "bad_request" ("unknown column: " <> column) - Just ty - | ty /= TInt -> reply400 "bad_request" "only integer columns can be indexed" - | otherwise -> case createIndexSql name column of + account <- accountRoute env name + if account + then baseInsertH env + else do + requirePrivilege env [(name, "insert")] + info <- requireTable env name + withJsonObject env $ \o -> case valuesField o >>= readRowValues False (tiColumns info) of + Left err -> reply400 "bad_request" err + Right row -> case insertRowSql name row of Left err -> reply400 "bad_request" (T.pack err) Right sql -> runWrite env sql +-- | 改一行,只改给出来的列;账号表上等于 ALTER USER(只改口令) +updateRowH :: AppEnv -> ActionM () +updateRowH env = do + requireDatabase env + name <- pathParam "t" + account <- accountRoute env name + if account + then do + target <- pathParam "id" + baseUpdateH env target + else do + requirePrivilege env [(name, "update")] + key <- rowIdParam + info <- requireTable env name + withJsonObject env $ \o -> case valuesField o >>= readRowValues False (tiColumns info) of + Left err -> reply400 "bad_request" err + Right assigns -> case updateRowSql name key assigns of + Left err -> reply400 "bad_request" (T.pack err) + Right sql -> runWrite env sql + +-- | 删一行;账号表上等于 DROP USER +deleteRowH :: AppEnv -> ActionM () +deleteRowH env = do + requireDatabase env + name <- pathParam "t" + account <- accountRoute env name + if account + then do + target <- pathParam "id" + baseDeleteH env target + else do + requirePrivilege env [(name, "delete")] + key <- rowIdParam + _ <- requireTable env name + case deleteRowSql name key of + Left err -> reply400 "bad_request" (T.pack err) + Right sql -> runWrite env sql + +-- | 建索引,索引列必须整数且唯一;账号表没有索引可建 +createIndexH :: AppEnv -> ActionM () +createIndexH env = do + requireDatabase env + requireAdmin env + name <- pathParam "t" + account <- accountRoute env name + if account + then reply400 "bad_request" "the account table has no indexes" + else do + info <- requireTable env name + withJsonObject env $ \o -> case textField "column" o of + Left err -> reply400 "bad_request" err + Right column -> case columnTypeOf (columnTypeText (tiColumns info) column) of + Nothing -> reply400 "bad_request" ("unknown column: " <> column) + Just _ -> case createIndexSql name column of + Left err -> reply400 "bad_request" (T.pack err) + Right sql -> runWrite env sql + -- | 删索引(内建索引不放行:存储层也会拒,这里先给一句清楚的话) dropIndexH :: AppEnv -> ActionM () dropIndexH env = do - _ <- requireUser env + requireDatabase env + requireAdmin env name <- pathParam "t" column <- pathParam "col" - _ <- requireTable env name - if column == T.pack builtInIndexColumn - then reply400 "bad_request" "the built-in id index cannot be dropped" - else case dropIndexSql name column of - Left err -> reply400 "bad_request" (T.pack err) - Right sql -> runWrite env sql + account <- accountRoute env name + if account + then reply400 "bad_request" "the account table has no indexes" + else do + _ <- requireTable env name + if column == T.pack builtInIndexColumn + then reply400 "bad_request" "the built-in id index cannot be dropped" + else case dropIndexSql name column of + Left err -> reply400 "bad_request" (T.pack err) + Right sql -> runWrite env sql --- | 删列,内建 id 列不放行 +-- | 删列,内建 id 列不放行;账号表的列是固定的 dropColumnH :: AppEnv -> ActionM () dropColumnH env = do - _ <- requireUser env + requireDatabase env + requireAdmin env name <- pathParam "t" column <- pathParam "col" - _ <- requireTable env name - if column == T.pack builtInIndexColumn - then reply400 "bad_request" "the built-in id column cannot be dropped" - else case dropColumnSql name column of - Left err -> reply400 "bad_request" (T.pack err) - Right sql -> runWrite env sql + account <- accountRoute env name + if account + then reply400 "bad_request" "the account table has no columns to drop" + else do + _ <- requireTable env name + if column == T.pack builtInIndexColumn + then reply400 "bad_request" "the built-in id column cannot be dropped" + else case dropColumnSql name column of + Left err -> reply400 "bad_request" (T.pack err) + Right sql -> runWrite env sql -- | 一键灌演示数据(缺什么补什么,可以反复点) demoDataH :: AppEnv -> ActionM () demoDataH env = do - _ <- requireUser env + requireDatabase env + requireAdmin env result <- liftIO (seedDemo (aeBackend env)) case result of Left e -> reply500 (T.pack e) @@ -776,6 +1115,116 @@ demoDataH env = do ] ) +-- | 角色总览:只有管理员能看 +rolesH :: AppEnv -> ActionM () +rolesH env = do + principal <- requirePrincipal env + rootOnly principal + result <- liftIO (listRoleViews (aePrivileges env)) + case result of + Left err -> privilegeError err + Right views -> json (map roleViewJson views) + +roleViewJson :: RoleView -> A.Value +roleViewJson view = + object + [ "name" .= roleName view + , "grants" .= map grantJson (roleGrants view) + , "members" .= roleMembers view + ] + +grantJson :: Grant -> A.Value +grantJson grant = + object + [ "privilege" .= grantPrivilege grant + , "object" .= grantObject grant + ] + +-- | 建角色 +createRoleH :: AppEnv -> ActionM () +createRoleH env = withJsonObject env $ \o -> case textField "name" o of + Left err -> reply400 "bad_request" err + Right name -> runPrivilegeWrite env (CreateRoleCommand name) + +-- | 删角色(连带清掉它的授权与成员) +dropRoleH :: AppEnv -> ActionM () +dropRoleH env = pathParam "name" >>= \name -> runPrivilegeWrite env (DropRoleCommand name) + +-- | 给角色加权限 +grantRoleH :: AppEnv -> ActionM () +grantRoleH env = do + role <- pathParam "name" + withJsonObject env $ \o -> case grantFields o of + Left err -> reply400 "bad_request" err + Right (privileges, objectName) -> runPrivilegeWrite env (GrantPrivilegesCommand privileges objectName role) + +-- | 收角色的权限 +revokeRoleH :: AppEnv -> ActionM () +revokeRoleH env = do + role <- pathParam "name" + withJsonObject env $ \o -> case grantFields o of + Left err -> reply400 "bad_request" err + Right (privileges, objectName) -> runPrivilegeWrite env (RevokePrivilegesCommand privileges objectName role) + +-- | 把用户加进角色 +addRoleMemberH :: AppEnv -> ActionM () +addRoleMemberH env = do + role <- pathParam "name" + withJsonObject env $ \o -> case memberFields o of + Left err -> reply400 "bad_request" err + Right users -> runPrivilegeWrite env (GrantRoleCommand role users) + +-- | 把用户移出角色 +removeRoleMemberH :: AppEnv -> ActionM () +removeRoleMemberH env = do + role <- pathParam "name" + user <- pathParam "user" + runPrivilegeWrite env (RevokeRoleCommand role [user]) + +-- | 权限(一条或一串)与对象 +grantFields :: A.Object -> Either Text ([Text], Text) +grantFields o = do + objectName <- textField "object" o + privileges <- case KM.lookup "privileges" o of + Just (A.Array xs) -> mapM stringValue (V.toList xs) + _ -> Left "missing or non-array field: privileges" + if null privileges then Left "at least one privilege is required" else pure (privileges, objectName) + +-- | 成员(一条或一串) +memberFields :: A.Object -> Either Text [Text] +memberFields o = case (KM.lookup "user" o, KM.lookup "users" o) of + (Just (A.String one), _) -> Right [one] + (_, Just (A.Array xs)) -> mapM stringValue (V.toList xs) + _ -> Left "missing or non-string field: user" + +stringValue :: A.Value -> Either Text Text +stringValue (A.String value) = Right value +stringValue _ = Left "values must be strings" + +-- | 角色命令:只有管理员能跑 +runPrivilegeWrite :: AppEnv -> PrivilegeCommand -> ActionM () +runPrivilegeWrite env command = do + principal <- requirePrincipal env + result <- liftIO (runPrivilegeCommand (aePrivileges env) principal (aeDatabase env) command) + case result of + Left err -> privilegeError err + Right () -> json (object ["ok" .= True]) + +-- | 权限错误与账号错误共用一套状态码映射 +privilegeError :: PrivilegeError -> ActionM a +privilegeError (PrivilegeError code message) = accountError (AccountError code message) + +-- | 一键接口的权限门:普通身份必须持有对应的 (表, 权限) +requirePrivilege :: AppEnv -> [(Text, Text)] -> ActionM () +requirePrivilege env needed = do + principal <- requirePrincipal env + allowed <- liftIO (authorizeTables (aePrivileges env) principal (aeDatabase env) needed) + either privilegeError pure allowed + +-- | 管理员专属操作(建库 / 建表 / 索引 / 列 / 演示数据) +requireAdmin :: AppEnv -> ActionM () +requireAdmin env = requirePrincipal env >>= rootOnly + -- | 写操作统一回执 runWrite :: AppEnv -> String -> ActionM () runWrite env sql = do @@ -889,10 +1338,11 @@ columnTypeText cols name = case [c | c <- cols, scName c == T.unpack name] of (c : _) -> T.pack (scType c) [] -> "" --- | 执行任意一条语句 +-- | 执行任意一条语句:管理语句走各自的服务,其余先过权限门 queryH :: AppEnv -> ActionM () queryH env = do - user <- requireUser env + principal <- requirePrincipal env + let user = principalName principal rawBody <- body live <- liftIO (readLive env) if BL.length rawBody > fromIntegral (lvBodyLimit live) @@ -908,33 +1358,92 @@ queryH env = do then reply413 else do liftIO (aeLog env ("query user=" <> user <> " sql=" <> sql)) - result <- liftIO (beStatement (aeBackend env) (T.unpack sql)) - case result of - Left e -> engineError e - Right res -> do - let shown = take (lvMaxRows live) (srRows res) - json - ( object - [ "columns" .= srColumns res - , "rows" .= map (renderRow (srColumns res)) shown - , "rowCount" .= length (srRows res) - , "truncated" .= (length (srRows res) > lvMaxRows live) - ] - ) + case parseStatement (T.unpack sql) of + Left _ -> executeSql env live sql + Right stmt -> case accountCommand stmt of + Just command -> runAccountSql env stmt command + Nothing -> case privilegeCommand stmt of + Just command -> runPrivilegeSql env stmt command + Nothing -> do + if T.null (aeDatabase env) && statementNeedsDatabase stmt + then reply400 "no_database" "no database selected" + else do + allowed <- liftIO (authorize (aePrivileges env) principal (aeDatabase env) stmt) + case allowed of + Left err -> privilegeError err + Right () -> executeSql env live sql + +-- | 把 SQL 交给引擎,结果按控制台口径回执 +executeSql :: AppEnv -> Live -> Text -> ActionM () +executeSql env live sql = do + result <- liftIO (beStatement (aeBackend env) (T.unpack sql)) + case result of + Left e -> engineError e + Right res -> do + let shown = take (lvMaxRows live) (srRows res) + json + ( object + [ "columns" .= srColumns res + , "rows" .= map (renderRow (srColumns res)) shown + , "rowCount" .= length (srRows res) + , "truncated" .= (length (srRows res) > lvMaxRows live) + , "database" .= (case parseStatement (T.unpack sql) of + Right (UseDatabase name) -> Just (T.toLower (T.pack name)) + _ -> Nothing :: Maybe Text) + ] + ) + +-- | 执行账号管理语句:只有管理员能跑 +runAccountSql :: AppEnv -> Statement -> AccountCommand -> ActionM () +runAccountSql env stmt command = case check [] stmt of + Left e -> engineError e + Right () -> do + principal <- requirePrincipal env + result <- liftIO (runAccountCommand (aeAccounts env) principal command) + case result of + Left err -> accountError err + Right () -> + json + ( object + [ "columns" .= ([] :: [Text]) + , "rows" .= ([] :: [A.Value]) + , "rowCount" .= (0 :: Int) + , "truncated" .= False + ] + ) + +-- | 执行角色与授权语句:只有管理员能跑 +runPrivilegeSql :: AppEnv -> Statement -> PrivilegeCommand -> ActionM () +runPrivilegeSql env stmt command = case check [] stmt of + Left e -> engineError e + Right () -> do + principal <- requirePrincipal env + result <- liftIO (runPrivilegeCommand (aePrivileges env) principal (aeDatabase env) command) + case result of + Left err -> privilegeError err + Right () -> + json + ( object + [ "columns" .= ([] :: [Text]) + , "rows" .= ([] :: [A.Value]) + , "rowCount" .= (0 :: Int) + , "truncated" .= False + ] + ) -- | 拿当前 Session 用户;没有就 401 requireUser :: AppEnv -> ActionM Text -requireUser env = do - token <- requestSessionToken - found <- case token of - Nothing -> pure Nothing - Just t -> liftIO (lookupSession (aeSessions env) t) - case found of - Just user -> pure user - Nothing -> do - status status401 - json (apiErrorJson "unauthorized" "sign in first") - finish +requireUser env = principalName <$> requirePrincipal env + +-- | 当前身份;没有会话就 401 +requirePrincipal :: AppEnv -> ActionM Principal +requirePrincipal env = do + token <- requireToken + found <- liftIO (currentPrincipal (aeAccounts env) token) + either accountError pure found + +requireToken :: ActionM Text +requireToken = requestSessionToken >>= maybe (accountError (AccountError "unauthorized" "sign in first")) pure -- | 请求里的会话令牌 requestSessionToken :: ActionM (Maybe Text) @@ -948,7 +1457,6 @@ parseCookieHeader name headerValue = case [v | part <- T.splitOn ";" headerValue (v : _) -> Just v [] -> Nothing where - -- | 这一对是不是 name=value,是且值非空就取出值 valueOf key part = let (k, rest) = T.breakOn "=" part value = T.strip (T.drop 1 rest) @@ -985,21 +1493,23 @@ renderRow cols row = [maybe A.Null valueJson (lookup c row) | c <- cols] -- | 一个值转 JSON valueJson :: Value -> A.Value +valueJson VNull = A.Null valueJson (VInt n) = A.Number (fromIntegral n) +valueJson (VFloat d) = A.Number (fromFloatDigits d) valueJson (VStr s) = A.String (T.pack s) valueJson (VBool b) = A.Bool b --- | 一张表的线上信息:列、行数、索引与统计 +-- | 一张表的线上信息:列、行数、索引与统计(普通表 kind = table) tableInfoJson :: TableInfo -> A.Value tableInfoJson info = object [ "table" .= tiTable info + , "kind" .= ("table" :: Text) , "rowCount" .= tiRows info , "indexes" .= [object ["column" .= c, "builtIn" .= (c == builtInIndexColumn)] | c <- tiIndexes info] , "columns" .= map columnJson (tiColumns info) ] where - -- | 一列的完整描述:角色与统计 columnJson sc = object [ "name" .= scName sc @@ -1009,10 +1519,16 @@ tableInfoJson info = , "prime" .= (isPrimaryKey || isIndexed) , "distinct" .= distinctCount , "statsCapped" .= statsCapped + , "secret" .= False + , "nullable" .= scNullable sc + , "default" .= fmap valueJson (scDefault sc) + , "autoIncrement" .= scAutoIncrement sc + , "unique" .= scUnique sc + , "check" .= scCheck sc ] where name = scName sc - isPrimaryKey = name == builtInIndexColumn && scType sc == "int" + isPrimaryKey = scPrimaryKey sc || (name == builtInIndexColumn && scType sc == "int") isIndexed = name `elem` tiIndexes info stat = [s | s@(n, _, _) <- tiStats info, n == name] distinctCount = case stat of diff --git a/chusql-web/src/ChuSQL/Web/Auth.hs b/chusql-web/src/ChuSQL/Web/Auth.hs index 8ba6d67..2d2fa11 100644 --- a/chusql-web/src/ChuSQL/Web/Auth.hs +++ b/chusql-web/src/ChuSQL/Web/Auth.hs @@ -14,9 +14,10 @@ module ChuSQL.Web.Auth ( sessionPolicy, setSessionPolicy, createSession, + createVersionedSession, + lookupVersionedSession, lookupSession, deleteSession, - deleteOtherSessions, sessionToken, ) where @@ -118,6 +119,7 @@ data Session = Session { sessUser :: Text , sessCreated :: UTCTime , sessSeen :: UTCTime + , sessRevision :: Maybe Integer } data SessionPolicy = SessionPolicy @@ -166,33 +168,31 @@ sessionToken = hexEncode <$> getRandomBytes 32 -- | 开一条会话,返回令牌 createSession :: SessionStore -> Text -> IO Text -createSession store user = do +createSession store user = createVersionedSession store user Nothing + +createVersionedSession :: SessionStore -> Text -> Maybe Integer -> IO Text +createVersionedSession store user revision = do token <- sessionToken now <- storeClock store pol <- sessionPolicy store modifyMVar_ (storeMap store) $ \m -> - pure (Map.insert token (Session user now now) (prune now pol m)) + pure (Map.insert token (Session user now now revision) (prune now pol m)) pure token -- | 查令牌并刷新最后活动时间 lookupSession :: SessionStore -> Text -> IO (Maybe Text) -lookupSession store token = do +lookupSession store token = fmap (fmap fst) (lookupVersionedSession store token) + +lookupVersionedSession :: SessionStore -> Text -> IO (Maybe (Text, Maybe Integer)) +lookupVersionedSession store token = do now <- storeClock store pol <- sessionPolicy store modifyMVar (storeMap store) $ \m -> do let kept = prune now pol m pure $ case Map.lookup token kept of Nothing -> (kept, Nothing) - Just s -> (Map.insert token s{sessSeen = now} kept, Just (sessUser s)) + Just s -> (Map.insert token s{sessSeen = now} kept, Just (sessUser s, sessRevision s)) -- | 删掉一条会话(退出登录) deleteSession :: SessionStore -> Text -> IO () deleteSession store token = modifyMVar_ (storeMap store) (pure . Map.delete token) - --- | 删掉除这条外的全部会话,返回条数 -deleteOtherSessions :: SessionStore -> Text -> IO Int -deleteOtherSessions store keep = do - removed <- modifyMVar (storeMap store) $ \m -> do - let kept = Map.filterWithKey (\k _ -> k == keep) m - pure (kept, Map.size m - Map.size kept) - pure removed diff --git a/chusql-web/src/ChuSQL/Web/Backend.hs b/chusql-web/src/ChuSQL/Web/Backend.hs index cf1ec57..1b7d11f 100644 --- a/chusql-web/src/ChuSQL/Web/Backend.hs +++ b/chusql-web/src/ChuSQL/Web/Backend.hs @@ -4,27 +4,45 @@ module ChuSQL.Web.Backend ( StatementResult (..), Backend (..), columnsFromStatement, + currentStamp, + exprTables, + inDatabase, + internalTable, + grantsTable, ipcBackend, memoryBackend, + membersTable, + rolesTable, + statementNeedsDatabase, + statementTables, + systemDatabaseName, tableInfoOf, + tableRefsOf, ) where import ChuSQL.Engine (runStatement, runStatementM) import ChuSQL.Model +import ChuSQL.Semantic (prepare) import ChuSQL.Storage (MonadStorage (schema)) import ChuSQL.Storage.IPC ( + Account (..), IPCStorage (..), Request (..), Response (..), SchemaColumn (..), TableInfo (..), - doListCatalog, sendRequest, ) import ChuSQL.Syntax.AST import ChuSQL.Syntax.Parser (parseStatement) import Control.Concurrent.MVar (MVar, modifyMVar, newMVar, readMVar, withMVar) import Data.List (nub) +import Data.Text (Text) +import Data.Time (getCurrentTime) +import Data.Time.Format (defaultTimeLocale, formatTime) +import qualified Data.Aeson as A +import qualified Data.ByteString.Lazy.Char8 as BL +import qualified Data.Text as T -- 后端:跑一条语句与取数据字典,分 IPC(真存储)和内存(测试)两套。 @@ -37,40 +55,52 @@ data Backend = Backend { beStatement :: String -> IO (Either String StatementResult) , beCatalog :: IO (Either String [TableInfo]) , bePing :: IO Bool + , beAccounts :: Request -> IO (Either String [Account]) + , beWithDatabase :: String -> Backend + , beDatabases :: IO (Either String [String]) } -- | 按给定列名取列,固定列顺序 columnsFromStatement :: Database -> Statement -> [String] -columnsFromStatement db stmt = case stmt of - Select cols from _ _ _ - | cols == [allColumns] -> wildcard from - | otherwise -> cols +columnsFromStatement db stmt = case prepare db stmt of + Right (Select cols _ _ _ _ _) -> cols + Right (SelectExpr items _ _ _ _ _) -> map fst items _ -> [] - where - -- | 展开 *:单表按列序,JOIN 左表在前 - wildcard (FromTable mAlias tbl) = case lookup tbl db of - Nothing -> [] - Just t -> [qualify mAlias c | c <- colNames t] - wildcard (FromJoin left mAlias tbl _) = wildcard left ++ wildcard (FromTable mAlias tbl) --- | 走命名管道的后端(真存储) +-- | 走本地端点的后端(真存储:Windows 具名管道 / Unix 套接字) ipcBackend :: IO Backend ipcBackend = do lock <- newMVar () - pure - Backend - { beStatement = \sql -> withMVar lock $ \_ -> runIpc sql + let backend current = Backend + { beStatement = \sql -> withMVar lock $ \_ -> runIpc current sql , beCatalog = withMVar lock $ \_ -> do - result <- doListCatalog - pure (either Left Right result) + response <- sendRequest (ReqInDatabase current ReqListCatalog) + pure $ case response of + RespCatalog infos -> Right infos + RespError err -> Left err + _ -> Left "unexpected catalog response" , bePing = withMVar lock $ \_ -> do resp <- sendRequest ReqPing pure (case resp of RespPong -> True; _ -> False) + , beAccounts = \req -> withMVar lock $ \_ -> do + response <- sendRequest req + pure $ case response of + RespAccounts accounts -> Right accounts + RespError err -> Left err + _ -> Left "unexpected account response" + , beWithDatabase = backend + , beDatabases = withMVar lock $ \_ -> do + response <- sendRequest (ReqDatabase "list_databases" "") + pure $ case response of + RespTables names -> Right names + RespError err -> Left err + _ -> Left "unexpected database response" } + -- 没有默认库:没选库时 current 是空串,任何请求都得先选库 + pure (backend "") where - -- | 解析并执行,空结果时补一次表头 - runIpc :: String -> IO (Either String StatementResult) - runIpc sql = case parseStatement sql of + runIpc :: String -> String -> IO (Either String StatementResult) + runIpc current sql = case fmap (inDatabase current) (parseStatement sql) of Left e -> pure (Left e) Right stmt -> do result <- runIPCStorage (runStatementM stmt) @@ -84,42 +114,287 @@ ipcBackend = do (r : _) -> pure (map fst r) pure (Right (StatementResult cols rows)) --- | 内存后端(测试):状态就是一个 `Database` -memoryBackend :: MVar Database -> Backend -memoryBackend ref = +-- | 内存后端(测试):状态就是一个 `Database`,名字是它代表的库 +memoryBackend :: String -> MVar Database -> Backend +memoryBackend = memoryBackendWith False + +-- | 同一份内存状态可以按普通库(trusted=False)或系统库(trusted=True)视角跑: +-- 系统库要能读写角色/授权表,普通库连看都不许看。 +memoryBackendWith :: Bool -> String -> MVar Database -> Backend +memoryBackendWith trusted name ref = Backend - { beStatement = \sql -> modifyMVar ref $ \db -> case parseStatement sql of - Left e -> pure (db, Left e) - Right stmt -> case runStatement db stmt of + { beStatement = \sql -> case parseStatement sql of + Left e -> pure (Left e) + -- 内存夹具也有"库"的概念:USE 只能切到它有的两个名字(自己 + system), + -- 与 beDatabases 口径一致;引擎的内存存储不实现库管理,所以拦在这里。 + Right (UseDatabase target) + | target == name || target == systemDatabaseName -> pure (Right (StatementResult [] [])) + | otherwise -> pure (Left ("unknown database: " ++ target)) + Right stmt -> modifyMVar ref $ \db -> case runStatement (scoped db) stmt of Left e -> pure (db, Left e) - Right (db', rows) -> pure (db', Right (StatementResult (colsOf db stmt rows) rows)) + Right (db', rows) + | any (hidden . fst) db' -> pure (db, Left "reserved system table") + | otherwise -> pure (filter (hidden . fst) db ++ db', Right (StatementResult (colsOf (scoped db) stmt rows) rows)) , beCatalog = do db <- readMVar ref - pure (Right (map tableInfoOf db)) + pure (Right (map tableInfoOf (filter (not . internalTable . fst) db))) , bePing = pure True + , beAccounts = \req -> do + stamp <- currentStamp + modifyMVar ref $ \db -> case memoryAccounts db stamp req of + Left err -> pure (db, Left err) + Right accounts -> pure ((usersTable, Table usersTable [("account", TStr)] + [[("account", VStr (BL.unpack (A.encode a))) ] | a <- accounts]) : filter ((/= usersTable) . fst) db, Right accounts) + , beWithDatabase = withDatabase + , beDatabases = pure (Right (nub [name, systemDatabaseName])) } where - -- | 有行用行上的键,空结果回退语法树 + scoped = if trusted then filter (not . reserved . fst) else visible + hidden = if trusted then reserved else internalTable colsOf db stmt rows = case rows of [] -> columnsFromStatement db stmt (r : _) -> map fst r + -- 系统库视角给权限服务用;夹具自己的库走普通视角;没选库时只放行不碰表的语句 + withDatabase target + | target == systemDatabaseName = memoryBackendWith True name ref + | target == name = memoryBackendWith False name ref + | null target = noDatabaseBackend (memoryBackendWith False name ref) + | otherwise = unavailableDatabase ("unknown database: " ++ target) (memoryBackendWith False name ref) + +unavailableDatabase :: String -> Backend -> Backend +unavailableDatabase message backend = backend + { beStatement = \_ -> pure (Left message) + , beCatalog = pure (Left message) + } + +-- | 没选库时的视角:不碰表的语句(USE、建库、账号与角色)照跑, +-- 一旦语句里出现裸表名就报 no database selected。 +-- 与存储层口径一致:`db.table` 这种限定名不需要先选库。 +noDatabaseBackend :: Backend -> Backend +noDatabaseBackend backend = backend + { beStatement = \sql -> case parseStatement sql of + Left e -> pure (Left e) + Right stmt + | statementNeedsDatabase stmt -> pure (Left "no database selected") + | otherwise -> beStatement backend sql + , beCatalog = pure (Left "no database selected") + } + +-- | Database context is an immutable request value, never shared connection state. +-- 子查询里出现的表名同样要带上数据库前缀,否则会落到错误的库里。 +inDatabase :: String -> Statement -> Statement +inDatabase current = walk + where + walk :: Statement -> Statement + walk stmt = case stmt of + q@Select{} -> + q + { selectFrom = source (selectFrom q) + , selectWhere = fmap expr (selectWhere q) + } + q@SelectExpr{} -> + q + { selectItems = [(label, expr e) | (label, e) <- selectItems q] + , selectFrom = source (selectFrom q) + , selectWhere = fmap expr (selectWhere q) + } + Insert t cols rows -> Insert (table t) cols (map (map expr) rows) + Delete t cond -> Delete (table t) (fmap expr cond) + Update t assigns cond -> Update (table t) [(c, expr e) | (c, e) <- assigns] (fmap expr cond) + CreateTable t cols -> CreateTable (table t) cols + DropTable t -> DropTable (table t) + CreateIndex t c -> CreateIndex (table t) c + DropIndex t c -> DropIndex (table t) c + DropColumn t c -> DropColumn (table t) c + AddColumn t def -> AddColumn (table t) def + RenameColumn t old new -> RenameColumn (table t) old new + AlterColumnType t c ty -> AlterColumnType (table t) c ty + AlterColumnDefault t c v -> AlterColumnDefault (table t) c v + AlterColumnNull t c b -> AlterColumnNull (table t) c b + other -> other + + table t | '.' `elem` t || null current = t + | otherwise = current ++ "." ++ t + + source FromUnit = FromUnit + source (FromTable a t) = FromTable a (table t) + source (FromJoin k l a t e) = FromJoin k (source l) a (table t) (expr e) + + -- 表达式里的子查询也要一起改写 + expr (ScalarSub sq) = ScalarSub (sub sq) + expr (ExistsSub sq neg) = ExistsSub (sub sq) neg + expr (InSub a sq neg) = InSub (expr a) (sub sq) neg + expr (InList a items neg) = InList (expr a) (map expr items) neg + expr (Add a b) = Add (expr a) (expr b) + expr (Sub a b) = Sub (expr a) (expr b) + expr (Mul a b) = Mul (expr a) (expr b) + expr (Div a b) = Div (expr a) (expr b) + expr (Neg a) = Neg (expr a) + expr (Gt a b) = Gt (expr a) (expr b) + expr (Lt a b) = Lt (expr a) (expr b) + expr (Eq a b) = Eq (expr a) (expr b) + expr (And a b) = And (expr a) (expr b) + expr (Or a b) = Or (expr a) (expr b) + expr (IsNull a) = IsNull (expr a) + expr (IsNotNull a) = IsNotNull (expr a) + expr (CountOf a) = CountOf (expr a) + expr (SumOf a) = SumOf (expr a) + expr (AvgOf a) = AvgOf (expr a) + expr (MinOf a) = MinOf (expr a) + expr (MaxOf a) = MaxOf (expr a) + expr other = other + + sub sq = sq {subqueryStatement = walk (subqueryStatement sq)} + +-- | 语句里出现的表(含 JOIN、子查询、INSERT/UPDATE/DELETE 的目标) +statementTables :: Statement -> [Text] +statementTables stmt = nub (case stmt of + Select{} -> + tableRefsOf (selectFrom stmt) + ++ concatMap exprTables (maybe [] (: []) (selectWhere stmt)) + SelectExpr{} -> + tableRefsOf (selectFrom stmt) + ++ concatMap exprTables (maybe [] (: []) (selectWhere stmt) ++ map snd (selectItems stmt)) + Insert table _ rows -> T.pack table : concatMap exprTables (concat rows) + Update table assigns cond -> T.pack table : concatMap exprTables (map snd assigns ++ maybe [] (: []) cond) + Delete table cond -> T.pack table : concatMap exprTables (maybe [] (: []) cond) + CreateTable table _ -> [T.pack table] + DropTable table -> [T.pack table] + CreateIndex table _ -> [T.pack table] + DropIndex table _ -> [T.pack table] + DropColumn table _ -> [T.pack table] + AddColumn table _ -> [T.pack table] + RenameColumn table _ _ -> [T.pack table] + AlterColumnType table _ _ -> [T.pack table] + AlterColumnDefault table _ _ -> [T.pack table] + AlterColumnNull table _ _ -> [T.pack table] + _ -> []) + +-- | 语句需不需要一个当前库:出现的表里有没写库名的就必须要 +statementNeedsDatabase :: Statement -> Bool +statementNeedsDatabase stmt = any (not . T.any (== '.')) (statementTables stmt) + +-- | FROM 子句里的所有表 +tableRefsOf :: FromClause -> [Text] +tableRefsOf FromUnit = [] +tableRefsOf (FromTable _ name) = [T.pack name] +tableRefsOf (FromJoin _ left _ name cond) = tableRefsOf left ++ [T.pack name] ++ exprTables cond + +-- | 表达式里出现的表(子查询是唯一的来路) +exprTables :: Expr -> [Text] +exprTables expr = case expr of + ScalarSub sub -> statementTables (subqueryStatement sub) + ExistsSub sub _ -> statementTables (subqueryStatement sub) + InSub value sub _ -> exprTables value ++ statementTables (subqueryStatement sub) + InList value items _ -> concatMap exprTables (value : items) + Add a b -> both a b + Sub a b -> both a b + Mul a b -> both a b + Div 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 + Neg a -> exprTables a + IsNull a -> exprTables a + IsNotNull a -> exprTables a + CountOf a -> exprTables a + SumOf a -> exprTables a + AvgOf a -> exprTables a + MinOf a -> exprTables a + MaxOf a -> exprTables a + _ -> [] + where + both a b = exprTables a ++ exprTables b + -- | 一张表的线上信息(内存实现:没有索引,统计现算) tableInfoOf :: (String, Table) -> TableInfo tableInfoOf (name, tbl) = TableInfo { tiTable = name - , tiColumns = [SchemaColumn c (wireType ty) | (c, ty) <- tableCols tbl] + , tiColumns = [wireColumn c ty | (c, ty) <- tableCols tbl] , tiRows = length (tableRows tbl) , tiIndexes = [] , tiStats = [(c, distinctOf c, False) | (c, _) <- tableCols tbl] } where - -- | 某一列有几个不同值 distinctOf c = length (nub [v | r <- tableRows tbl, Just v <- [lookup c r]]) -- | 本地列类型转线上字符串(和 IPC 那边同一套写法) wireType :: Column -> String -wireType TInt = "int" -wireType TStr = "str" -wireType TBool = "bool" +wireType = typeName . columnType + +-- | 本地列转线上 schema +wireColumn :: String -> Column -> SchemaColumn +wireColumn name col = + SchemaColumn + { scName = name + , scType = wireType col + , scNullable = columnNullable col + , scDefault = columnDefault col + , scAutoIncrement = columnAutoIncrement col + , scPrimaryKey = columnPrimaryKey col + , scUnique = columnUnique col + , scCheck = columnCheck col + } + +-- | 系统库:账号、角色与授权表住在这里,其中的表都是系统表,只给管理员 +systemDatabaseName :: String +systemDatabaseName = "system" + +usersTable :: String +usersTable = "__chusql_users" + +-- | 角色与授权表:住在 system 库,名字不带 `__chusql_` 前缀(Rust 存储层拒收那个前缀) +rolesTable :: String +rolesTable = "sys_roles" + +grantsTable :: String +grantsTable = "sys_grants" + +membersTable :: String +membersTable = "sys_members" + +internalNames :: [String] +internalNames = [rolesTable, grantsTable, membersTable] + +reserved :: String -> Bool +reserved = T.isPrefixOf "__chusql_" . T.toLower . T.pack + +-- | 服务自己的内部表:目录里不出现,普通作用域读写都拒(系统作用域才放行角色/授权表) +internalTable :: String -> Bool +internalTable name = reserved name || T.unpack (T.toLower (T.pack name)) `elem` internalNames + +visible :: Database -> Database +visible = filter (not . internalTable . fst) + +-- | 当前 UTC 时间,写法与引擎的 timestamp 一致 +currentStamp :: IO T.Text +currentStamp = T.pack . formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" <$> getCurrentTime + +memoryAccounts :: Database -> T.Text -> Request -> Either String [Account] +memoryAccounts db stamp req = do + accounts <- case lookup usersTable db of + Nothing -> Right [] + Just table -> mapM decodeAccount (tableRows table) + case req of + ReqAccountsList -> Right accounts + ReqAccountCreate u h + | any ((== T.toLower u) . accountUser) accounts -> Left "account already exists" + | otherwise -> Right (accounts ++ [Account (1 + maximum (0 : map accountId accounts)) (T.toLower u) h 1 stamp Nothing]) + ReqAccountReset u h -> case filter ((== T.toLower u) . accountUser) accounts of + [_] -> Right [if accountUser x == T.toLower u then x{accountHash = h, accountRevision = accountRevision x + 1} else x | x <- accounts] + _ -> Left "unknown account" + ReqAccountLogin u at -> case filter ((== T.toLower u) . accountUser) accounts of + [_] -> Right [if accountUser x == T.toLower u then x{accountLastLoginAt = at} else x | x <- accounts] + _ -> Left "unknown account" + ReqAccountDrop u -> case filter ((== T.toLower u) . accountUser) accounts of + [] -> Left "unknown account" + _ -> Right [x | x <- accounts, accountUser x /= T.toLower u] + _ -> Left "invalid account request" + where + decodeAccount row = case lookup "account" row of + Just (VStr encoded) -> A.eitherDecode (BL.pack encoded) + _ -> Left "invalid account record" diff --git a/chusql-web/src/ChuSQL/Web/Config.hs b/chusql-web/src/ChuSQL/Web/Config.hs index f9aeb1a..f699003 100644 --- a/chusql-web/src/ChuSQL/Web/Config.hs +++ b/chusql-web/src/ChuSQL/Web/Config.hs @@ -6,22 +6,31 @@ module ChuSQL.Web.Config ( defaultUser, defaultPassword, usingDefaultCredentials, - loadWebConfig, + loadWebConfigAt, resolveCredential, resolveStaticDir, staticDirCandidates, + rootUserName, + canonicalSettingKeys, + resolvePipeName, ) where +import ChuSQL.Storage.IPC (defaultPipeName) import ChuSQL.Web.Auth (Credential (..), hashPassword) +import ChuSQL.Web.TOML (readSection) import Data.Char (toLower) +import qualified Data.Map.Strict as Map import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T import System.Directory (doesDirectoryExist) -import System.Environment (lookupEnv) import Text.Read (readMaybe) --- 运行参数:命令行 > 环境变量 > 内置默认;默认只听本机 127.0.0.1:7777。 +-- 运行参数:命令行 > 全局 chusql.toml 的分区 > 内置默认,不看环境变量。 +-- 配置文件位置是固定的(安装时定下,见 ChuSQL.Web.TOML.defaultConfigFile), +-- 想读别处的文件只能在命令行用 --config 指。 +-- 自己那段是 [web];管名与数据目录取自 [server] pipe_name / [storage] data_dir, +-- 因为它们是存储进程的事实,前端只当端点读。root 凭据只认 [web] 里的明文。 -- | 默认账号名 defaultUser :: Text @@ -36,8 +45,6 @@ data WebConfig = WebConfig , wcPort :: Int , wcStaticDir :: FilePath , wcUser :: Text - , wcPasswordHash :: Maybe Text - , wcPassword :: Maybe Text , wcStorageServer :: Maybe FilePath , wcPipeName :: Maybe String , wcDataDir :: Maybe FilePath @@ -62,9 +69,7 @@ defaultWebConfig = { wcHost = "127.0.0.1" , wcPort = 7777 , wcStaticDir = "static" - , wcUser = defaultUser - , wcPasswordHash = Nothing - , wcPassword = Nothing + , wcUser = "" , wcStorageServer = Nothing , wcPipeName = Nothing , wcDataDir = Nothing @@ -81,68 +86,114 @@ defaultWebConfig = , wcSeedDemo = False } --- | 这份配置用的还是内置默认口令吗 -usingDefaultCredentials :: WebConfig -> Bool -usingDefaultCredentials cfg = wcPasswordHash cfg == Nothing && wcPassword cfg == Nothing +-- | [web] 分区里的键统一成连字符写法(scripts/chusql.toml 用的是下划线) +-- 两种写法同时出现时,连字符(设置页写的规范形式)优先。 +canonicalSettingKeys :: Map.Map Text Text -> Map.Map Text Text +canonicalSettingKeys saved = + foldr (\(key, value) acc -> Map.insert (canonicalKey key) value acc) Map.empty (Map.toList saved) + where + canonicalKey key = fromMaybe key (lookup key keyAliases) --- | 备一份凭据:哈希 > 明文 > 内置默认 -resolveCredential :: WebConfig -> IO Credential -resolveCredential cfg = case (wcPasswordHash cfg, wcPassword cfg) of - (Just encoded, _) -> pure (Credential (wcUser cfg) encoded) - (Nothing, Just plain) -> do - encoded <- hashPassword plain - pure (Credential (wcUser cfg) encoded) - (Nothing, Nothing) -> do - encoded <- hashPassword defaultPassword - pure (Credential (wcUser cfg) encoded) +-- | 下划线写法到连字符写法的别名 +keyAliases :: [(Text, Text)] +keyAliases = + [ ("static_dir", "static-dir") + , ("session_idle", "session-idle") + , ("session_max", "session-max") + , ("login_max_attempts", "login-max-attempts") + , ("login_window", "login-window") + , ("body_limit", "body-limit") + , ("rows_per_page", "rows-per-page") + , ("max_page_size", "max-page-size") + , ("max_rows", "max-rows") + , ("max_sql_length", "max-sql-length") + , ("password_min_length", "password-min-length") + , ("password_classes", "password-classes") + , ("storage_server", "storage-server") + ] --- | 从环境变量读配置,缺省用内置默认 -loadWebConfig :: IO WebConfig -loadWebConfig = do - let d = defaultWebConfig - host <- envOr "CHUSQL_WEB_HOST" (wcHost d) - port <- envInt "CHUSQL_WEB_PORT" (wcPort d) - staticDir <- envOr "CHUSQL_WEB_STATIC" (wcStaticDir d) - user <- T.pack <$> envOr "CHUSQL_WEB_USER" (T.unpack (wcUser d)) - passwordHash <- envText "CHUSQL_WEB_PASSWORD_HASH" - password <- envText "CHUSQL_WEB_PASSWORD" - server <- envMaybe "CHUSQL_STORAGE_SERVER" - pipe <- envMaybe "CHUSQL_PIPE" - dataDir <- envMaybe "CHUSQL_DATA_DIR" - secure <- envBool "CHUSQL_WEB_COOKIE_SECURE" (wcCookieSecure d) - limit <- envInt "CHUSQL_WEB_BODY_LIMIT" (wcBodyLimit d) - idle <- envInt "CHUSQL_WEB_SESSION_IDLE" (wcSessionIdle d) - sessionMax <- envInt "CHUSQL_WEB_SESSION_MAX" (wcSessionMax d) - loginMax <- envInt "CHUSQL_WEB_LOGIN_MAX_ATTEMPTS" (wcLoginMaxAttempts d) - loginWindow <- envInt "CHUSQL_WEB_LOGIN_WINDOW" (wcLoginWindow d) - pageSize <- envInt "CHUSQL_WEB_PAGE_SIZE" (wcPageSize d) - maxPageSize <- envInt "CHUSQL_WEB_MAX_PAGE_SIZE" (wcMaxPageSize d) - maxRows <- envInt "CHUSQL_WEB_MAX_ROWS" (wcMaxRows d) - maxSqlLength <- envInt "CHUSQL_WEB_MAX_SQL_LENGTH" (wcMaxSqlLength d) - seedDemo <- envBool "CHUSQL_WEB_SEED" (wcSeedDemo d) - pure - WebConfig - { wcHost = host - , wcPort = port - , wcStaticDir = staticDir - , wcUser = user - , wcPasswordHash = passwordHash - , wcPassword = password - , wcStorageServer = server - , wcPipeName = pipe - , wcDataDir = dataDir - , wcCookieSecure = secure - , wcBodyLimit = limit - , wcSessionIdle = idle - , wcSessionMax = sessionMax - , wcLoginMaxAttempts = loginMax - , wcLoginWindow = loginWindow - , wcPageSize = pageSize - , wcMaxPageSize = maxPageSize - , wcMaxRows = maxRows - , wcMaxSqlLength = maxSqlLength - , wcSeedDemo = seedDemo - } +-- | 这份配置用的还是内置默认口令吗。 +-- 显式留空(password = "")是"只允许管理员免密登录",算已经配置过。 +usingDefaultCredentials :: Map.Map Text Text -> Bool +usingDefaultCredentials saved = Map.notMember "password" saved + +-- | 设置文件里某个键的非空值 +nonEmptyValue :: Text -> Map.Map Text Text -> Maybe Text +nonEmptyValue key saved = case T.strip <$> Map.lookup key saved of + Just value | not (T.null value) -> Just value + _ -> Nothing + +-- | root 凭据只认设置文件里的明文:给了明文就哈希它;显式留空就是免密(空编码串); +-- 没配这一项才回落到内置演示口令。 +resolveCredential :: WebConfig -> Map.Map Text Text -> IO Credential +resolveCredential cfg saved = do + encoded <- case Map.lookup "password" saved of + Just plain | not (T.null (T.strip plain)) -> hashPassword (T.strip plain) + Just _ -> pure "" + Nothing -> hashPassword defaultPassword + pure (Credential (rootUserName cfg saved) encoded) + +-- | 管理员名字:命令行 --user > 设置文件 user > 内置默认 +rootUserName :: WebConfig -> Map.Map Text Text -> Text +rootUserName cfg saved + | not (T.null (T.strip (wcUser cfg))) = T.strip (wcUser cfg) + | otherwise = fromMaybe defaultUser (nonEmptyValue "user" saved) + +-- | 读配置:内置默认 < [web] 分区(端点取自 [server] / [storage])。 +-- user / password 不在这里读:它们由 rootUserName / resolveCredential 直接看设置文件。 +loadWebConfigAt :: FilePath -> IO WebConfig +loadWebConfigAt path = do + raw <- readSection path "web" + server <- readSection path "server" + storage <- readSection path "storage" + pure (withEndpoint server storage (webConfigFromSection (canonicalSettingKeys raw))) + +-- | 端点:存储进程 bind 的管名与它用的数据目录,前端只读不改 +withEndpoint :: Map.Map Text Text -> Map.Map Text Text -> WebConfig -> WebConfig +withEndpoint server storage cfg = + cfg + { wcPipeName = T.unpack <$> nonEmptyValue "pipe_name" server + , wcDataDir = T.unpack <$> nonEmptyValue "data_dir" storage + } + +-- | 实际要连的管名:配置里没有就用与存储层一致的默认值 +resolvePipeName :: WebConfig -> String +resolvePipeName cfg = fromMaybe defaultPipeName (wcPipeName cfg) + +-- | [web] 分区里的启动参数,缺失或坏值一律回到内置默认 +webConfigFromSection :: Map.Map Text Text -> WebConfig +webConfigFromSection saved = + defaultWebConfig + { wcHost = T.unpack (sectionText "host" saved (T.pack (wcHost defaultWebConfig))) + , wcPort = sectionInt "port" saved (wcPort defaultWebConfig) + , wcStaticDir = T.unpack (sectionText "static-dir" saved (T.pack (wcStaticDir defaultWebConfig))) + , wcCookieSecure = sectionBool "cookie-secure" saved (wcCookieSecure defaultWebConfig) + , wcBodyLimit = sectionInt "body-limit" saved (wcBodyLimit defaultWebConfig) + , wcSessionIdle = sectionInt "session-idle" saved (wcSessionIdle defaultWebConfig) + , wcSessionMax = sectionInt "session-max" saved (wcSessionMax defaultWebConfig) + , wcLoginMaxAttempts = sectionInt "login-max-attempts" saved (wcLoginMaxAttempts defaultWebConfig) + , wcLoginWindow = sectionInt "login-window" saved (wcLoginWindow defaultWebConfig) + , wcPageSize = sectionInt "rows-per-page" saved (wcPageSize defaultWebConfig) + , wcMaxPageSize = sectionInt "max-page-size" saved (wcMaxPageSize defaultWebConfig) + , wcMaxRows = sectionInt "max-rows" saved (wcMaxRows defaultWebConfig) + , wcMaxSqlLength = sectionInt "max-sql-length" saved (wcMaxSqlLength defaultWebConfig) + , wcSeedDemo = sectionBool "seed" saved (wcSeedDemo defaultWebConfig) + , wcStorageServer = T.unpack <$> nonEmptyValue "storage-server" saved + } + +-- | 分区里一个键的非空字符串值 +sectionText :: Text -> Map.Map Text Text -> Text -> Text +sectionText key saved def = fromMaybe def (nonEmptyValue key saved) + +-- | 分区里的整数,解析不出来就用内置默认 +sectionInt :: Text -> Map.Map Text Text -> Int -> Int +sectionInt key saved def = fromMaybe def (nonEmptyValue key saved >>= readMaybe . T.unpack) + +-- | 分区里的布尔(1/true/yes/on 都算真) +sectionBool :: Text -> Map.Map Text Text -> Bool -> Bool +sectionBool key saved def = case nonEmptyValue key saved of + Nothing -> def + Just value -> map toLower (T.unpack value) `elem` ["1", "true", "yes", "on"] -- | 静态目录候选位置,按顺序取第一个存在的 staticDirCandidates :: FilePath -> [FilePath] @@ -157,31 +208,3 @@ resolveStaticDir given = go (staticDirCandidates given) go (d : ds) = do ok <- doesDirectoryExist d if ok then pure (Just d) else go ds - --- | 读一个字符串,缺省给默认值 -envOr :: String -> String -> IO String -envOr name def = fromMaybe def <$> lookupEnv name - --- | 读一个环境变量(空串当没设) -envMaybe :: String -> IO (Maybe String) -envMaybe name = do - v <- lookupEnv name - pure (case v of Just s | not (null s) -> Just s; _ -> Nothing) - --- | 读成 Text -envText :: String -> IO (Maybe Text) -envText name = fmap (fmap T.pack) (envMaybe name) - --- | 读成整数,解析不出来就用默认值 -envInt :: String -> Int -> IO Int -envInt name def = do - v <- envMaybe name - pure (fromMaybe def (v >>= \s -> readMaybe s)) - --- | 读成布尔(1/true/yes/on 都算真) -envBool :: String -> Bool -> IO Bool -envBool name def = do - v <- envMaybe name - pure $ case v of - Nothing -> def - Just s -> map toLower s `elem` ["1", "true", "yes", "on"] diff --git a/chusql-web/src/ChuSQL/Web/Demo.hs b/chusql-web/src/ChuSQL/Web/Demo.hs index 8bead61..2243cd3 100644 --- a/chusql-web/src/ChuSQL/Web/Demo.hs +++ b/chusql-web/src/ChuSQL/Web/Demo.hs @@ -106,7 +106,6 @@ seedDemo backend = do (const (SeedReport (map T.unpack missingNames) skipped (map (T.unpack . snd) wantedIndexes))) indexes where - -- | 逐条执行,遇到错就停 runAll :: [Either String String] -> IO (Either String ()) runAll [] = pure (Right ()) runAll (sql : rest) = case sql of @@ -117,7 +116,6 @@ seedDemo backend = do Left e -> pure (Left e) Right _ -> runAll rest - -- | 这张表上是否还没有这个索引 indexMissing :: [TableInfo] -> T.Text -> T.Text -> Bool indexMissing infos t c = case lookup (T.unpack t) [(tiTable i, tiIndexes i) | i <- infos] of Nothing -> True diff --git a/chusql-web/src/ChuSQL/Web/Privileges.hs b/chusql-web/src/ChuSQL/Web/Privileges.hs new file mode 100644 index 0000000..7fb81c4 --- /dev/null +++ b/chusql-web/src/ChuSQL/Web/Privileges.hs @@ -0,0 +1,396 @@ +{-# LANGUAGE OverloadedStrings #-} + +-- | 角色与权限服务。 +-- +-- 模型:权限挂在角色上,用户通过角色成员资格继承角色权限;管理员(root)隐式全权, +-- 新账号默认零权限。语句(CREATE ROLE / DROP ROLE / GRANT / REVOKE)由引擎解析并做 +-- 基本校验,真正落库在这里。 +-- +-- 存储:system 库里的三张普通表(`sys_roles` / `sys_grants` / `sys_members`)。 +-- 起普通名字是因为 Rust 存储层拒收 `__chusql_` 前缀的表名,那条路走不通; +-- 读写都借 system 作用域后端的普通 SQL,内存后端与 IPC 后端共用一套逻辑。 +module ChuSQL.Web.Privileges ( + Grant (..), + Membership (..), + PrivilegeCommand (..), + PrivilegeError (..), + Privileges, + RoleView (..), + authorize, + authorizeTables, + filterTables, + listRoleViews, + newPrivileges, + normalizeObject, + privilegeCommand, + runPrivilegeCommand, +) where + +import ChuSQL.Model (Row, Value (..)) +import ChuSQL.Syntax.AST +import ChuSQL.Web.Accounts (Principal, principalIsRoot, principalName) +import ChuSQL.Web.Actions (sqlLiteral) +import ChuSQL.Web.Backend ( + Backend (..), + StatementResult (..), + exprTables, + grantsTable, + membersTable, + rolesTable, + systemDatabaseName, + tableRefsOf, + ) +import Control.Concurrent.MVar (MVar, newMVar, withMVar) +import Data.List (intercalate, isInfixOf, nub, sort) +import Data.Text (Text) +import qualified Data.Text as T + +-- | 出错口径与账号服务一致,映射由 API 层统一做 +data PrivilegeError = PrivilegeError Text Text + deriving (Show, Eq) + +-- | 一条角色授权:角色 + 具体权限 + 对象(`*` 或 `库.表`) +data Grant = Grant + { grantRole :: Text + , grantPrivilege :: Text + , grantObject :: Text + } + deriving (Show, Eq, Ord) + +-- | 角色成员资格 +data Membership = Membership + { memberRole :: Text + , memberUser :: Text + } + deriving (Show, Eq, Ord) + +-- | 一个角色的全貌 +data RoleView = RoleView + { roleName :: Text + , roleGrants :: [Grant] + , roleMembers :: [Text] + } + deriving (Show, Eq) + +data PrivilegeCommand + = CreateRoleCommand Text + | DropRoleCommand Text + | GrantPrivilegesCommand [Text] Text Text + | RevokePrivilegesCommand [Text] Text Text + | GrantRoleCommand Text [Text] + | RevokeRoleCommand Text [Text] + deriving (Show, Eq) + +data Privileges = Privileges + { pvBackend :: Backend + , pvLock :: MVar () + } + +-- | 服务固定看 system 库;构造时不碰存储,第一次用才建表 +newPrivileges :: Backend -> IO Privileges +newPrivileges base = do + lock <- newMVar () + pure (Privileges (beWithDatabase base systemDatabaseName) lock) + +-- | 语句 → 管理命令(引擎只解析与基本校验,不认的语句给 Nothing) +privilegeCommand :: Statement -> Maybe PrivilegeCommand +privilegeCommand stmt = case stmt of + CreateRole name -> Just (CreateRoleCommand (T.pack name)) + DropRole name -> Just (DropRoleCommand (T.pack name)) + GrantPrivileges privs obj role -> + Just (GrantPrivilegesCommand (map T.toLower (map T.pack privs)) (T.pack obj) (T.pack role)) + RevokePrivileges privs obj role -> + Just (RevokePrivilegesCommand (map T.toLower (map T.pack privs)) (T.pack obj) (T.pack role)) + GrantRole role members -> Just (GrantRoleCommand (T.pack role) (map T.pack members)) + RevokeRole role members -> Just (RevokeRoleCommand (T.pack role) (map T.pack members)) + _ -> Nothing + +-- | 执行管理命令:只有管理员能跑,命令提到的角色必须已经存在 +runPrivilegeCommand :: Privileges -> Principal -> Text -> PrivilegeCommand -> IO (Either PrivilegeError ()) +runPrivilegeCommand service principal database command + | not (principalIsRoot principal) = pure (Left forbidden) + | needsDatabase = pure (Left (PrivilegeError "no_database" "no database selected")) + | otherwise = withTables service (applyCommand service database command) + where + -- 授权到具体对象(不是 `*`)时要把裸表名补成 `库.表`,没选库就无从补全 + needsDatabase = T.null database && case command of + GrantPrivilegesCommand _ object _ -> T.strip object /= "*" + RevokePrivilegesCommand _ object _ -> T.strip object /= "*" + _ -> False + +-- | 语句鉴权:管理员全通;普通身份按语句需要的每一项 (表, 权限) 查 +authorize :: Privileges -> Principal -> Text -> Statement -> IO (Either PrivilegeError ()) +authorize service principal database stmt + | principalIsRoot principal = pure (Right ()) + | otherwise = case requiredActions stmt of + Nothing -> pure (Left forbidden) + Just needed -> authorizeTables service principal database needed + +-- | 直接查一组 (表, 权限):一键接口用这个 +authorizeTables :: Privileges -> Principal -> Text -> [(Text, Text)] -> IO (Either PrivilegeError ()) +authorizeTables service principal database needed + | principalIsRoot principal = pure (Right ()) + | otherwise = withTables service $ do + grants <- grantsOfUser service (principalName principal) + pure (grants >>= \found -> checkAll database found needed) + +-- | 只留下用户有 SELECT 权限的表(管理员看全部);表清单接口用 +filterTables :: Privileges -> Principal -> Text -> [Text] -> IO (Either PrivilegeError [Text]) +filterTables service principal database tables + | principalIsRoot principal = pure (Right tables) + | otherwise = withTables service $ do + grants <- grantsOfUser service (principalName principal) + pure ((\found -> [table | table <- tables, checkAll database found [(table, "select")] == Right ()]) <$> grants) + +-- | 角色总览(REST 与测试用) +listRoleViews :: Privileges -> IO (Either PrivilegeError [RoleView]) +listRoleViews service = withTables service $ do + names <- roleNames service + case names of + Left err -> pure (Left err) + Right known -> sequence <$> mapM view (sort known) + where + view role = do + grants <- roleGrantsOf service role + members <- membersOfRole service role + pure (RoleView role <$> grants <*> members) + +-- | 授权对象落库口径:`*` 原样,裸表名按当前库补全 +normalizeObject :: Text -> Text -> Text +normalizeObject database object + | T.strip object == "*" = "*" + | T.any (== '.') object = T.toLower (T.strip object) + | otherwise = normalizeUser database <> "." <> normalizeUser object + +-- | 一条语句需要哪些 (表, 权限);Nothing 表示管理员专属 +requiredActions :: Statement -> Maybe [(Text, Text)] +requiredActions stmt = case stmt of + Select{} -> Just (selectNeeds (selectFrom stmt) (maybe [] (: []) (selectWhere stmt))) + SelectExpr{} -> + Just (selectNeeds (selectFrom stmt) (maybe [] (: []) (selectWhere stmt) ++ map snd (selectItems stmt))) + Insert table _ rows -> Just ((T.pack table, "insert") : selectsOf (concat rows)) + Update table assigns cond -> Just ((T.pack table, "update") : selectsOf (map snd assigns ++ maybe [] (: []) cond)) + Delete table cond -> Just ((T.pack table, "delete") : selectsOf (maybe [] (: []) cond)) + _ -> Nothing + where + selectNeeds source exprs = [(ref, "select") | ref <- nub (tableRefsOf source)] ++ selectsOf exprs + selectsOf exprs = [(ref, "select") | ref <- nub (concatMap exprTables exprs)] + +-- | 每项 (表, 权限) 都要被某条授权覆盖 +checkAll :: Text -> [Grant] -> [(Text, Text)] -> Either PrivilegeError () +checkAll database grants needed = case [pair | pair@(table, privilege) <- needed, not (covered table privilege)] of + [] -> Right () + ((table, privilege) : _) -> + Left (PrivilegeError "forbidden" ("permission denied: " <> T.toUpper privilege <> " ON " <> table)) + where + covered table privilege = + let target = normalizeObject database table + in any + (\grant -> (grantObject grant == "*" || grantObject grant == target) && grantPrivilege grant == privilege) + grants + +-- | 一个用户实际能用的授权:他所属角色身上的那些 +grantsOfUser :: Privileges -> Text -> IO (Either PrivilegeError [Grant]) +grantsOfUser service user = do + members <- readRows service membersTable + rows <- readRows service grantsTable + pure $ do + memberRows <- members + grantRows <- rows + let mine = [normalizeRole (cell "role" row) | row <- memberRows, normalizeUser (cell "member" row) == normalizeUser user] + pure [grantOf row | row <- grantRows, normalizeRole (cell "role" row) `elem` mine] + +-- | 一个角色挂着的授权 +roleGrantsOf :: Privileges -> Text -> IO (Either PrivilegeError [Grant]) +roleGrantsOf service role = do + rows <- readRows service grantsTable + pure $ fmap (map grantOf . filter (\row -> normalizeRole (cell "role" row) == normalizeRole role)) rows + +-- | 角色名清单(统一小写) +roleNames :: Privileges -> IO (Either PrivilegeError [Text]) +roleNames service = do + rows <- readRows service rolesTable + pure (map (normalizeRole . cell "name") <$> rows) + +-- | 一个角色的成员清单 +membersOfRole :: Privileges -> Text -> IO (Either PrivilegeError [Text]) +membersOfRole service role = do + rows <- readRows service membersTable + pure $ + fmap + (map (normalizeUser . cell "member") . filter (\row -> normalizeRole (cell "role" row) == normalizeRole role)) + rows + +-- | 角色必须存在,否则命令不落地 +requireRole :: Privileges -> Text -> IO (Either PrivilegeError ()) +requireRole service role = do + known <- roleNames service + pure $ case known of + Left err -> Left err + Right names + | normalizeRole role `elem` names -> Right () + | otherwise -> Left (unknownRole role) + +-- | 真正落库(表已经保证存在) +applyCommand :: Privileges -> Text -> PrivilegeCommand -> IO (Either PrivilegeError ()) +applyCommand service database command = case command of + CreateRoleCommand role -> do + known <- roleNames service + case known of + Left err -> pure (Left err) + Right names + | normalizeRole role `elem` names -> pure (Left (PrivilegeError "conflict" ("role already exists: " <> role))) + | otherwise -> exec service (insertSql rolesTable ["name"] [normalizeRole role]) + DropRoleCommand role -> do + allowed <- requireRole service role + case allowed of + Left err -> pure (Left err) + Right () -> do + first <- exec service ("DELETE FROM " ++ rolesTable ++ " WHERE name = " ++ roleLiteral role) + second <- exec service ("DELETE FROM " ++ grantsTable ++ " WHERE role = " ++ roleLiteral role) + third <- exec service ("DELETE FROM " ++ membersTable ++ " WHERE role = " ++ roleLiteral role) + pure (sequence_ [first, second, third]) + GrantPrivilegesCommand privileges object role -> do + allowed <- requireRole service role + case allowed of + Left err -> pure (Left err) + Right () -> each (expandPrivileges privileges) $ \privilege -> do + let target = normalizeObject database object + cleared <- exec service (deleteGrantSql role privilege target) + case cleared of + Left err -> pure (Left err) + Right () -> + exec service (insertSql grantsTable ["role", "privilege", "object"] [normalizeRole role, privilege, target]) + RevokePrivilegesCommand privileges object role -> do + allowed <- requireRole service role + case allowed of + Left err -> pure (Left err) + Right () -> + each (expandPrivileges privileges) $ \privilege -> + exec service (deleteGrantSql role privilege (normalizeObject database object)) + GrantRoleCommand role users -> do + allowed <- requireRole service role + case allowed of + Left err -> pure (Left err) + Right () -> + each users $ \user -> do + known <- membersOfRole service role + case known of + Left err -> pure (Left err) + Right members + | normalizeUser user `elem` members -> pure (Right ()) + | otherwise -> + exec service (insertSql membersTable ["role", "member"] [normalizeRole role, normalizeUser user]) + RevokeRoleCommand role users -> do + allowed <- requireRole service role + case allowed of + Left err -> pure (Left err) + Right () -> + each users $ \user -> + exec + service + ( "DELETE FROM " ++ membersTable ++ " WHERE role = " ++ roleLiteral role + ++ " AND member = " ++ literal (normalizeUser user) + ) + +-- | 建表只在第一次用时做;已经有了就算成功 +withTables :: Privileges -> IO (Either PrivilegeError a) -> IO (Either PrivilegeError a) +withTables service action = withMVar (pvLock service) $ \_ -> do + ready <- ensureTables service + case ready of + Left err -> pure (Left err) + Right () -> action + +ensureTables :: Privileges -> IO (Either PrivilegeError ()) +ensureTables service = each tableDefs (createTable service) + where + tableDefs = + [ (rolesTable, "(name VARCHAR(64))") + , (grantsTable, "(role VARCHAR(64), privilege VARCHAR(16), object VARCHAR(128))") + , (membersTable, "(role VARCHAR(64), member VARCHAR(64))") + ] + +createTable :: Privileges -> (String, String) -> IO (Either PrivilegeError ()) +createTable service (table, columns) = do + result <- beStatement (pvBackend service) ("CREATE TABLE " ++ table ++ " " ++ columns) + pure $ case result of + Right _ -> Right () + Left err + | "exists" `isInfixOf` err -> Right () + | otherwise -> Left (storageError err) + +-- | 跑一条内部写语句 +exec :: Privileges -> String -> IO (Either PrivilegeError ()) +exec service sql = do + result <- beStatement (pvBackend service) sql + pure $ case result of + Left err -> Left (storageError err) + Right _ -> Right () + +-- | 读一张内部表 +readRows :: Privileges -> String -> IO (Either PrivilegeError [Row]) +readRows service table = do + result <- beStatement (pvBackend service) ("SELECT * FROM " ++ table) + pure $ case result of + Left err -> Left (storageError err) + Right res -> Right (srRows res) + +-- | 行里取一列文本 +cell :: String -> Row -> Text +cell column row = case lookup column row of + Just (VStr value) -> T.pack value + _ -> "" + +grantOf :: Row -> Grant +grantOf row = + Grant + { grantRole = normalizeRole (cell "role" row) + , grantPrivilege = T.toLower (T.strip (cell "privilege" row)) + , grantObject = T.toLower (T.strip (cell "object" row)) + } + +deleteGrantSql :: Text -> Text -> Text -> String +deleteGrantSql role privilege object = + "DELETE FROM " ++ grantsTable ++ " WHERE role = " ++ roleLiteral role + ++ " AND privilege = " ++ literal privilege ++ " AND object = " ++ literal object + +insertSql :: String -> [String] -> [Text] -> String +insertSql table columns values = + "INSERT INTO " ++ table ++ " (" ++ intercalate ", " columns ++ ") VALUES (" ++ intercalate ", " (map literal values) ++ ")" + +-- | 授权里的 ALL 展开成四种具体权限(存具体权限,鉴权不做兜底) +expandPrivileges :: [Text] -> [Text] +expandPrivileges privileges = nub (concatMap expand (map (T.toLower . T.strip) privileges)) + where + expand "all" = ["select", "insert", "update", "delete"] + expand other = [other] + +-- | 一串动作挨着跑,遇到第一个错误就停 +each :: [a] -> (a -> IO (Either PrivilegeError ())) -> IO (Either PrivilegeError ()) +each [] _ = pure (Right ()) +each (item : rest) action = do + result <- action item + case result of + Left err -> pure (Left err) + Right () -> each rest action + +roleLiteral :: Text -> String +roleLiteral = literal . normalizeRole + +literal :: Text -> String +literal = sqlLiteral . VStr . T.unpack + +normalizeRole :: Text -> Text +normalizeRole = T.toLower . T.strip + +normalizeUser :: Text -> Text +normalizeUser = T.toLower . T.strip + +forbidden :: PrivilegeError +forbidden = PrivilegeError "forbidden" "administrator required" + +unknownRole :: Text -> PrivilegeError +unknownRole role = PrivilegeError "not_found" ("unknown role: " <> role) + +storageError :: String -> PrivilegeError +storageError message = PrivilegeError "storage_error" ("privilege storage: " <> T.pack message) diff --git a/chusql-web/src/ChuSQL/Web/Settings.hs b/chusql-web/src/ChuSQL/Web/Settings.hs index 03a5c4e..71f2f0d 100644 --- a/chusql-web/src/ChuSQL/Web/Settings.hs +++ b/chusql-web/src/ChuSQL/Web/Settings.hs @@ -5,9 +5,8 @@ module ChuSQL.Web.Settings ( settingCatalogue, findItem, isRootOnly, + isLockedSetting, isRestartRequired, - settingsFileCandidates, - resolveSettingsFile, readSettingsFile, writeSettingsFile, applySettings, @@ -16,72 +15,93 @@ module ChuSQL.Web.Settings ( effectiveSettings, ) where -import ChuSQL.Web.Config (WebConfig (..)) -import qualified Data.Aeson as A -import qualified Data.Aeson.Key as K -import qualified Data.Aeson.KeyMap as KM -import qualified Data.ByteString as BS -import qualified Data.ByteString.Lazy as BL +import ChuSQL.Web.Config (WebConfig (..), canonicalSettingKeys, defaultUser) +import ChuSQL.Web.TOML (readSection, writeSection) +import Data.List (nub) import qualified Data.Map.Strict as Map import Data.Text (Text) import qualified Data.Text as T -import Control.Exception (IOException, try) -import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist) -import System.FilePath (takeDirectory, ()) --- Web 配置项目录,与启动脚本共用一份设置文件,支持热改与重启生效。 +-- Web 配置项目录,与启动脚本共用一份全局 chusql.toml; +-- 每个键只写在它所属的那一段里,web 不再复制存储层的键。 data SettingItem = SettingItem { siKey :: Text , siLabel :: Text , siGroup :: Text , siKind :: Text - , siEnv :: Text , siDefault :: Text , siRootOnly :: Bool , siRestart :: Bool + , siSection :: Text + , siTomlKey :: Text } deriving (Show, Eq) -- | 全部可配置项(顺序即界面里的顺序) settingCatalogue :: [SettingItem] settingCatalogue = - [ item "port" "HTTP port" "server" "int" "CHUSQL_WEB_PORT" "7777" True True - , item "host" "Listen address" "server" "text" "CHUSQL_WEB_HOST" "127.0.0.1" True True - , item "static-dir" "Static directory" "server" "text" "CHUSQL_WEB_STATIC" "static" True True - , item "cookie-secure" "Secure cookie (behind TLS)" "server" "bool" "CHUSQL_WEB_COOKIE_SECURE" "0" True False - , item "body-limit" "Max request body (bytes)" "limits" "int" "CHUSQL_WEB_BODY_LIMIT" "65536" False False - , item "session-idle" "Session idle timeout (s)" "limits" "int" "CHUSQL_WEB_SESSION_IDLE" "28800" False False - , item "session-max" "Session max age (s)" "limits" "int" "CHUSQL_WEB_SESSION_MAX" "86400" False False - , item "login-max-attempts" "Failed logins before lockout" "limits" "int" "CHUSQL_WEB_LOGIN_MAX_ATTEMPTS" "5" False False - , item "login-window" "Lockout window (s)" "limits" "int" "CHUSQL_WEB_LOGIN_WINDOW" "300" False False - , item "rows-per-page" "Rows per page" "limits" "int" "CHUSQL_WEB_PAGE_SIZE" "25" False False - , item "max-page-size" "Rows per page ceiling" "limits" "int" "CHUSQL_WEB_MAX_PAGE_SIZE" "500" False False - , item "max-rows" "Rows per query" "limits" "int" "CHUSQL_WEB_MAX_ROWS" "1000" False False - , item "max-sql-length" "Max SQL characters" "limits" "int" "CHUSQL_WEB_MAX_SQL_LENGTH" "20000" False False - , item "user" "Account name" "auth" "text" "CHUSQL_WEB_USER" "root" True True - , item "password" "Password (plain, hashed at start)" "auth" "secret" "CHUSQL_WEB_PASSWORD" "" True True - , item "password-hash" "Password hash (pbkdf2)" "auth" "secret" "CHUSQL_WEB_PASSWORD_HASH" "" True True - , item "data-dir" "Data directory" "storage" "text" "CHUSQL_DATA_DIR" "" True True - , item "pipe-name" "Named pipe" "storage" "text" "CHUSQL_PIPE" "" True True - , item "storage-page-size" "Storage page size" "storage" "int" "CHUSQL_PAGE_SIZE" "4096" True True - , item "storage-btree-order" "Storage B+tree order" "storage" "int" "CHUSQL_BTREE_ORDER" "4" True True - , item "storage-buffer-pool" "Storage buffer pool pages" "storage" "int" "CHUSQL_BUFFER_POOL_SIZE" "1024" True True - , item "storage-log" "Storage log level" "storage" "text" "CHUSQL_LOG" "info" True True - , item "seed" "Seed demo data on start" "storage" "bool" "CHUSQL_WEB_SEED" "1" True True + [ item "port" "HTTP port" "server" "int" "7777" True True + , item "host" "Listen address" "server" "text" "127.0.0.1" True True + , item "static-dir" "Static directory" "server" "text" "static" True True + , item "cookie-secure" "Secure cookie (behind TLS)" "server" "bool" "0" True False + , item "body-limit" "Max request body (bytes)" "limits" "int" "65536" False False + , item "session-idle" "Session idle timeout (s)" "limits" "int" "28800" False False + , item "session-max" "Session max age (s)" "limits" "int" "86400" False False + , item "login-max-attempts" "Failed logins before lockout" "limits" "int" "5" False False + , item "login-window" "Lockout window (s)" "limits" "int" "300" False False + , item "rows-per-page" "Rows per page" "limits" "int" "25" False False + , item "max-page-size" "Rows per page ceiling" "limits" "int" "500" False False + , item "max-rows" "Rows per query" "limits" "int" "1000" False False + , item "max-sql-length" "Max SQL characters" "limits" "int" "20000" False False + , item "user" "管理员账号(设置文件)" "auth" "text" "root" True True + , item "password" "管理员口令(设置文件,明文)" "auth" "secret" "" True True + , item "password-min-length" "口令最短长度" "auth" "int" "12" True False + , item "password-classes" "口令字符类别数" "auth" "int" "2" True False + , owned "data-dir" "Data directory" "storage" "text" "" "storage" "data_dir" + , owned "pipe-name" "Endpoint name" "storage" "text" "" "server" "pipe_name" + , owned "storage-page-size" "Storage page size" "storage" "int" "4096" "page" "size" + , owned "storage-btree-order" "Storage B+tree order" "storage" "int" "4" "btree" "order" + , owned "storage-buffer-pool" "Storage buffer pool pages" "storage" "int" "1024" "buffer" "pool_size" + , owned "storage-log" "Storage log level" "storage" "text" "info" "log" "level" + , item "storage-server" "Storage server executable" "storage" "text" "" True True + , item "seed" "Seed demo data on start" "storage" "bool" "1" True True ] where - item k l g kind env def rootOnly restart = + -- web 自己的键:分区写死 [web],TOML 键名就是界面键名 + item k l g kind def rootOnly restart = SettingItem { siKey = k , siLabel = l , siGroup = g , siKind = kind - , siEnv = env , siDefault = def , siRootOnly = rootOnly , siRestart = restart + , siSection = webSection + , siTomlKey = k } + -- 存储层的键:只读自己那段 + owned k l g kind def section tomlKey = + SettingItem + { siKey = k + , siLabel = l + , siGroup = g + , siKind = kind + , siDefault = def + , siRootOnly = True + , siRestart = True + , siSection = section + , siTomlKey = tomlKey + } + +-- | web 自己那段 +webSection :: Text +webSection = "web" + +-- | 目录里涉及的分区(按出现顺序,去重) +catalogueSections :: [Text] +catalogueSections = nub [siSection i | i <- settingCatalogue] -- | 按 key 找一项 findItem :: Text -> Maybe SettingItem @@ -93,6 +113,14 @@ findItem key = case [i | i <- settingCatalogue, siKey i == key] of isRootOnly :: Text -> Bool isRootOnly key = maybe False siRootOnly (findItem key) +-- | 只读项:root 凭据只认设置文件里的明文,网页端不能改(改文件后重启生效) +lockedKeys :: [Text] +lockedKeys = ["user", "password"] + +-- | 这项在界面上是不是只读 +isLockedSetting :: Text -> Bool +isLockedSetting = (`elem` lockedKeys) + -- | 这项是不是要重启才生效 isRestartRequired :: Text -> Bool isRestartRequired key = maybe False siRestart (findItem key) @@ -100,7 +128,8 @@ isRestartRequired key = maybe False siRestart (findItem key) -- | 改完立刻生效的配置键 liveKeys :: [Text] liveKeys = - [ "body-limit" + [ "password-min-length", "password-classes" + , "body-limit" , "session-idle" , "session-max" , "login-max-attempts" @@ -111,87 +140,42 @@ liveKeys = , "max-sql-length" ] --- | 设置文件的候选位置 -settingsFileCandidates :: [FilePath] -settingsFileCandidates = - [ "script" "chusql.settings.json" - , ".." "script" "chusql.settings.json" - , "chusql.settings.json" - ] - --- | 找现成的设置文件,否则挑目录已存在的候选 -resolveSettingsFile :: IO FilePath -resolveSettingsFile = do - found <- firstExistingFile settingsFileCandidates - case found of - Just path -> pure path - Nothing -> do - ready <- firstExistingDir settingsFileCandidates - pure (maybe (headOr settingsFileCandidates "chusql.settings.json") id ready) - where - firstExistingFile [] = pure Nothing - firstExistingFile (p : ps) = do - ok <- doesFileExist p - if ok then pure (Just p) else firstExistingFile ps - - -- | 候选所在目录是否已存在 - firstExistingDir [] = pure Nothing - firstExistingDir (p : ps) = do - let dir = takeDirectory p - ok <- if null dir then pure True else doesDirectoryExist dir - if ok then pure (Just p) else firstExistingDir ps - --- | 候选列表里的第一个 -headOr :: [a] -> a -> a -headOr (x : _) _ = x -headOr [] d = d - --- | 读设置文件:键 -> 字符串值(文件不存在或坏掉都当空) +-- | 读目录里用到的每个分区,摊成界面键;只有 [web] 统一成连字符写法 readSettingsFile :: FilePath -> IO (Map.Map Text Text) -readSettingsFile path = do - exists <- doesFileExist path - if not exists - then pure Map.empty - else do - raw <- readFileUtf8 path - pure $ case A.eitherDecodeStrict' raw of - Left _ -> Map.empty - Right (A.Object o) -> Map.fromList [(K.toText k, jsonToText v) | (k, v) <- KM.toList o] - Right _ -> Map.empty +readSettingsFile path = fmap Map.unions (mapM readSectionOf catalogueSections) where - -- | JSON 值当字符串看 - jsonToText v = case v of - A.String s -> s - A.Number n -> T.pack (show n) - A.Bool b -> if b then "1" else "0" - _ -> "" + readSectionOf section = do + raw <- readSection path section + let values = if section == webSection then canonicalSettingKeys raw else raw + pure + ( Map.fromList + [ (siKey i, value) + | i <- settingCatalogue + , siSection i == section + , Just value <- [Map.lookup (siTomlKey i) values] + ] + ) --- | 读文件(UTF-8 字节读,避免依赖系统编码) -readFileUtf8 :: FilePath -> IO BS.ByteString -readFileUtf8 = BS.readFile - --- | 写设置文件,只写非空值 +-- | 按分区写回:每个键落进自己的段,别的分区与注释原样保留 writeSettingsFile :: FilePath -> Map.Map Text Text -> IO (Either String ()) -writeSettingsFile path values = do - let dir = takeDirectory path - prepared <- try (ensureDir dir) :: IO (Either IOException ()) - case prepared of - Left e -> pure (Left ("cannot create the settings directory " ++ dir ++ ": " ++ show e)) - Right () -> do - let encoded = - A.encode - ( A.Object - ( KM.fromList - [ (K.fromText k, A.String v) - | (k, v) <- Map.toList values - , not (T.null v) - ] - ) - ) - written <- try (BS.writeFile path (BL.toStrict encoded)) :: IO (Either IOException ()) - pure (either (Left . \e -> "cannot write " ++ path ++ ": " ++ show e) Right written) +writeSettingsFile path values = writeSections catalogueSections where - ensureDir dir = if null dir then pure () else createDirectoryIfMissing True dir + writeSections [] = pure (Right ()) + writeSections (section : rest) = do + let entries = + Map.fromList + [ (siTomlKey i, value) + | i <- settingCatalogue + , siSection i == section + , Just value <- [Map.lookup (siKey i) values] + ] + if Map.null entries + then writeSections rest + else do + written <- writeSection path section entries + case written of + Left err -> pure (Left err) + Right () -> writeSections rest -- | 把界面上传来的值合并进现有文件内容(只接受目录里有的键) applySettings :: Map.Map Text Text -> Map.Map Text Text -> Either String (Map.Map Text Text) @@ -209,6 +193,10 @@ applySettings current = go (Right current) . Map.toList checkKind :: SettingItem -> Text -> Either String () checkKind spec value | T.null (T.strip value) = Right () + | Just (lo, hi) <- lookup (siKey spec) [("password-min-length", (8, 256)), ("password-classes", (1, 4))] = + case reads (T.unpack (T.strip value)) :: [(Int, String)] of + [(n, "")] | n >= lo && n <= hi -> Right () + _ -> Left (T.unpack (siKey spec) ++ " is outside the permitted range") | siKind spec == "int" = case reads (T.unpack (T.strip value)) :: [(Int, String)] of [(_, "")] -> Right () _ -> Left (T.unpack (siKey spec) ++ " needs an integer, got: " ++ T.unpack value) @@ -238,7 +226,7 @@ effectiveSettings cfg = , ("max-page-size", tshow (wcMaxPageSize cfg)) , ("max-rows", tshow (wcMaxRows cfg)) , ("max-sql-length", tshow (wcMaxSqlLength cfg)) - , ("user", wcUser cfg) + , ("user", if T.null (T.strip (wcUser cfg)) then defaultUser else T.strip (wcUser cfg)) , ("seed", boolText (wcSeedDemo cfg)) , ("data-dir", maybe "" T.pack (wcDataDir cfg)) , ("pipe-name", maybe "" T.pack (wcPipeName cfg)) diff --git a/chusql-web/src/ChuSQL/Web/Static.hs b/chusql-web/src/ChuSQL/Web/Static.hs index 87bda62..3d672af 100644 --- a/chusql-web/src/ChuSQL/Web/Static.hs +++ b/chusql-web/src/ChuSQL/Web/Static.hs @@ -33,7 +33,6 @@ safeRelative t | map toLower (takeExtension (T.unpack t)) `notElem` allowedExtensions = Nothing | otherwise = Just (T.unpack t) where - -- | 只允许字母数字与 ._- badChar c = not (isAsciiLower c || isAsciiUpper c || isDigit c || c `elem` ("._-" :: String)) -- | 读一个静态文件;不存在(或是目录)给 Nothing diff --git a/chusql-web/src/ChuSQL/Web/StorageProcess.hs b/chusql-web/src/ChuSQL/Web/StorageProcess.hs index aae21ee..9f0ad4e 100644 --- a/chusql-web/src/ChuSQL/Web/StorageProcess.hs +++ b/chusql-web/src/ChuSQL/Web/StorageProcess.hs @@ -4,19 +4,22 @@ module ChuSQL.Web.StorageProcess ( StorageProcess (..), findStorageServer, startStorageProcess, + startStorageProcessAt, + platformBinaryName, + storageChildArgs, stopStorageProcess, waitForStorage, ) where -import ChuSQL.Storage.IPC (Request (..), Response (..), closeConnection, sendRequest) +import ChuSQL.Storage.IPC (Request (..), Response (..), closeConnection, sendRequest, setPipeName) +import ChuSQL.Web.Config (loadWebConfigAt, resolvePipeName) import Control.Concurrent (threadDelay) import Control.Exception (SomeException, try) import Control.Monad (filterM) -import Data.Time.Clock.POSIX (getPOSIXTime) -import System.Directory (createDirectoryIfMissing, doesFileExist, getTemporaryDirectory) -import System.Environment (getEnvironment, setEnv) +import System.Directory (doesFileExist) import System.Exit (ExitCode) import System.FilePath (()) +import System.Info (os) import System.IO (Handle) import System.Process ( CreateProcess (..), @@ -29,13 +32,21 @@ import System.Process ( ) -- 拉起并管理 Rust 存储进程,起来后等管道应答 ping 再继续。 +-- 调参、管名与数据目录都在全局 chusql.toml 里,孩子自己读;这里只负责起进程、 +-- 用 --config 把「读哪份配置文件」告诉它,并等就绪。 data StorageProcess = StorageProcess { spProcess :: ProcessHandle , spPipe :: String - , spDataDir :: FilePath } +-- | 可执行文件名跟平台走:Windows 带 .exe,其它平台不带。同一个 target 目录里可能 +-- 同时躺着一份别处交叉编译出来的 .exe(Windows PE),在 Linux 上误选它只会拿到 +-- config file not found(Windows 进程看不到 /tmp/... 这样的路径)。目标平台当参数传, +-- 两个分支都能被测试直接钉住。 +platformBinaryName :: String -> String -> FilePath +platformBinaryName targetOs stem = if targetOs == "mingw32" then stem ++ ".exe" else stem + -- | 找存储进程,没有就给路径提示 findStorageServer :: Maybe FilePath -> IO (Either String FilePath) findStorageServer (Just p) = do @@ -49,36 +60,37 @@ findStorageServer Nothing = do , "target" ] modes = ["release", "debug"] - exes = ["chusql-storage.exe", "chusql-storage"] + exes = [platformBinaryName os "chusql-storage"] candidates = [d m e | d <- dirs, m <- modes, e <- exes] found <- filterM doesFileExist candidates pure $ case found of (p : _) -> Right p [] -> Left "no Rust storage server found; run `cargo build --release` in chusql-storage, or pass --storage " --- | 起存储进程并等它就绪 -startStorageProcess :: FilePath -> IO (Either String StorageProcess) -startStorageProcess bin = do - stamp <- round . (* 1000000) <$> getPOSIXTime :: IO Integer - tmp <- getTemporaryDirectory - let pipe = "chusql-web-" ++ show stamp - dir = tmp pipe - createDirectoryIfMissing True dir - envs <- - childEnv - [ ("CHUSQL_PIPE", pipe) - , ("CHUSQL_DATA_DIR", dir) - ] + +-- | 按配置文件里的管名起存储进程 +startStorageProcess :: FilePath -> FilePath -> IO (Either String StorageProcess) +startStorageProcess bin configPath = do + cfg <- loadWebConfigAt configPath + startStorageProcessAt bin (resolvePipeName cfg) configPath + +-- | 给存储子进程的参数:配置文件位置是固定的,唯一的显式覆盖就是写进 --config +storageChildArgs :: FilePath -> [String] +storageChildArgs configPath = ["--config", configPath] + +-- | 用给定的管名起存储进程,并把本进程的端点也指到它;环境原样继承 +startStorageProcessAt :: FilePath -> String -> FilePath -> IO (Either String StorageProcess) +startStorageProcessAt bin pipe configPath = do + let child = (proc bin (storageChildArgs configPath)){std_out = NoStream, std_err = Inherit} started <- - try (createProcess (proc bin []){env = Just envs, std_out = NoStream, std_err = Inherit}) :: + try (createProcess child) :: IO (Either SomeException (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle)) case started of Left e -> pure (Left ("failed to start the storage process: " ++ show e)) Right (_, _, _, ph) -> do - setEnv "CHUSQL_PIPE" pipe - setEnv "CHUSQL_DATA_DIR" dir + setPipeName pipe ready <- waitForStorage (200 :: Int) if ready - then pure (Right (StorageProcess ph pipe dir)) + then pure (Right (StorageProcess ph pipe)) else do _ <- try (terminateProcess ph) :: IO (Either SomeException ()) pure (Left "storage process did not become ready within 5 seconds (no ping reply on the pipe)") @@ -99,21 +111,3 @@ waitForStorage n = do case r of Right RespPong -> pure True _ -> threadDelay 25000 >> waitForStorage (n - 1) - --- | 子进程环境:继承并覆盖两个专用变量 -childEnv :: [(String, String)] -> IO [(String, String)] -childEnv extra = do - base <- getEnvironment - let ours = ["CHUSQL_PIPE", "CHUSQL_DATA_DIR"] - kept = filter (\(k, _) -> k `notElem` ours) base - missing (k, _) = not (any ((== k) . fst) kept) - defaults = filter missing storageDefaults - pure (kept ++ defaults ++ extra) - --- | 存储进程的调参默认值(可以被用户环境里的同名变量覆盖) -storageDefaults :: [(String, String)] -storageDefaults = - [ ("CHUSQL_PAGE_SIZE", "4096") - , ("CHUSQL_BTREE_ORDER", "4") - , ("CHUSQL_LOG", "info") - ] diff --git a/chusql-web/src/ChuSQL/Web/TOML.hs b/chusql-web/src/ChuSQL/Web/TOML.hs new file mode 100644 index 0000000..49ddb6a --- /dev/null +++ b/chusql-web/src/ChuSQL/Web/TOML.hs @@ -0,0 +1,163 @@ +{-# LANGUAGE OverloadedStrings #-} + +module ChuSQL.Web.TOML + ( readSection + , writeSection + , defaultConfigFile + , resolveConfigPath + ) where + +import Control.Exception (IOException, try) +import qualified Data.ByteString as BS +import qualified Data.Map.Strict as Map +import Data.Map.Strict (Map) +import qualified Data.Set as Set +import Data.Text (Text) +import qualified Data.Text as T +import qualified Data.Text.Encoding as TE +import System.Directory (XdgDirectory (XdgConfig), createDirectoryIfMissing, doesFileExist, getXdgDirectory) +import System.FilePath (takeDirectory, ()) + +-- 全局 chusql.toml 的最小读写:只认 [section] 与 key = value。 +-- 读写都是一次性打开就关,不长期占着文件句柄,别的层随时能读同一份文件。 + +-- | 读一个分区;文件不存在或没有这个分区都当空表 +readSection :: FilePath -> Text -> IO (Map Text Text) +readSection path section = do + exists <- doesFileExist path + if not exists + then pure Map.empty + else do + raw <- BS.readFile path + pure (Map.fromList (sectionEntries section (T.lines (TE.decodeUtf8 raw)))) + +-- | 只重写一个分区,其它行(含注释与别的层)原样保留 +writeSection :: FilePath -> Text -> Map Text Text -> IO (Either String ()) +writeSection path section values = do + prepared <- prepareDirectory + case prepared of + Left err -> pure (Left err) + Right () -> do + exists <- doesFileExist path + raw <- if exists then TE.decodeUtf8 <$> BS.readFile path else pure "" + let updated = T.unlines (rewrite section values (T.lines raw)) + written <- tryWrite (TE.encodeUtf8 updated) + pure written + where + prepareDirectory = do + let dir = takeDirectory path + if null dir + then pure (Right ()) + else do + done <- try (createDirectoryIfMissing True dir) :: IO (Either IOException ()) + pure (either (Left . show) (const (Right ())) done) + tryWrite bytes = do + done <- try (BS.writeFile path bytes) :: IO (Either IOException ()) + pure (either (Left . show) (const (Right ())) done) + +-- | 分区里的键值对 +sectionEntries :: Text -> [Text] -> [(Text, Text)] +sectionEntries wanted = go "" + where + go _ [] = [] + go current (line : rest) + | Just name <- sectionName line = go name rest + | current /= wanted = go current rest + | otherwise = case assignment line of + Nothing -> go current rest + Just entry -> entry : go current rest + +-- | 是不是分区头,返回分区名 +sectionName :: Text -> Maybe Text +sectionName line = case T.strip line of + stripped + | "[" `T.isPrefixOf` stripped && "]" `T.isSuffixOf` stripped && T.length stripped > 2 -> + Just (T.strip (T.drop 1 (T.dropEnd 1 stripped))) + _ -> Nothing + +-- | 是不是 key = value +assignment :: Text -> Maybe (Text, Text) +assignment line = case T.breakOn "=" (stripComment line) of + (key, rest) + | not (T.null rest) + , let name = T.strip key + , not (T.null name) -> + Just (name, unquote (T.strip (T.drop 1 rest))) + _ -> Nothing + +-- | 去掉行尾注释(引号里的 # 不算) +stripComment :: Text -> Text +stripComment = T.pack . go False . T.unpack + where + go _ [] = [] + go inString ('#' : rest) + | not inString = [] + | otherwise = '#' : go inString rest + go inString ('"' : rest) = '"' : go (not inString) rest + go inString (ch : rest) = ch : go inString rest + +-- | 去掉包裹的引号 +unquote :: Text -> Text +unquote value + | T.length value >= 2 && T.head value == '"' && T.last value == '"' = T.drop 1 (T.dropEnd 1 value) + | T.length value >= 2 && T.head value == '\'' && T.last value == '\'' = T.drop 1 (T.dropEnd 1 value) + | otherwise = value + +-- | 行级重写:本分区里已有的键就地更新,空值删行,缺的键补在分区末尾 +rewrite :: Text -> Map Text Text -> [Text] -> [Text] +rewrite section values = addMissing . replaceInSection . dropEmpties + where + dropEmpties = filter (\line -> maybe True (not . T.null . snd) (assignment line)) + replaceInSection = go "" + go _ [] = [] + go current (line : rest) + | Just name <- sectionName line = line : go name rest + | current /= section = line : go current rest + | otherwise = case assignment line of + Just (key, _) | Just value <- Map.lookup key values -> assignmentLine key value : go current rest + _ -> line : go current rest + addMissing lines' = + let present = Set.fromList (keysOf section lines') + missing = [(key, value) | (key, value) <- Map.toList values, not (Set.member key present), not (T.null value)] + in if null missing then lines' else appendSection section missing lines' + +assignmentLine :: Text -> Text -> Text +assignmentLine key value = key <> " = " <> quote value + +quote :: Text -> Text +quote value + | T.all allowed value && not (T.null value) = value + | otherwise = "\"" <> T.replace "\"" "\\\"" value <> "\"" + where + allowed ch = ch `elem` ("0123456789.-" :: String) + +-- | 取某个分区里出现过的键 +keysOf :: Text -> [Text] -> [Text] +keysOf wanted = go "" + where + go _ [] = [] + go current (line : rest) + | Just name <- sectionName line = go name rest + | current /= wanted = go current rest + | otherwise = case assignment line of + Just (key, _) -> key : go current rest + Nothing -> go current rest + +-- | 没有这个分区就在末尾补一个 +appendSection :: Text -> [(Text, Text)] -> [Text] -> [Text] +appendSection section entries lines' = + lines' + ++ (if null lines' || not (T.null (last lines')) then [""] else []) + ++ ("[" <> section <> "]") : [assignmentLine key value | (key, value) <- entries] + +-- | 全局配置文件的位置:安装时定下的固定路径,跟随系统惯例 +-- (Windows: %APPDATA%\ChuSQL\chusql.toml;其它平台: $XDG_CONFIG_HOME 或 ~/.config 下的 ChuSQL/chusql.toml) +defaultConfigFile :: IO FilePath +defaultConfigFile = do + dir <- getXdgDirectory XdgConfig "ChuSQL" + pure (dir "chusql.toml") + +-- | 定位配置文件:命令行 --config 指的是哪份就读哪份,否则用固定路径 +resolveConfigPath :: Maybe FilePath -> IO FilePath +resolveConfigPath (Just path) = pure path +resolveConfigPath Nothing = defaultConfigFile diff --git a/chusql-web/src/ChuSQL/Web/UiSettings.hs b/chusql-web/src/ChuSQL/Web/UiSettings.hs index e96b9df..3f92165 100644 --- a/chusql-web/src/ChuSQL/Web/UiSettings.hs +++ b/chusql-web/src/ChuSQL/Web/UiSettings.hs @@ -1,11 +1,11 @@ {-# LANGUAGE OverloadedStrings #-} -module ChuSQL.Web.UiSettings ( +module ChuSQL.Web.UISettings ( uiSettingsFileCandidates, - resolveUiSettingsFile, - validateUiSettings, - readUiSettings, - writeUiSettings, + resolveUISettingsFile, + validateUISettings, + readUISettings, + writeUISettings, ) where import Control.Exception (IOException, try) @@ -22,19 +22,19 @@ import qualified Data.Vector as V import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist) import System.FilePath (takeDirectory, ()) --- 前端 IDE 设置单独存一份文件,与启动配置 chusql.settings.json 互不干扰。 +-- 前端 IDE 设置单独存一份文件,与全局启动配置 chusql.toml 互不干扰。 -- | IDE 设置文件的候选位置 uiSettingsFileCandidates :: [FilePath] uiSettingsFileCandidates = - [ "script" "chusql.ui.settings.json" - , ".." "script" "chusql.ui.settings.json" + [ "scripts" "chusql.ui.settings.json" + , ".." "scripts" "chusql.ui.settings.json" , "chusql.ui.settings.json" ] -- | 找现成的设置文件,否则挑目录已存在的候选 -resolveUiSettingsFile :: IO FilePath -resolveUiSettingsFile = do +resolveUISettingsFile :: IO FilePath +resolveUISettingsFile = do found <- firstExistingFile uiSettingsFileCandidates case found of Just path -> pure path @@ -42,7 +42,6 @@ resolveUiSettingsFile = do ready <- firstExistingDir uiSettingsFileCandidates pure (maybe firstCandidate id ready) where - -- | 候选清单里的第一个 firstCandidate = case uiSettingsFileCandidates of (p : _) -> p [] -> "chusql.ui.settings.json" @@ -52,7 +51,6 @@ resolveUiSettingsFile = do ok <- doesFileExist p if ok then pure (Just p) else firstExistingFile ps - -- | 候选所在目录是否已存在 firstExistingDir [] = pure Nothing firstExistingDir (p : ps) = do let dir = takeDirectory p @@ -60,8 +58,8 @@ resolveUiSettingsFile = do if ok then pure (Just p) else firstExistingDir ps -- | 校验一份 IDE 设置,未知键与越界都给错 -validateUiSettings :: A.Value -> Either String (Map.Map Text A.Value) -validateUiSettings (A.Object o) = Map.fromList <$> mapM checkField (KM.toList o) +validateUISettings :: A.Value -> Either String (Map.Map Text A.Value) +validateUISettings (A.Object o) = Map.fromList <$> mapM checkField (KM.toList o) where checkField (rawKey, value) = let key = K.toText rawKey @@ -84,7 +82,7 @@ validateUiSettings (A.Object o) = Map.fromList <$> mapM checkField (KM.toList o) pair key value = (key, value) -validateUiSettings _ = Left "settings must be a JSON object" +validateUISettings _ = Left "settings must be a JSON object" fontChainMax :: Int fontChainMax = 10 @@ -150,8 +148,8 @@ boolValue key value = case value of _ -> Left (T.unpack key ++ " expects a boolean") -- | 读 IDE 设置文件,缺文件或坏内容都给空对象 -readUiSettings :: FilePath -> IO A.Value -readUiSettings path = do +readUISettings :: FilePath -> IO A.Value +readUISettings path = do exists <- doesFileExist path if not exists then pure emptyObject @@ -166,8 +164,8 @@ readUiSettings path = do emptyObject = A.Object KM.empty -- | 写 IDE 设置文件,先建目录再落盘 -writeUiSettings :: FilePath -> Map.Map Text A.Value -> IO (Either String ()) -writeUiSettings path values = do +writeUISettings :: FilePath -> Map.Map Text A.Value -> IO (Either String ()) +writeUISettings path values = do prepared <- try (ensureDir (takeDirectory path)) :: IO (Either IOException ()) case prepared of Left e -> pure (Left ("cannot create the settings directory " ++ takeDirectory path ++ ": " ++ show e)) diff --git a/chusql-web/static/api.js b/chusql-web/static/api.js new file mode 100644 index 0000000..8766bbf --- /dev/null +++ b/chusql-web/static/api.js @@ -0,0 +1,257 @@ +// REST 数据源:与 chusql-web 的 /api/* 一一对应。 +// 所有请求带 X-ChuSQL-Database 头(服务端不保存 USE 状态);401 时广播登录事件。 + +export const AUTH_EVENT = 'chusql-auth-required'; + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +async function request(url, init, database) { + const headers = new Headers(init && init.headers ? init.headers : undefined); + if (database) headers.set('X-ChuSQL-Database', database); + let response; + try { + response = await fetch(url, { credentials: 'same-origin', ...(init ?? {}), headers }); + } catch (error) { + throw new Error(`无法连接服务器:${error && error.message ? error.message : String(error)}`); + } + let body; + try { + body = await response.json(); + } catch { + body = undefined; + } + if (!response.ok) { + if (response.status === 401 || (isObject(body) && body.error === 'password_expired')) { + window.dispatchEvent(new Event(AUTH_EVENT)); + } + const message = isObject(body) && typeof body.message === 'string' + ? body.message + : `请求失败(HTTP ${response.status})。`; + throw new Error(message); + } + return body; +} + +function jsonInit(method, body) { + return { + method, + headers: { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }; +} + +const identifier = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/; + +function checkDatabaseName(name) { + if (!identifier.test(String(name))) throw new Error('数据库名只能是字母、数字与下划线,且以字母或下划线开头。'); +} + +// ---------------------------------------------------------------- 会话 + +export async function currentSession() { + const body = await request('/api/session'); + if (!isObject(body) || typeof body.user !== 'string') throw new Error('服务器返回的会话信息无法识别。'); + return { + user: body.user, + administrator: body.administrator === true, + policy: isObject(body.policy) ? body.policy : { minLength: 0, classes: 0 }, + }; +} + +export async function login(user, password) { + await request('/api/login', jsonInit('POST', { user, password })); +} + +export async function logout() { + await request('/api/logout', jsonInit('POST', {})); +} + +// ---------------------------------------------------------------- 数据库 + +export async function listDatabases() { + const body = await request('/api/databases'); + if (!Array.isArray(body) || !body.every((name) => typeof name === 'string')) { + throw new Error('服务器返回的数据库列表无法识别。'); + } + return body; +} + +export async function createDatabase(name, database) { + checkDatabaseName(name); + await request('/api/databases', jsonInit('POST', { name }), database); +} + +export async function dropDatabase(name, database) { + await request(`/api/databases/${encodeURIComponent(name)}`, jsonInit('DELETE'), database); +} + +// 切换当前库:走一条 USE 语句,服务端在响应里回带新的库名。 +export async function useDatabase(name, database) { + checkDatabaseName(name); + return query(`USE ${name}`, database); +} + +// ---------------------------------------------------------------- 表结构 + +function columnKind(type) { + return String(type).split('(')[0].trim().toLowerCase(); +} + +const knownTypes = ['int', 'bigint', 'smallint', 'str', 'text', 'varchar', 'char', 'float', 'double', 'decimal', 'numeric', 'bool', 'date', 'timestamp', 'blob']; + +function asColumns(value) { + if (!Array.isArray(value)) throw new Error('服务器返回的列清单无法识别。'); + return value.map((entry) => { + if (!isObject(entry) || typeof entry.name !== 'string' || typeof entry.type !== 'string') { + throw new Error('服务器返回的列描述无法识别。'); + } + if (!knownTypes.includes(columnKind(entry.type))) throw new Error(`不支持的列类型:${entry.type}`); + return { ...entry, name: entry.name, type: entry.type }; + }); +} + +function asTable(value) { + if (!isObject(value) || typeof value.table !== 'string' || typeof value.rowCount !== 'number') { + throw new Error('服务器返回的表描述无法识别。'); + } + return { + name: value.table, + rowCount: value.rowCount, + columns: asColumns(value.columns), + indexes: Array.isArray(value.indexes) ? value.indexes : [], + kind: value.kind === 'system' ? 'system' : 'table', + }; +} + +export async function listTables(database) { + const body = await request('/api/tables', undefined, database); + if (!Array.isArray(body)) throw new Error('服务器返回的表清单无法识别。'); + return body.map(asTable); +} + +export async function getTable(database, table) { + return asTable(await request(`/api/tables/${encodeURIComponent(table)}`, undefined, database)); +} + +export async function createTable(database, name, columns) { + await request('/api/tables', jsonInit('POST', { name, columns }), database); +} + +export async function dropTable(database, table) { + await request(`/api/tables/${encodeURIComponent(table)}`, jsonInit('DELETE'), database); +} + +export async function createIndex(database, table, column) { + await request(`/api/tables/${encodeURIComponent(table)}/indexes`, jsonInit('POST', { column }), database); +} + +export async function dropIndex(database, table, column) { + await request(`/api/tables/${encodeURIComponent(table)}/indexes/${encodeURIComponent(column)}`, jsonInit('DELETE'), database); +} + +export async function dropColumn(database, table, column) { + await request(`/api/tables/${encodeURIComponent(table)}/columns/${encodeURIComponent(column)}`, jsonInit('DELETE'), database); +} + +// ---------------------------------------------------------------- 行 + +export async function fetchRows({ database, table, limit, offset, sort, dir, filters }) { + const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }); + if (sort) { + params.set('sort', sort); + params.set('dir', dir === 'desc' ? 'desc' : 'asc'); + } + if (filters && filters.length) { + params.set('filter', JSON.stringify(filters.map((f) => ({ column: f.column, value: f.expression })))); + } + const body = await request(`/api/tables/${encodeURIComponent(table)}/rows?${params}`, undefined, database); + if (!isObject(body) || !Array.isArray(body.columns) || !body.columns.every((c) => typeof c === 'string') + || !Array.isArray(body.rows) || typeof body.total !== 'number') { + throw new Error('服务器返回的分页数据无法识别。'); + } + const columns = body.columns; + const rows = body.rows.map((raw) => { + if (!Array.isArray(raw)) throw new Error('服务器返回的行数据无法识别。'); + return Object.fromEntries(columns.map((column, index) => [column, raw[index]])); + }); + return { columns, rows, total: body.total }; +} + +export async function insertRow(database, table, values) { + await request(`/api/tables/${encodeURIComponent(table)}/rows`, jsonInit('POST', { values }), database); +} + +export async function updateRow(database, table, id, values) { + await request(`/api/tables/${encodeURIComponent(table)}/rows/${encodeURIComponent(id)}`, jsonInit('PATCH', { values }), database); +} + +export async function deleteRow(database, table, id) { + await request(`/api/tables/${encodeURIComponent(table)}/rows/${encodeURIComponent(id)}`, jsonInit('DELETE'), database); +} + +// 逐条提交,遇到第一条失败就停,返回已经成功了几条。 +export async function commitChanges(database, changes) { + let applied = 0; + for (const [index, change] of changes.entries()) { + try { + if (change.op === 'insert') await insertRow(database, change.table, change.values); + else if (change.op === 'delete') await deleteRow(database, change.table, change.pk); + else await updateRow(database, change.table, change.pk, change.set); + applied += 1; + } catch (error) { + return { ok: false, applied, errors: [{ index, message: error instanceof Error ? error.message : String(error) }] }; + } + } + return { ok: true, applied }; +} + +// ---------------------------------------------------------------- 查询 + +export async function query(sql, database) { + const started = performance.now(); + try { + const body = await request('/api/query', jsonInit('POST', { sql }), database); + if (!isObject(body) || !Array.isArray(body.columns) || !Array.isArray(body.rows)) { + throw new Error('服务器返回的查询结果无法识别。'); + } + const durationMs = performance.now() - started; + return { + type: body.columns.length ? 'rows' : 'affected', + columns: body.columns, + rows: body.rows, + affected: typeof body.rowCount === 'number' ? body.rowCount : 0, + truncated: body.truncated === true, + database: typeof body.database === 'string' ? body.database : undefined, + durationMs, + }; + } catch (error) { + return { type: 'error', durationMs: performance.now() - started, message: error instanceof Error ? error.message : String(error) }; + } +} + +export async function seedDemoData(database) { + return request('/api/demo-data', jsonInit('POST', {}), database); +} + +// ---------------------------------------------------------------- 设置 + +export async function loadServerSettings() { + const body = await request('/api/settings'); + if (!isObject(body) || !Array.isArray(body.items)) throw new Error('服务器返回的设置清单无法识别。'); + return body; +} + +export async function saveServerSettings(values) { + const body = await request('/api/settings', jsonInit('PUT', { values })); + return isObject(body) ? body : { ok: true }; +} + +export async function loadUiSettings() { + return request('/api/ui-settings'); +} + +export async function saveUiSettings(settings) { + await request('/api/ui-settings', jsonInit('PUT', settings)); +} diff --git a/chusql-web/static/app.js b/chusql-web/static/app.js new file mode 100644 index 0000000..0d94d85 --- /dev/null +++ b/chusql-web/static/app.js @@ -0,0 +1,1567 @@ +// ChuSQL Web IDE:纯 HTML + CSS + JS,无 React、无打包器、无 Node 依赖。 +// 结构:全局状态 → 页面骨架 → 各区域渲染 → 事件代理 → 会话/数据/变更动作。 + +import * as api from './api.js'; +import { AUTH_EVENT } from './api.js'; +import { + DEFAULT_SETTINGS, + IDE_SETTINGS_KEY, + accountsTable, + cellText, + changeCount, + changeEntries, + changeKey, + changesSql, + clampSetting, + closeTab, + createEmptyChangeSet, + creatableTypes, + deleteKey, + editText, + escapeHtml, + fontFamilyValue, + fontsFromText, + fontsToText, + highlightHtml, + insertKey, + matchesFilter, + openQueryTab, + openSettingsTab, + openTableTab, + parseEdited, + parseUiSettings, + settingLabels, + toCommitPayload, + typeLabel, +} from './core.js'; + +// ---------------------------------------------------------------- 状态 + +const state = { + view: 'loading', // loading | login | workbench + session: null, + databases: [], + database: '', + tables: [], + expanded: new Set(), + tabs: [], + activeId: undefined, + changes: createEmptyChangeSet(), + insertSeq: 0, + messages: [], + tabSeq: 0, + settings: { ...DEFAULT_SETTINGS }, + serverSettings: null, + bottomTab: 'changes', + status: '', + loading: false, + sidebarWidth: 250, + dockHeight: '22vh', +}; + +const root = document.getElementById('root'); + +// ---------------------------------------------------------------- 小工具 + +function region(id) { + return document.getElementById(id); +} + +function now() { + return new Date().toLocaleTimeString('zh-CN', { hour12: false }); +} + +function log(level, text) { + state.messages.push({ level, text, time: now() }); + if (state.messages.length > 200) state.messages.shift(); + renderBottom(); +} + +function setStatus(text) { + state.status = text; + const el = region('status-message'); + if (el) el.textContent = text; +} + +function describe(error) { + return error instanceof Error ? error.message : String(error); +} + +function fail(error) { + const message = describe(error); + log('error', message); + setStatus(message); + return message; +} + +function nextTabId(prefix) { + state.tabSeq += 1; + return `${prefix}-${state.tabSeq}`; +} + +function activeTab() { + return state.tabs.find((tab) => tab.id === state.activeId); +} + +// ---------------------------------------------------------------- 登录页 + +function renderLogin(message, level) { + state.view = 'login'; + root.innerHTML = ` +
+
+
ChuSQL

数据库终端

+ + +

配置里口令留空时,管理员直接留空口令登录。

+ ${message ? `

${escapeHtml(message)}

` : ''} + +
+
`; +} + +// ---------------------------------------------------------------- 页面骨架 + +function renderShell() { + state.view = 'workbench'; + root.innerHTML = ` +
+
+
ChuSQL数据库终端
+ + +
+
+ + +
+ +
+
+
+ + + + +
+ + +
+ + +
+

新建数据库

+
+ +

+
+
+ + +
+
+
+ + +
+

新建表

+
+ +
列
+ +

+
+
+ + +
+
+
+ + +
+

建索引

+
+ +

+
+
+ + +
+
+
`; + renderAccount(); + applyLayout(); +} + +// 侧栏宽度与下方面板高度用 CSSOM 写,避免 CSP 拦掉内联 style 属性。 +function applyLayout() { + const workbench = region('workbench'); + if (!workbench) return; + workbench.style.setProperty('--sidebar-width', `${state.sidebarWidth}px`); + workbench.style.setProperty('--dock-height', state.dockHeight); +} + +// ---------------------------------------------------------------- 账号区 + +function renderAccount() { + const el = region('account'); + if (!el) return; + if (!state.session) { + el.innerHTML = ''; + return; + } + const title = state.session.administrator ? '管理员' : '普通账号'; + const badge = state.session.administrator ? ' · 管理员' : ''; + el.innerHTML = ` + `; +} + +// ---------------------------------------------------------------- 侧栏 + +function renderSidebar() { + const el = region('sidebar'); + if (!el) return; + const options = state.databases + .map((name) => ``) + .join(''); + const databaseOptions = state.database ? options : `${options}`; + + const rows = state.tables.map((table) => { + const open = state.expanded.has(table.name); + const hasId = table.columns.some((column) => column.name === 'id'); + const columns = table.columns.map((column) => { + const marks = [ + column.primaryKey || column.name === 'id' ? 'PK' : '', + column.indexed ? 'IX' : '', + column.nullable ? '' : 'NN', + ].join(''); + const drop = column.name === 'id' + ? '' + : ``; + return `
  • ${escapeHtml(column.name)} + ${escapeHtml(typeLabel(column.type))} + ${marks}${drop}
  • `; + }).join(''); + + const own = (table.indexes ?? []).filter((index) => !index.builtIn && index.column !== 'id'); + const indexList = own.length + ? `
      ${own.map((index) => `
    • 索引 ${escapeHtml(index.column)} +
    • `).join('')}
    ` + : ''; + + return `
  • +
    + + + ${table.rowCount} + ${hasId ? '' : '只读'} + +
    + ${open ? `
      ${columns}
    ${indexList} +
    ` : ''} +
  • `; + }).join(''); + + const count = changeCount(state.changes); + el.innerHTML = ` +

    资源管理器

    +
    +
    数据库
    +
    + + + +
    +
    +
    +
    表 + + +
    + ${state.tables.length ? `
      ${rows}
    ` : `

    ${state.database ? '这个库还没有表。' : '未选择数据库。'}

    `} +
    +
    +
    变更
    +
    + + +
    +
    `; +} + +// ---------------------------------------------------------------- 标签栏 + +function renderTabs() { + const el = region('editor'); + if (!el) return; + const tabs = state.tabs.map((tab) => ` + + + + `).join(''); + el.innerHTML = ` +
    ${tabs || '没有打开的标签页'}
    +
    `; + renderEditor(); +} + +// ---------------------------------------------------------------- 编辑器区 + +function renderEditor() { + const el = region('tab-content'); + if (!el) return; + const tab = activeTab(); + if (!tab) { + el.innerHTML = `
    +

    ChuSQL 数据库终端

    +

    从左边选一张表,或者 。

    +
    `; + return; + } + if (tab.kind === 'table') renderTableTab(el, tab); + else if (tab.kind === 'sql') renderSqlTab(el, tab); + else renderSettingsTab(el, tab); +} + +// ---- 表格标签 + +function cellContent(value, nullText) { + if (value === null || value === undefined) return `${escapeHtml(nullText)}`; + return escapeHtml(cellText(value, nullText)); +} + +function renderTableTab(el, tab) { + const data = tab.data; + if (!data) { + el.innerHTML = '

    正在加载…

    '; + return; + } + if (data.error) { + el.innerHTML = `

    ${escapeHtml(data.error)}

    `; + return; + } + const columns = data.columns; + const keyColumn = columns.find((column) => column.name === 'id'); + const editable = Boolean(keyColumn); + const filters = data.filters ?? {}; + const count = changeCount(state.changes); + + const head = columns.map((column) => { + const arrow = data.sort === column.name ? (data.dir === 'desc' ? ' ▼' : ' ▲') : ''; + return ` + ${escapeHtml(typeLabel(column.type))}`; + }).join(''); + + const filterRow = columns.map((column) => ``).join(''); + + const stagedInserts = [...state.changes.inserts.values()].filter((insert) => insert.table === tab.table && insert.database === tab.database); + + const insertRows = stagedInserts.map((insert) => { + const cells = columns.map((column) => `${cellContent(insert.values[column.name], state.settings.nullText)}`).join(''); + return ` + + + + ${cells}`; + }).join(''); + + const body = data.rows.map((row, index) => { + const pk = keyColumn ? row[keyColumn.name] : undefined; + const removed = keyColumn ? state.changes.deletes.has(deleteKey(tab.table, pk)) : false; + const selected = tab.selected?.has(String(pk)) === true; + const cells = columns.map((column) => { + const staged = keyColumn ? state.changes.updates.get(changeKey(tab.table, pk, column.name)) : undefined; + const value = staged ? staged.newValue : row[column.name]; + const action = editable ? ` data-action="edit-cell" data-pk="${escapeHtml(pk)}" data-column="${escapeHtml(column.name)}"` : ''; + return `${cellContent(value, state.settings.nullText)}`; + }).join(''); + const checkbox = editable + ? `` + : ''; + return ` + ${data.offset + index + 1} + ${checkbox} + ${cells}`; + }).join(''); + + const pageCount = Math.max(1, Math.ceil(data.total / data.pageSize)); + + el.innerHTML = ` +
    +
    + + + + + + + + + + + 第 ${data.page + 1} / ${pageCount} 页 · 共 ${data.total} 行 + +
    +
    + + + ${head} + ${filterRow} + + ${insertRows}${body} +
    + ${data.rows.length || stagedInserts.length ? '' : '

    这张表没有数据。

    '} +
    + ${data.loading ? '
    加载中…
    ' : ''} + ${editable ? '' : '

    这张表没有 id 列,只能浏览。

    '} +
    `; +} + +// ---- SQL 标签 + +function lineNumbers(sql) { + const count = String(sql ?? '').split('\n').length; + return Array.from({ length: count }, (_, index) => index + 1).join('\n'); +} + +function renderSqlTab(el, tab) { + const result = tab.result; + const resultHtml = (() => { + if (!result) return '

    按 Ctrl+Enter 运行,或点上面的"运行"。

    '; + if (result.type === 'error') return `

    ${escapeHtml(result.message ?? '执行失败')}

    `; + if (result.type === 'affected') return `

    影响 ${result.affected ?? 0} 行 · ${result.durationMs.toFixed(1)} ms

    `; + const head = result.columns.map((column) => `${escapeHtml(column)}`).join(''); + const body = result.rows + .map((row) => `${row.map((value) => `${cellContent(value, state.settings.nullText)}`).join('')}`) + .join(''); + return `
    ${head}${body}
    +

    ${result.rows.length} 行${result.truncated ? '(已截断)' : ''} · ${result.durationMs.toFixed(1)} ms

    `; + })(); + + el.innerHTML = ` +
    +
    + + + + 库 ${escapeHtml(tab.database ?? state.database)} +
    +
    + ${state.settings.sqlLineNumbers ? `
    ${lineNumbers(tab.sql)}
    ` : ''} +
    + + +
    + ${state.settings.minimap ? '
    ' : ''} +
    +
    ${resultHtml}
    +
    `; + + const textarea = region('sql-input'); + const highlight = region('sql-highlight'); + const gutter = region('sql-gutter'); + const minimap = region('sql-minimap'); + if (!textarea) return; + + const refreshLayers = () => { + if (highlight) highlight.innerHTML = highlightHtml(tab.sql); + if (gutter) gutter.textContent = lineNumbers(tab.sql); + sync(); + }; + const sync = () => { + if (highlight) { + highlight.scrollTop = textarea.scrollTop; + highlight.scrollLeft = textarea.scrollLeft; + } + if (gutter) gutter.scrollTop = textarea.scrollTop; + if (minimap) { + const thumb = minimap.querySelector('.minimap-thumb'); + if (!thumb) return; + const visible = Math.max(8, (textarea.clientHeight / Math.max(1, textarea.scrollHeight)) * 100); + const ratio = textarea.scrollHeight > textarea.clientHeight + ? textarea.scrollTop / (textarea.scrollHeight - textarea.clientHeight) + : 0; + thumb.style.height = `${visible}%`; + thumb.style.top = `${Math.min(100 - visible, ratio * (100 - visible))}%`; + } + }; + + textarea.addEventListener('input', () => { + tab.sql = textarea.value; + refreshLayers(); + }); + textarea.addEventListener('scroll', sync); + textarea.addEventListener('keydown', (event) => { + if (event.key === 'Tab') { + event.preventDefault(); + const spaces = ' '.repeat(state.settings.sqlTabSize); + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + textarea.value = `${textarea.value.slice(0, start)}${spaces}${textarea.value.slice(end)}`; + textarea.selectionStart = start + spaces.length; + textarea.selectionEnd = start + spaces.length; + tab.sql = textarea.value; + refreshLayers(); + } + if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { + event.preventDefault(); + void runSql(tab); + } + }); + sync(); +} + +// ---- 设置标签 + +function renderSettingsTab(el) { + const settings = state.settings; + const fontField = (key) => ` + `; + const numberField = (key) => ` + `; + const boolField = (key) => ` + `; + + const serverHtml = (() => { + const server = state.serverSettings; + if (!server) return '

    正在读取服务器设置…

    '; + if (!server.items.length) return `

    ${escapeHtml(server.error ?? '没有可显示的设置。')}

    `; + const groups = []; + for (const item of server.items) { + let group = groups.find((entry) => entry.name === item.group); + if (!group) { + group = { name: item.group, items: [] }; + groups.push(group); + } + group.items.push(item); + } + return groups.map((group) => ` +
    ${escapeHtml(group.name)} + ${group.items.map((item) => ` + `).join('')} +
    `).join(''); + })(); + + el.innerHTML = ` +
    +

    设置

    IDE 设置存在 chusql.ui.settings.json,服务器设置写回 chusql.toml 的 [web] 分区。

    +
    界面 + ${fontField('uiFonts')}${numberField('uiFontSize')} +
    +
    数据表格 + ${fontField('gridFonts')}${numberField('gridFontSize')}${numberField('gridRowHeight')}${numberField('pageSize')} + +
    +
    SQL 控制台 + ${fontField('sqlFonts')}${numberField('sqlFontSize')}${numberField('sqlLineHeight')}${numberField('sqlTabSize')} + ${boolField('sqlLineNumbers')}${boolField('autocomplete')}${boolField('minimap')} +
    +
    + 预览 +
    SELECT * FROM users WHERE id = 1;
    +
    +
    +
    ${serverHtml} +
    + + +
    +
    +
    `; + + const preview = region('settings-preview'); + if (preview) { + preview.style.fontFamily = fontFamilyValue(settings.gridFonts); + preview.style.fontSize = `${settings.gridFontSize}px`; + preview.style.lineHeight = `${settings.gridRowHeight}px`; + } +} + +// ---------------------------------------------------------------- 下方面板 + +function renderBottom() { + const el = region('bottom'); + if (!el) return; + const count = changeCount(state.changes); + const entries = changeEntries(state.changes); + const preview = entries.map((entry) => entry.sql); + const changeList = entries.length + ? `
      ${entries.map((entry) => ` +
    • + ${escapeHtml(entry.table)} + ${escapeHtml(entry.sql)} + +
    • `).join('')}
    +
    预览生成的全部 SQL(${preview.length} 条) +
    ${escapeHtml(preview.join('\n'))}
    ` + : '

    没有待提交的变更。编辑单元格、插入或删除行都会先记在这里。

    '; + const messages = state.messages.length + ? `
      ${state.messages.slice(-100).map((message) => ` +
    • ${escapeHtml(message.time)}${escapeHtml(message.text)}
    • `).join('')}
    ` + : '

    还没有消息。

    '; + + el.innerHTML = ` +
    + + + + + + +
    +
    ${state.bottomTab === 'changes' ? changeList : messages}
    `; + + const dbEl = region('status-database'); + if (dbEl) dbEl.textContent = state.database ? `库 ${state.database}` : '未选择数据库'; + const changeEl = region('status-changes'); + if (changeEl) changeEl.textContent = `待提交 ${count}`; +} + +// ---------------------------------------------------------------- 数据加载 + +async function refreshSession() { + try { + state.session = await api.currentSession(); + return true; + } catch { + state.session = null; + return false; + } +} + +async function loadWorkspace() { + try { + state.databases = await api.listDatabases(); + if (!state.databases.includes(state.database)) { + // 没有默认库:优先留在原来的库,否则挑第一个非系统库,一个都没有就空着 + state.database = state.databases.filter((name) => name !== 'system')[0] ?? ''; + } + state.tables = state.database ? await api.listTables(state.database) : []; + renderSidebar(); + renderBottom(); + setStatus(state.database ? `已加载 ${state.tables.length} 张表` : '未选择数据库:选一个库,或新建一个'); + } catch (error) { + fail(error); + } +} + +async function switchDatabase(name) { + if (name === state.database) return; + if (changeCount(state.changes) > 0) { + log('warn', '请先提交或撤销当前库的修改,再切换数据库。'); + renderSidebar(); + return; + } + const result = await api.query(`USE ${name}`, state.database); + if (result.type === 'error') { + fail(new Error(result.message)); + renderSidebar(); + return; + } + state.database = result.database ?? name; + log('info', `已切换到数据库 ${state.database}`); + await loadWorkspace(); + renderEditor(); +} + +async function openTable(database, name) { + const opened = openTableTab(state.tabs, database, name, nextTabId('table')); + state.tabs = opened.tabs; + state.activeId = opened.activeId; + const tab = state.tabs.find((entry) => entry.id === opened.activeId); + if (!tab.data) { + tab.data = { + columns: [], rows: [], total: 0, page: 0, pageSize: state.settings.pageSize, + sort: null, dir: 'asc', filters: {}, offset: 0, loading: true, + }; + tab.selected = new Set(); + } + tab.database = database; + renderTabs(); + await loadTableData(tab); +} + +async function loadTableData(tab) { + const data = tab.data; + data.loading = true; + renderEditor(); + try { + const meta = await api.getTable(tab.database, tab.table); + const filters = Object.entries(data.filters ?? {}) + .filter(([, expression]) => String(expression).trim().length > 0) + .map(([column, expression]) => ({ column, expression })); + const pageSize = data.pageSize || state.settings.pageSize; + const offset = data.page * pageSize; + const page = await api.fetchRows({ + database: tab.database, + table: tab.table, + limit: pageSize, + offset, + sort: data.sort, + dir: data.dir, + filters, + }); + data.columns = meta.columns; + data.total = page.total; + data.offset = offset; + data.pageSize = pageSize; + data.error = null; + // 服务端做等值过滤,界面上的 like / 比较表达式再在本地收敛一次 + data.rows = filters.length + ? page.rows.filter((row) => filters.every((filter) => matchesFilter(row[filter.column], filter.expression))) + : page.rows; + if (!data.rows.length && data.page > 0) data.page -= 1; + } catch (error) { + data.error = describe(error); + } finally { + data.loading = false; + renderEditor(); + renderBottom(); + } +} + +async function reloadOpenTables() { + for (const tab of state.tabs) { + if (tab.kind === 'table') await loadTableData(tab); + } +} + +// ---------------------------------------------------------------- 变更动作 + +function unstageRow(table, pk) { + const prefix = `${table}:${JSON.stringify(pk)}:`; + for (const key of [...state.changes.updates.keys()]) { + if (key.startsWith(prefix)) state.changes.updates.delete(key); + } + state.changes.deletes.delete(deleteKey(table, pk)); +} + +function stageUpdate(table, database, pk, column, oldValue, newValue) { + if (oldValue === newValue) state.changes.updates.delete(changeKey(table, pk, column)); + else state.changes.updates.set(changeKey(table, pk, column), { table, database, pk, column, oldValue, newValue }); + afterChange(); +} + +function stageInsert(table, database, values) { + state.insertSeq += 1; + const tempId = `new-${state.insertSeq}`; + state.changes.inserts.set(insertKey(table, tempId), { table, database, tempId, values }); + afterChange(); +} + +function stageDelete(table, database, pk, row) { + unstageRow(table, pk); + state.changes.deletes.set(deleteKey(table, pk), { table, database, pk, row }); + afterChange(); +} + +function afterChange() { + renderSidebar(); + renderEditor(); + renderBottom(); +} + +function removeStaged(key) { + if (state.changes.updates.has(key)) state.changes.updates.delete(key); + else if (state.changes.inserts.has(key)) state.changes.inserts.delete(key); + else if (state.changes.deletes.has(key)) state.changes.deletes.delete(key); + afterChange(); +} + +function rollbackAll() { + state.changes = createEmptyChangeSet(); + for (const tab of state.tabs) if (tab.selected) tab.selected.clear(); + log('info', '已撤销全部待提交变更。'); + afterChange(); +} + +async function commitAll() { + const payload = toCommitPayload(state.changes); + if (!payload.length) { + log('info', '没有待提交的变更。'); + return; + } + const database = state.database; + const result = await api.commitChanges(database, payload); + if (result.ok) { + state.changes = createEmptyChangeSet(); + for (const tab of state.tabs) if (tab.selected) tab.selected.clear(); + log('info', `已提交 ${result.applied} 条变更。`); + setStatus(`已提交 ${result.applied} 条变更`); + } else { + const failed = result.errors?.[0]; + // 成功的部分不再保留,失败的那条留在面板上等修正 + state.changes = createEmptyChangeSet(); + payload.slice(result.applied).forEach((change) => { + if (change.op === 'insert') { + state.insertSeq += 1; + const tempId = `new-${state.insertSeq}`; + state.changes.inserts.set(insertKey(change.table, tempId), { table: change.table, database, tempId, values: change.values }); + } else if (change.op === 'delete') { + state.changes.deletes.set(deleteKey(change.table, change.pk), { table: change.table, database, pk: change.pk, row: {} }); + } else { + for (const [column, value] of Object.entries(change.set)) { + state.changes.updates.set(changeKey(change.table, change.pk, column), { + table: change.table, database, pk: change.pk, column, oldValue: undefined, newValue: value, + }); + } + } + }); + log('error', `提交在第 ${(failed?.index ?? 0) + 1} 条失败:${failed?.message ?? '未知错误'}(已成功 ${result.applied} 条)`); + } + await loadWorkspace(); + await reloadOpenTables(); + afterChange(); +} + +// ---------------------------------------------------------------- SQL 运行 + +async function runSql(tab) { + const sql = String(tab.sql ?? '').trim(); + if (!sql) return; + setStatus('执行中…'); + const result = await api.query(sql, tab.database); + tab.result = result; + if (result.type === 'error') { + log('error', result.message ?? '执行失败'); + setStatus(result.message ?? '执行失败'); + } else { + if (result.database && result.database !== state.database) { + state.database = result.database; + tab.database = result.database; + await loadWorkspace(); + } + if (result.type === 'rows') { + log('info', `查询返回 ${result.rows.length} 行`); + setStatus(`${result.rows.length} 行 · ${result.durationMs.toFixed(1)} ms`); + } else { + log('info', `语句影响 ${result.affected ?? 0} 行`); + setStatus(`影响 ${result.affected ?? 0} 行`); + await loadWorkspace(); + await reloadOpenTables(); + } + } + renderEditor(); +} + +// ---------------------------------------------------------------- 标签动作 + +function activateTab(id) { + state.activeId = id; + const tab = activeTab(); + if (tab?.kind === 'sql') tab.database = state.database; + if (tab?.kind === 'settings' && !state.serverSettings) void loadServerSettings(); + renderTabs(); +} + +function closeTabById(id) { + const closed = closeTab(state.tabs, id, state.activeId); + state.tabs = closed.tabs; + state.activeId = closed.activeId; + renderTabs(); +} + +function openQuery(sql) { + const title = `查询 ${state.tabs.filter((tab) => tab.kind === 'sql').length + 1}`; + const opened = openQueryTab(state.tabs, title, nextTabId('sql'), state.database, sql ?? ''); + state.tabs = opened.tabs; + state.activeId = opened.activeId; + renderTabs(); +} + +function openSettings() { + const opened = openSettingsTab(state.tabs, '设置', nextTabId('settings')); + state.tabs = opened.tabs; + state.activeId = opened.activeId; + renderTabs(); + if (!state.serverSettings) void loadServerSettings(); +} + +// ---------------------------------------------------------------- 设置动作 + +function applySettingsToDom() { + const style = document.documentElement.style; + style.setProperty('--ui-font', fontFamilyValue(state.settings.uiFonts)); + style.setProperty('--ui-font-size', `${state.settings.uiFontSize}px`); + style.setProperty('--grid-font', fontFamilyValue(state.settings.gridFonts)); + style.setProperty('--grid-font-size', `${state.settings.gridFontSize}px`); + style.setProperty('--grid-row-height', `${state.settings.gridRowHeight}px`); + style.setProperty('--sql-font', fontFamilyValue(state.settings.sqlFonts)); + style.setProperty('--sql-font-size', `${state.settings.sqlFontSize}px`); + style.setProperty('--sql-line-height', `${state.settings.sqlLineHeight}px`); + style.setProperty('--sql-tab-size', String(state.settings.sqlTabSize)); +} + +let saveTimer; +function persistSettings() { + try { + localStorage.setItem(IDE_SETTINGS_KEY, JSON.stringify(state.settings)); + } catch { + // 本地存储不可用时忽略:服务器那边仍会保存 + } + clearTimeout(saveTimer); + saveTimer = setTimeout(() => { + api.saveUiSettings(state.settings).catch((error) => log('warn', `IDE 设置没能保存到服务器:${describe(error)}`)); + }, 400); +} + +function updateSetting(key, value) { + state.settings = { ...state.settings, [key]: value }; + applySettingsToDom(); + persistSettings(); + renderEditor(); +} + +function loadLocalSettings() { + try { + const raw = localStorage.getItem(IDE_SETTINGS_KEY); + if (!raw) return; + state.settings = { ...state.settings, ...parseUiSettings(JSON.parse(raw)) }; + } catch { + // 本地设置坏了就用默认值 + } +} + +async function loadRemoteSettings() { + try { + state.settings = { ...state.settings, ...parseUiSettings(await api.loadUiSettings()) }; + applySettingsToDom(); + } catch (error) { + log('warn', `IDE 设置没能从服务器读取:${describe(error)}`); + } +} + +async function loadServerSettings() { + try { + state.serverSettings = await api.loadServerSettings(); + } catch (error) { + state.serverSettings = { error: describe(error), items: [] }; + } + renderEditor(); +} + +async function saveServerSettings() { + const inputs = [...document.querySelectorAll('[data-server-setting]')]; + const values = {}; + for (const input of inputs) { + if (input.disabled || !input.dataset.dirty) continue; + values[input.dataset.serverSetting] = input.value; + } + if (!Object.keys(values).length) { + const note = region('server-settings-note'); + if (note) note.textContent = '没有需要保存的改动。'; + return; + } + try { + const result = await api.saveServerSettings(values); + const applied = Array.isArray(result.applied) ? result.applied : []; + const restart = Array.isArray(result.restartRequired) ? result.restartRequired : []; + log('info', `服务器设置已保存:${Object.keys(values).join('、')}`); + state.serverSettings = await api.loadServerSettings(); + renderEditor(); + const note = region('server-settings-note'); + if (note) { + note.textContent = `已应用 ${applied.length} 项${restart.length ? `,需重启生效 ${restart.length} 项:${restart.join('、')}` : ''}`; + } + } catch (error) { + fail(error); + } +} + +// ---------------------------------------------------------------- 表格动作 + +function sortColumn(tab, column) { + const data = tab.data; + if (data.sort === column) data.dir = data.dir === 'asc' ? 'desc' : 'asc'; + else { + data.sort = column; + data.dir = 'asc'; + } + data.page = 0; + void loadTableData(tab); +} + +function deleteSelected(tab) { + const keyColumn = tab.data.columns.find((column) => column.name === 'id'); + if (!keyColumn || !tab.selected) return; + for (const row of tab.data.rows) { + if (tab.selected.has(String(row[keyColumn.name]))) stageDelete(tab.table, tab.database, row[keyColumn.name], row); + } + tab.selected.clear(); + afterChange(); +} + +// 点一下就地把单元格变成输入框;Enter/失焦提交,Esc 放弃。 +function beginCellEdit(cell, options) { + if (cell.querySelector('input')) return; + const input = document.createElement('input'); + input.className = 'cell-input'; + input.value = editText(options.currentValue); + cell.textContent = ''; + cell.appendChild(input); + input.focus(); + input.select(); + let done = false; + const finish = (save) => { + if (done) return; + done = true; + const raw = input.value; + input.remove(); + options.commit(save ? parseEdited(raw, options.type) : undefined); + }; + input.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + finish(true); + } else if (event.key === 'Escape') { + event.preventDefault(); + finish(false); + } + }); + input.addEventListener('blur', () => finish(true)); +} + +function addColumnRow(name, type) { + const container = region('dlg-table-columns'); + if (!container) return; + const row = document.createElement('div'); + row.className = 'column-row'; + row.innerHTML = ` + + + `; + container.appendChild(row); +} + +function openCreateTableDialog() { + region('dlg-table-name').value = ''; + region('dlg-table-error').textContent = ''; + region('dlg-table-columns').innerHTML = ''; + addColumnRow('id', 'int'); + addColumnRow('', 'str'); + region('dlg-table').showModal(); +} + +function openIndexDialog(table) { + const meta = state.tables.find((entry) => entry.name === table); + const select = region('dlg-index-column'); + const candidates = (meta?.columns ?? []).filter((column) => column.name !== 'id' && !column.indexed); + select.innerHTML = candidates + .map((column) => ``) + .join(''); + region('dlg-index-error').textContent = candidates.length ? '' : '没有可建索引的列。'; + region('dlg-index').dataset.table = table; + region('dlg-index').showModal(); +} + +async function createDatabase() { + const name = region('dlg-db-name').value.trim(); + try { + await api.createDatabase(name, state.database); + region('dlg-db').close(); + log('info', `已创建数据库 ${name}`); + await loadWorkspace(); + } catch (error) { + region('dlg-db-error').textContent = describe(error); + } +} + +async function createTable() { + const name = region('dlg-table-name').value.trim(); + const rows = [...region('dlg-table-columns').querySelectorAll('.column-row')]; + const columns = rows + .map((row) => ({ name: row.querySelector('.column-name').value.trim(), type: row.querySelector('.column-type').value })) + .filter((column) => column.name); + if (!name) { + region('dlg-table-error').textContent = '表名不能为空。'; + return; + } + if (!columns.length) { + region('dlg-table-error').textContent = '至少需要一列。'; + return; + } + try { + await api.createTable(state.database, name, columns); + region('dlg-table').close(); + log('info', `已创建表 ${name}`); + await loadWorkspace(); + await openTable(state.database, name); + } catch (error) { + region('dlg-table-error').textContent = describe(error); + } +} + +async function createIndexFor(table) { + const dialog = region('dlg-index'); + const column = region('dlg-index-column').value; + if (!column) return; + try { + await api.createIndex(state.database, table, column); + dialog.close(); + log('info', `已在 ${table}.${column} 上建索引`); + await loadWorkspace(); + } catch (error) { + region('dlg-index-error').textContent = describe(error); + } +} + +async function dropCurrentDatabase() { + const name = state.database; + if (!window.confirm(`删除数据库 ${name}?此操作不可撤销。`)) return; + try { + await api.dropDatabase(name, name); + log('info', `已删除数据库 ${name}`); + state.tabs = state.tabs.filter((tab) => tab.kind !== 'table' || tab.database !== name); + if (!state.tabs.some((tab) => tab.id === state.activeId)) state.activeId = state.tabs[0]?.id; + await loadWorkspace(); + renderTabs(); + } catch (error) { + fail(error); + } +} + +async function dropTableByName(table) { + if (!window.confirm(`删除表 ${table}?`)) return; + try { + await api.dropTable(state.database, table); + log('info', `已删除表 ${table}`); + state.tabs = state.tabs.filter((tab) => !(tab.kind === 'table' && tab.table === table)); + if (!state.tabs.some((tab) => tab.id === state.activeId)) state.activeId = state.tabs[0]?.id; + await loadWorkspace(); + renderTabs(); + } catch (error) { + fail(error); + } +} + +async function dropColumnByName(table, column) { + if (!window.confirm(`删除 ${table}.${column}?`)) return; + try { + await api.dropColumn(state.database, table, column); + log('info', `已删除列 ${table}.${column}`); + await loadWorkspace(); + await reloadOpenTables(); + } catch (error) { + fail(error); + } +} + +async function dropIndexByName(table, column) { + if (!window.confirm(`删除 ${table}.${column} 上的索引?`)) return; + try { + await api.dropIndex(state.database, table, column); + log('info', `已删除 ${table}.${column} 上的索引`); + await loadWorkspace(); + } catch (error) { + fail(error); + } +} + +// ---------------------------------------------------------------- 事件代理 + +function startSplitterDrag(kind, event) { + event.preventDefault(); + const workbench = region('workbench'); + const move = (moveEvent) => { + if (kind === 'v') { + const width = Math.min(560, Math.max(180, moveEvent.clientX - 48)); + state.sidebarWidth = width; + workbench.style.setProperty('--sidebar-width', `${width}px`); + } else { + const height = Math.min(window.innerHeight - 160, Math.max(80, window.innerHeight - moveEvent.clientY - 22)); + state.dockHeight = `${height}px`; + workbench.style.setProperty('--dock-height', `${height}px`); + } + }; + const stop = () => { + window.removeEventListener('mousemove', move); + window.removeEventListener('mouseup', stop); + }; + window.addEventListener('mousemove', move); + window.addEventListener('mouseup', stop); +} + +// 这些动作都要求已经选中一个库(服务没有默认库) +const DATABASE_ACTIONS = new Set([ + 'open-table', 'demo-data', 'open-table-dialog', 'create-table', + 'open-index-dialog', 'create-index', 'drop-table', 'drop-column', 'drop-index', + 'drop-database', 'commit', 'rollback', 'copy-sql', +]); + +function needsDatabase(action) { + return DATABASE_ACTIONS.has(action); +} + +function onClick(event) { + const splitter = event.target.closest('[data-split]'); + if (splitter) { + startSplitterDrag(splitter.dataset.split, event); + return; + } + const target = event.target.closest('[data-action]'); + if (!target) return; + const action = target.dataset.action; + const tab = activeTab(); + + // 没有选库时,需要当前库的动作先拦下来(服务端也会拒,这里给人话) + if (!state.database && needsDatabase(action)) { + log('warn', '还没有选择数据库:先在左侧数据库下拉里选一个,或者新建一个库。'); + return; + } + + switch (action) { + case 'logout': + void (async () => { + try { + await api.logout(); + } catch { + // 退出失败也要回到登录页 + } + state.session = null; + state.tabs = []; + state.activeId = undefined; + state.changes = createEmptyChangeSet(); + renderLogin('已退出登录。', 'info'); + })(); + return; + case 'new-query': + openQuery(''); + return; + case 'focus-explorer': { + const sidebar = region('sidebar'); + if (sidebar) sidebar.scrollTop = 0; + return; + } + case 'settings': + openSettings(); + return; + case 'close-dialog': { + const dialog = target.closest('dialog'); + if (dialog) dialog.close(); + return; + } + case 'activate-tab': + activateTab(target.dataset.tab); + return; + case 'close-tab': + closeTabById(target.dataset.tab); + return; + case 'bottom-tab': + state.bottomTab = target.dataset.tab; + renderBottom(); + return; + case 'expand-table': { + const name = target.dataset.table; + if (state.expanded.has(name)) state.expanded.delete(name); + else state.expanded.add(name); + renderSidebar(); + return; + } + case 'open-table': + void openTable(state.database, target.dataset.table); + return; + case 'refresh': + void loadWorkspace().then(reloadOpenTables); + return; + case 'demo-data': + void (async () => { + try { + const report = await api.seedDemoData(state.database); + log('info', `演示数据:新建 ${(report.created ?? []).length} 张,保留 ${(report.skipped ?? []).length} 张。`); + await loadWorkspace(); + await reloadOpenTables(); + } catch (error) { + fail(error); + } + })(); + return; + case 'open-db-dialog': + region('dlg-db-name').value = ''; + region('dlg-db-error').textContent = ''; + region('dlg-db').showModal(); + return; + case 'open-table-dialog': + openCreateTableDialog(); + return; + case 'add-column': + addColumnRow('', 'int'); + return; + case 'remove-column': { + const row = target.closest('.column-row'); + if (row) row.remove(); + return; + } + case 'create-database': + void createDatabase(); + return; + case 'create-table': + void createTable(); + return; + case 'open-index-dialog': + openIndexDialog(target.dataset.table); + return; + case 'create-index': + void createIndexFor(region('dlg-index').dataset.table); + return; + case 'drop-database': + void dropCurrentDatabase(); + return; + case 'drop-table': + void dropTableByName(target.dataset.table); + return; + case 'drop-column': + void dropColumnByName(target.dataset.table, target.dataset.column); + return; + case 'drop-index': + void dropIndexByName(target.dataset.table, target.dataset.column); + return; + case 'sort-column': + if (tab?.kind === 'table') sortColumn(tab, target.dataset.column); + return; + case 'prev-page': + if (tab?.kind === 'table' && tab.data.page > 0) { + tab.data.page -= 1; + void loadTableData(tab); + } + return; + case 'next-page': + if (tab?.kind === 'table') { + tab.data.page += 1; + void loadTableData(tab); + } + return; + case 'reload-table': + if (tab?.kind === 'table') void loadTableData(tab); + return; + case 'insert-row': + if (tab?.kind === 'table') { + const values = {}; + for (const column of tab.data.columns) values[column.name] = null; + if (tab.table === accountsTable) { + values.user = ''; + values.password = ''; + } + stageInsert(tab.table, tab.database, values); + } + return; + case 'delete-selected': + if (tab?.kind === 'table') deleteSelected(tab); + return; + case 'unstage': + removeStaged(target.dataset.key); + return; + case 'unstage-insert': { + const owner = tab?.kind === 'table' ? tab : undefined; + if (owner) removeStaged(insertKey(owner.table, target.dataset.temp)); + return; + } + case 'commit': + void commitAll(); + return; + case 'rollback': + rollbackAll(); + return; + case 'copy-sql': { + const text = changesSql(state.changes).join('\n'); + const clipboard = navigator.clipboard; + if (!clipboard) { + log('warn', '这个浏览器不允许写剪贴板,请手动选中下面的 SQL。'); + return; + } + clipboard.writeText(text).then( + () => log('info', 'SQL 已复制到剪贴板。'), + () => log('warn', '复制失败,请手动选中下面的 SQL。'), + ); + return; + } + case 'run-sql': + if (tab?.kind === 'sql') void runSql(tab); + return; + case 'clear-sql': + if (tab?.kind === 'sql') { + tab.sql = ''; + tab.result = undefined; + renderEditor(); + } + return; + case 'reset-settings': + state.settings = { ...DEFAULT_SETTINGS }; + applySettingsToDom(); + persistSettings(); + renderEditor(); + return; + case 'save-server-settings': + void saveServerSettings(); + return; + case 'edit-cell': { + if (tab?.kind !== 'table') return; + const pk = target.dataset.pk; + const column = target.dataset.column; + const meta = tab.data.columns.find((entry) => entry.name === column); + const row = tab.data.rows.find((entry) => String(entry.id) === String(pk)); + if (!meta || !row) return; + const staged = state.changes.updates.get(changeKey(tab.table, pk, column)); + const oldValue = staged ? staged.oldValue : row[column.name]; + beginCellEdit(target, { + type: meta.type, + currentValue: staged ? staged.newValue : row[column.name], + commit: (value) => { + if (value === undefined) renderEditor(); + else stageUpdate(tab.table, tab.database, pk, column, oldValue, value); + }, + }); + return; + } + case 'edit-insert': { + if (tab?.kind !== 'table') return; + const insert = state.changes.inserts.get(insertKey(tab.table, target.dataset.temp)); + if (!insert) return; + const column = target.dataset.column; + const meta = tab.data.columns.find((entry) => entry.name === column); + beginCellEdit(target, { + type: meta ? meta.type : 'str', + currentValue: insert.values[column], + commit: (value) => { + if (value === undefined) renderEditor(); + else { + insert.values[column] = value; + afterChange(); + } + }, + }); + return; + } + default: + } +} + +function onChange(event) { + const target = event.target; + if (target.dataset.action === 'switch-database') { + void switchDatabase(target.value); + return; + } + if (target.dataset.action === 'filter-column') { + const tab = activeTab(); + if (tab?.kind !== 'table') return; + tab.data.filters = { ...(tab.data.filters ?? {}), [target.dataset.column]: target.value }; + tab.data.page = 0; + void loadTableData(tab); + return; + } + if (target.dataset.action === 'change-page-size') { + const tab = activeTab(); + if (tab?.kind !== 'table') return; + tab.data.pageSize = Number(target.value); + tab.data.page = 0; + void loadTableData(tab); + return; + } + if (target.dataset.action === 'select-row') { + const tab = activeTab(); + if (tab?.kind !== 'table') return; + tab.selected = tab.selected ?? new Set(); + if (target.checked) tab.selected.add(String(target.dataset.pk)); + else tab.selected.delete(String(target.dataset.pk)); + renderEditor(); + return; + } + if (target.dataset.serverSetting) { + target.dataset.dirty = '1'; + return; + } + const setting = target.dataset.setting; + if (!setting) return; + const key = target.dataset.key; + if (setting === 'number') updateSetting(key, clampSetting(key, target.value)); + else if (setting === 'boolean') updateSetting(key, target.checked); + else if (setting === 'fonts') updateSetting(key, fontsFromText(target.value)); + else updateSetting(key, target.value); +} + +function onSubmit(event) { + if (event.target.id !== 'login-form') return; + event.preventDefault(); + const user = region('login-user').value.trim(); + const password = region('login-password').value; + state.loading = true; + renderLogin('', 'info'); + void (async () => { + try { + await api.login(user, password); + state.loading = false; + if (!(await refreshSession())) { + renderLogin('登录成功但会话读取失败,请重试。', 'error'); + return; + } + renderShell(); + renderSidebar(); + renderTabs(); + renderBottom(); + applySettingsToDom(); + await loadWorkspace(); + log('info', `已登录:${state.session.user}`); + } catch (error) { + state.loading = false; + renderLogin(describe(error), 'error'); + } + })(); +} + +function onKeydown(event) { + if (event.key === 'Enter' && event.target.id === 'dlg-db-name') { + event.preventDefault(); + void createDatabase(); + } +} + +// ---------------------------------------------------------------- 启动 + +window.addEventListener(AUTH_EVENT, () => { + if (state.view === 'login') return; + state.session = null; + state.tabs = []; + state.activeId = undefined; + renderLogin('会话已过期,请重新登录。', 'warn'); +}); + +root.addEventListener('click', onClick); +root.addEventListener('change', onChange); +root.addEventListener('submit', onSubmit); +root.addEventListener('keydown', onKeydown); + +async function boot() { + loadLocalSettings(); + applySettingsToDom(); + if (!(await refreshSession())) { + renderLogin('', 'info'); + return; + } + renderShell(); + renderSidebar(); + renderTabs(); + renderBottom(); + applySettingsToDom(); + await loadWorkspace(); + renderEditor(); + await loadRemoteSettings(); + renderEditor(); +} + +void boot(); diff --git a/chusql-web/static/core.js b/chusql-web/static/core.js new file mode 100644 index 0000000..1505575 --- /dev/null +++ b/chusql-web/static/core.js @@ -0,0 +1,497 @@ +// 纯逻辑层:SQL 生成、变更集、筛选、类型、标签页与 IDE 设置默认值。 +// 不碰 DOM、不发请求,方便单独阅读与替换。 + +// ---------------------------------------------------------------- 转义 + +// 所有拼进 innerHTML 的文本都要先过这里(表名、列名、单元格值都是用户数据)。 +export function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +// ---------------------------------------------------------------- SQL 字面量 + +const identifierPattern = /^[A-Za-z_][A-Za-z0-9_]*$/; + +// 账号表:在 system 库里像普通表一样浏览,写入会翻译成账号命令。 +export const accountsTable = '__chusql_users'; + +// 字面量:NULL / 数字 / 布尔原样,其它按字符串加单引号并把单引号翻倍。 +export function sqlLiteral(value) { + if (value === null || value === undefined) return 'NULL'; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return `'${String(value).replaceAll("'", "''")}'`; +} + +// 标识符:白名单外才加双引号。 +export function sqlIdentifier(name) { + const text = String(name); + return identifierPattern.test(text) ? text : `"${text.replaceAll('"', '""')}"`; +} + +// 单元格修改语句。 +export function updateSql(table, pk, column, value) { + if (table === accountsTable) return `ALTER USER ${sqlIdentifier(pk)} IDENTIFIED BY ${sqlLiteral(value)};`; + return `UPDATE ${sqlIdentifier(table)} SET ${sqlIdentifier(column)} = ${sqlLiteral(value)} WHERE id = ${sqlLiteral(pk)};`; +} + +// 插入语句。 +export function insertSql(table, values) { + if (table === accountsTable) { + return `CREATE USER ${sqlIdentifier(values.user ?? '')} IDENTIFIED BY ${sqlLiteral(values.password)};`; + } + const columns = Object.keys(values); + const literals = columns.map((column) => sqlLiteral(values[column])); + return `INSERT INTO ${sqlIdentifier(table)} (${columns.map(sqlIdentifier).join(', ')}) VALUES (${literals.join(', ')});`; +} + +// 删除语句。 +export function deleteSql(table, pk) { + if (table === accountsTable) return `DROP USER ${sqlIdentifier(pk)};`; + return `DELETE FROM ${sqlIdentifier(table)} WHERE id = ${sqlLiteral(pk)};`; +} + +// 一条待提交变更对应的展示用 SQL。 +export function changeSql(change) { + if (change.op === 'insert') return insertSql(change.table, change.values); + if (change.op === 'delete') return deleteSql(change.table, change.pk); + if (change.table === accountsTable) { + return updateSql(change.table, change.pk, 'password', change.set.password); + } + const assignments = Object.entries(change.set) + .map(([column, value]) => `${sqlIdentifier(column)} = ${sqlLiteral(value)}`) + .join(', '); + return `UPDATE ${sqlIdentifier(change.table)} SET ${assignments} WHERE id = ${sqlLiteral(change.pk)};`; +} + +// ---------------------------------------------------------------- 变更集 + +// 空变更集:updates 按"表:主键:列"记,inserts/deletes 按各自的键记。 +export function createEmptyChangeSet() { + return { updates: new Map(), inserts: new Map(), deletes: new Map() }; +} + +export function cloneChangeSet(changes) { + return { + updates: new Map(changes.updates), + inserts: new Map(changes.inserts), + deletes: new Map(changes.deletes), + }; +} + +export function changeCount(changes) { + return changes.updates.size + changes.inserts.size + changes.deletes.size; +} + +// 单元格变更的键。 +export function changeKey(table, pk, column) { + return `${table}:${JSON.stringify(pk)}:${column}`; +} + +// 插入行的键(临时 id,不落库)。 +export function insertKey(table, tempId) { + return `${table}:${tempId}`; +} + +// 删除行的键。 +export function deleteKey(table, pk) { + return `${table}:${JSON.stringify(pk)}`; +} + +// 组装 DML 提交载荷:同一行的多列修改并成一条。 +export function toCommitPayload(changes) { + const updates = new Map(); + for (const update of changes.updates.values()) { + const key = `${update.table}:${JSON.stringify(update.pk)}`; + const existing = updates.get(key); + if (existing) { + existing.set[update.column] = update.newValue; + } else { + updates.set(key, { op: 'update', table: update.table, pk: update.pk, set: { [update.column]: update.newValue } }); + } + } + return [ + ...updates.values(), + ...[...changes.inserts.values()].map((insert) => ({ op: 'insert', table: insert.table, values: { ...insert.values } })), + ...[...changes.deletes.values()].map((deleted) => ({ op: 'delete', table: deleted.table, pk: deleted.pk })), + ]; +} + +// 面板里要显示的一条条变更:[{ key, kind, table, sql, … }] +export function changeEntries(changes) { + const updates = [...changes.updates.entries()].map(([key, update]) => ({ + key, + kind: 'update', + table: update.table, + pk: update.pk, + column: update.column, + sql: changeSql({ op: 'update', table: update.table, pk: update.pk, set: { [update.column]: update.newValue } }), + })); + const inserts = [...changes.inserts.entries()].map(([key, insert]) => ({ + key, + kind: 'insert', + table: insert.table, + tempId: insert.tempId, + sql: changeSql({ op: 'insert', table: insert.table, values: insert.values }), + })); + const deletes = [...changes.deletes.entries()].map(([key, deleted]) => ({ + key, + kind: 'delete', + table: deleted.table, + pk: deleted.pk, + sql: changeSql({ op: 'delete', table: deleted.table, pk: deleted.pk }), + })); + return [...updates, ...inserts, ...deletes]; +} + +// 全部变更的 SQL 预览。 +export function changesSql(changes) { + return toCommitPayload(changes).map(changeSql); +} + +// ---------------------------------------------------------------- 筛选 + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function likePattern(raw) { + const escaped = escapeRegExp(raw).replaceAll('%', '.*').replaceAll('_', '.'); + return new RegExp(`^${escaped}$`, 'i'); +} + +// 表格筛选表达式:null / true / false / like 通配 / 数值比较 / 纯文本包含。 +export function matchesFilter(value, rawExpression) { + const expression = String(rawExpression ?? '').trim(); + if (!expression) return true; + const normalized = expression.toLowerCase(); + + if (normalized === 'null') return value === null || value === undefined; + if (normalized === 'true' || normalized === 'false') return value === (normalized === 'true'); + + const like = expression.match(/^like\s+(.+)$/i); + if (like) return likePattern(like[1]).test(String(value ?? '')); + + const comparison = expression.match(/^(>=|<=|>|<|=|!=)\s*(-?\d+(?:\.\d+)?)$/); + if (comparison) { + const left = Number(value); + const right = Number(comparison[2]); + if (!Number.isFinite(left)) return false; + switch (comparison[1]) { + case '>': return left > right; + case '<': return left < right; + case '>=': return left >= right; + case '<=': return left <= right; + case '!=': return left !== right; + default: return left === right; + } + } + + return String(value ?? '').toLocaleLowerCase().includes(normalized); +} + +// ---------------------------------------------------------------- 列类型 + +const typeLabels = { + int: 'INT', bigint: 'BIGINT', smallint: 'SMALLINT', str: 'STR', text: 'TEXT', + varchar: 'VARCHAR', char: 'CHAR', bool: 'BOOL', boolean: 'BOOL', float: 'FLOAT', + real: 'REAL', double: 'DOUBLE', decimal: 'DECIMAL', numeric: 'NUMERIC', + date: 'DATE', timestamp: 'TIMESTAMP', blob: 'BLOB', +}; + +// 建表对话框里可选的类型。 +export const creatableTypes = [ + 'int', 'bigint', 'smallint', 'str', 'varchar(255)', 'char(1)', 'bool', + 'float', 'double', 'decimal(10,2)', 'date', 'timestamp', 'blob', +]; + +export function columnKind(type) { + return String(type).split('(')[0].trim().toLowerCase(); +} + +export function isIntegerType(type) { + return ['int', 'bigint', 'smallint'].includes(columnKind(type)); +} + +export function isNumericType(type) { + return ['int', 'bigint', 'smallint', 'float', 'double', 'decimal', 'numeric'].includes(columnKind(type)); +} + +export function isBooleanType(type) { + return ['bool', 'boolean'].includes(columnKind(type)); +} + +// 显示名:按基名查标签,参数原样跟在后面(varchar(20) → VARCHAR(20))。 +export function typeLabel(type) { + const kind = columnKind(type); + const label = typeLabels[kind]; + const args = String(type).includes('(') ? String(type).slice(String(type).indexOf('(')) : ''; + if (label) return `${label}${args}`; + return String(type); +} + +// ---------------------------------------------------------------- 值渲染 + +// 单元格显示的文本;null/undefined 用设置里的占位文本。 +export function cellText(value, nullText) { + if (value === null || value === undefined) return nullText; + if (typeof value === 'boolean') return value ? 'true' : 'false'; + return String(value); +} + +// 编辑框里回填的文本(null 给空串)。 +export function editText(value) { + if (value === null || value === undefined) return ''; + return String(value); +} + +// 把编辑框里的文本变回提交给服务的值(空串按 null 处理,与界面上的"清空"一致)。 +export function parseEdited(raw, type) { + const text = String(raw ?? '').trim(); + if (text === '') return null; + if (isIntegerType(type)) { + const n = Number(text); + return Number.isFinite(n) ? Math.trunc(n) : text; + } + if (isNumericType(type)) { + const n = Number(text); + return Number.isFinite(n) ? n : text; + } + if (isBooleanType(type)) { + const lower = text.toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(lower)) return true; + if (['0', 'false', 'no', 'off'].includes(lower)) return false; + return text; + } + return text; +} + +// ---------------------------------------------------------------- SQL 高亮 + +export const SQL_TOKEN_COLORS = { + keyword: '#569CD6', + string: '#CE9178', + number: '#B5CEA8', + comment: '#6A9955', +}; + +const keywords = new Set([ + 'select', 'from', 'where', 'insert', 'into', 'values', 'update', 'set', 'delete', + 'create', 'table', 'index', 'on', 'drop', 'alter', 'and', 'or', 'not', 'null', + 'order', 'by', 'group', 'having', 'limit', 'offset', 'join', 'left', 'right', + 'inner', 'outer', 'as', 'distinct', 'primary', 'key', 'int', 'integer', 'str', + 'text', 'bool', 'boolean', 'true', 'false', 'use', 'show', 'database', 'databases', + 'user', 'identified', 'add', 'column', 'if', 'exists', 'begin', 'commit', 'rollback', +]); + +const wordChar = /[A-Za-z0-9_]/; + +// 切分 SQL 为关键字、字符串、数字、注释与普通片段。 +export function tokenizeSql(sql) { + const tokens = []; + let plain = ''; + let index = 0; + const text = String(sql ?? ''); + const flush = () => { + if (plain) { + tokens.push({ text: plain, kind: 'plain' }); + plain = ''; + } + }; + while (index < text.length) { + const char = text[index]; + if (char === "'") { + flush(); + let end = index + 1; + while (end < text.length) { + if (text[end] !== "'") { end += 1; continue; } + if (text[end + 1] === "'") { end += 2; continue; } + end += 1; + break; + } + tokens.push({ text: text.slice(index, end), kind: 'string' }); + index = end; + continue; + } + if (char === '-' && text[index + 1] === '-') { + flush(); + const end = text.indexOf('\n', index); + tokens.push({ text: end < 0 ? text.slice(index) : text.slice(index, end), kind: 'comment' }); + index = end < 0 ? text.length : end; + continue; + } + if (char >= '0' && char <= '9') { + flush(); + let end = index; + while (end < text.length && (wordChar.test(text[end]) || text[end] === '.')) end += 1; + tokens.push({ text: text.slice(index, end), kind: 'number' }); + index = end; + continue; + } + if (wordChar.test(char)) { + let end = index; + while (end < text.length && wordChar.test(text[end])) end += 1; + const word = text.slice(index, end); + if (keywords.has(word.toLowerCase())) { + flush(); + tokens.push({ text: word, kind: 'keyword' }); + } else { + plain += word; + } + index = end; + continue; + } + plain += char; + index += 1; + } + flush(); + return tokens; +} + +// 高亮片段转 HTML(词法切分的结果,文本仍然要转义)。 +export function highlightHtml(sql) { + return tokenizeSql(sql) + .map((token) => (token.kind === 'plain' ? escapeHtml(token.text) : `${escapeHtml(token.text)}`)) + .join(''); +} + +// ---------------------------------------------------------------- 标签页 + +// 打开表标签:同库同表已开则激活,否则追加。 +export function openTableTab(tabs, database, name, id) { + const existing = tabs.find((tab) => tab.kind === 'table' && tab.table === name && tab.database === database); + if (existing) return { tabs, activeId: existing.id }; + return { tabs: [...tabs, { id, kind: 'table', title: name, table: name, database }], activeId: id }; +} + +// 打开查询标签:每次追加。 +export function openQueryTab(tabs, title, id, database, sql) { + return { tabs: [...tabs, { id, kind: 'sql', title, sql, database }], activeId: id }; +} + +// 打开设置标签:只保留一个。 +export function openSettingsTab(tabs, title, id) { + const existing = tabs.find((tab) => tab.kind === 'settings'); + if (existing) return { tabs, activeId: existing.id }; + return { tabs: [...tabs, { id, kind: 'settings', title }], activeId: id }; +} + +// 关闭标签:关当前标签时激活右邻,无则左邻。 +export function closeTab(tabs, id, activeId) { + const index = tabs.findIndex((tab) => tab.id === id); + if (index < 0) return { tabs, activeId }; + const remaining = tabs.filter((tab) => tab.id !== id); + if (activeId !== id) return { tabs: remaining, activeId }; + const next = remaining[index] ?? remaining[index - 1]; + return { tabs: remaining, activeId: next ? next.id : undefined }; +} + +// ---------------------------------------------------------------- IDE 设置 + +// 默认 IDE 设置(与后端 /api/ui-settings 的键一一对应)。 +export const DEFAULT_SETTINGS = Object.freeze({ + uiFonts: ['JetBrains Mono', 'Consolas', 'Cascadia Code'], + uiFontSize: 13, + gridFonts: ['JetBrains Mono', 'Consolas'], + gridFontSize: 12, + gridRowHeight: 24, + sqlFonts: ['JetBrains Mono', 'Consolas'], + sqlFontSize: 13, + sqlLineHeight: 20, + sqlTabSize: 2, + sqlLineNumbers: true, + pageSize: 200, + nullText: 'NULL', + autocomplete: true, + minimap: false, +}); + +export const fontKeys = ['uiFonts', 'gridFonts', 'sqlFonts']; +export const booleanKeys = ['sqlLineNumbers', 'autocomplete', 'minimap']; +export const numberKeys = ['uiFontSize', 'gridFontSize', 'gridRowHeight', 'sqlFontSize', 'sqlLineHeight', 'sqlTabSize', 'pageSize']; + +export const settingRanges = { + uiFontSize: [11, 16], + gridFontSize: [10, 18], + gridRowHeight: [18, 40], + sqlFontSize: [10, 20], + sqlLineHeight: [16, 40], + sqlTabSize: [1, 8], + pageSize: [10, 500], +}; + +export const settingLabels = { + uiFonts: '界面字体链', + uiFontSize: '界面字号', + gridFonts: '表格字体链', + gridFontSize: '表格字号', + gridRowHeight: '表格行高', + sqlFonts: 'SQL 字体链', + sqlFontSize: 'SQL 字号', + sqlLineHeight: 'SQL 行高', + sqlTabSize: 'SQL Tab 宽度', + sqlLineNumbers: '显示行号', + pageSize: '每页行数', + nullText: 'NULL 显示文本', + autocomplete: '自动补全', + minimap: '缩略图', +}; + +export const IDE_SETTINGS_KEY = 'chusql.ide.settings.v1'; + +// 数值收敛到区间并取整。 +export function clampSetting(key, value) { + const range = settingRanges[key]; + const numeric = Number(value); + if (!Number.isFinite(numeric)) return DEFAULT_SETTINGS[key]; + const rounded = Math.round(numeric); + return range ? Math.min(range[1], Math.max(range[0], rounded)) : rounded; +} + +// 字体链 → CSS font-family 值。 +export function fontFamilyValue(chain) { + const fonts = chain + .map((font) => String(font).trim()) + .filter(Boolean) + .map((font) => `"${font.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`); + return [...fonts, 'monospace'].join(', '); +} + +// 只取已知键、按类型收敛;坏值丢弃。 +export function parseUiSettings(body) { + const parsed = {}; + if (!body || typeof body !== 'object' || Array.isArray(body)) return parsed; + for (const key of fontKeys) { + const value = body[key]; + if (!Array.isArray(value)) continue; + const fonts = value.filter((font) => typeof font === 'string' && font.trim().length > 0).slice(0, 10); + if (fonts.length) parsed[key] = fonts; + } + for (const key of numberKeys) { + const value = body[key]; + if (typeof value !== 'number' || !Number.isFinite(value)) continue; + parsed[key] = clampSetting(key, value); + } + for (const key of booleanKeys) { + if (typeof body[key] === 'boolean') parsed[key] = body[key]; + } + if (typeof body.nullText === 'string') parsed.nullText = body.nullText; + return parsed; +} + +// 逗号分隔的字体链 ↔ 数组。 +export function fontsFromText(text) { + return String(text ?? '') + .split(',') + .map((font) => font.trim()) + .filter(Boolean) + .slice(0, 10); +} + +export function fontsToText(chain) { + return chain.join(', '); +} diff --git a/chusql-web/static/index.html b/chusql-web/static/index.html new file mode 100644 index 0000000..fff4fda --- /dev/null +++ b/chusql-web/static/index.html @@ -0,0 +1,19 @@ + + + + + + + + + + ChuSQL 数据库终端 + + + + +
    + + + \ No newline at end of file diff --git a/chusql-web/static/style.css b/chusql-web/static/style.css new file mode 100644 index 0000000..2ca69ab --- /dev/null +++ b/chusql-web/static/style.css @@ -0,0 +1,497 @@ +/* ChuSQL Web IDE 样式:VS Code Dark+ 风格,纯手写,无框架。 + 字体与字号由 app.js 通过 CSS 变量(--ui-font 等)按 IDE 设置覆盖。 */ + +:root { + --bg-base: #1e1e1e; + --bg-elevated: #252526; + --bg-overlay: #2d2d30; + --bg-active: #37373d; + --bg-titlebar: #3c3c3c; + --bg-statusbar: #007acc; + --border: #3c3c3c; + --border-subtle: #2d2d2d; + --fg-primary: #cccccc; + --fg-muted: #858585; + --fg-disabled: #5a5a5a; + --accent: #007acc; + --accent-2: #0e639c; + --success: #4ec9b0; + --warning: #cca700; + --danger: #f14c4c; + --modified: #e2c08d; + --info: #9cdcfe; + --syn-keyword: #569cd6; + --syn-string: #ce9178; + --syn-number: #b5cea8; + --syn-comment: #6a9955; + --ui-font: "JetBrains Mono", Consolas, "Cascadia Code", monospace; + --ui-font-size: 13px; + --grid-font: "JetBrains Mono", Consolas, monospace; + --grid-font-size: 12px; + --grid-row-height: 24px; + --sql-font: "JetBrains Mono", Consolas, monospace; + --sql-font-size: 13px; + --sql-line-height: 20px; + --sql-tab-size: 2; + color-scheme: dark; +} + +* { box-sizing: border-box; } + +html, body, #root { height: 100%; margin: 0; overflow: hidden; } + +body { + background: var(--bg-base); + color: var(--fg-primary); + font-family: var(--ui-font); + font-size: var(--ui-font-size); +} + +button, input, select, textarea { font-family: inherit; font-size: inherit; } +button { border-radius: 2px; cursor: pointer; } +button:disabled { cursor: default; opacity: 0.45; } +button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { + outline: 1px solid var(--accent); + outline-offset: -1px; +} + +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--bg-active); } + +.muted { color: var(--fg-muted); } +.flex-spacer { flex: 1; } +.sep { width: 1px; align-self: stretch; background: var(--border); margin: 3px 4px; } +.inline { display: inline-flex; align-items: center; gap: 5px; } +.alert { margin: 4px 0; } +.alert-error { color: var(--danger); } +.alert-warn { color: var(--warning); } +.alert-info { color: var(--info); } + +/* ---------------------------------------------------------------- 按钮 / 表单 */ + +button:not(.mini):not(.tab-label):not(.tree-label):not(.column-head):not(.link):not(.splitter-v):not(.splitter-h) { + background: var(--bg-active); + border: 1px solid var(--border); + color: var(--fg-primary); + min-height: 24px; + padding: 0 8px; +} +button:not(.mini):not(.tab-label):not(.tree-label):not(.column-head):not(.link):not(.splitter-v):not(.splitter-h):hover:not(:disabled) { + background: #2f2f33; +} +button.primary { background: var(--accent-2); border-color: var(--accent); color: #ffffff; } +button.primary:hover:not(:disabled) { background: #1177bb; } +button.plain { background: transparent; border: 1px dashed var(--border); color: var(--fg-muted); } +button.link { + background: none; + border: 0; + color: var(--info); + padding: 0; + text-decoration: underline; +} +button.mini { + background: transparent; + border: 1px solid transparent; + color: var(--fg-muted); + height: 20px; + min-width: 20px; + padding: 0 3px; + line-height: 1; +} +button.mini:hover:not(:disabled) { background: var(--bg-active); color: var(--fg-primary); } +button.mini.danger:hover:not(:disabled) { color: var(--danger); } + +input, select, textarea { + background: var(--bg-base); + border: 1px solid var(--border); + color: var(--fg-primary); + min-height: 22px; + padding: 1px 5px; +} +input[type="checkbox"] { min-height: 0; } + +.field { display: grid; gap: 4px; margin: 6px 0; } +.field > span { color: var(--fg-muted); } +.field small { font-size: 11px; } +.toggle { display: inline-flex; align-items: center; gap: 6px; margin: 6px 12px 6px 0; } + +/* ---------------------------------------------------------------- 登录页 */ + +.auth-view { height: 100%; display: flex; align-items: center; justify-content: center; background: var(--bg-base); } +.auth-card { + width: 340px; + background: var(--bg-elevated); + border: 1px solid var(--border); + padding: 18px; + display: grid; + gap: 10px; +} +.auth-card header { display: flex; align-items: center; gap: 8px; } +.auth-card h1 { margin: 0; font-size: 14px; font-weight: 500; } +.auth-card label { display: grid; gap: 4px; } +.auth-card label span { color: var(--fg-muted); font-size: 11px; } +.auth-card input { height: 26px; } +.auth-hint { color: var(--fg-muted); font-size: 11px; margin: 0; } +.brand-mark { color: var(--accent); font-weight: 700; letter-spacing: 0.04em; } + +/* ---------------------------------------------------------------- 工作台布局 */ + +.workbench { + --sidebar-width: 250px; + --dock-height: 22vh; + display: grid; + grid-template-columns: 48px var(--sidebar-width) minmax(0, 1fr); + grid-template-rows: 34px minmax(0, 1fr) var(--dock-height) 22px; + height: 100vh; + min-height: 360px; + position: relative; +} +.titlebar { + grid-column: 1 / -1; + grid-row: 1; + background: var(--bg-titlebar); + display: flex; + align-items: center; + gap: 10px; + padding: 0 8px 0 12px; +} +.brand { display: inline-flex; align-items: baseline; gap: 7px; } +.brand-name { color: var(--fg-muted); font-size: 11px; } +.titlebar-actions { display: flex; gap: 2px; margin-left: auto; } +.account { display: inline-flex; align-items: center; gap: 8px; margin-left: 10px; } +.account-name { color: var(--fg-muted); } + +.activitybar { + grid-column: 1; + grid-row: 2 / 4; + background: #333333; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding-top: 12px; +} +.activitybar button { background: none; border: 0; color: var(--fg-muted); font-size: 16px; width: 100%; height: 28px; } +.activitybar button:hover { color: var(--fg-primary); } + +.sidebar { + grid-column: 2; + grid-row: 2 / 4; + background: var(--bg-elevated); + border-right: 1px solid var(--border); + overflow: auto; + min-width: 0; +} +.sidebar h2 { + margin: 0; + padding: 11px 12px; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.12em; +} +.side-section { padding-bottom: 10px; } +.side-title { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 8px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; +} +.side-actions { margin-left: auto; display: flex; gap: 3px; } +.side-row { display: flex; align-items: center; gap: 4px; padding: 0 8px; } +.side-row select { flex: 1; min-width: 0; } + +.table-list, .column-list, .index-list { list-style: none; margin: 0; padding: 0; } +.tree-row { display: flex; align-items: center; gap: 4px; height: 22px; padding: 0 6px; } +.tree-row small { color: var(--fg-muted); } +.tree-row small.warn { color: var(--warning); } +.tree-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; + background: none; + border: 0; + border-left: 2px solid transparent; + color: var(--fg-primary); + height: 22px; +} +.tree-label:hover { background: var(--bg-active); } +.column-list li, .index-list li { display: flex; align-items: center; gap: 6px; height: 22px; padding-left: 34px; } +.index-list li { padding-left: 34px; color: var(--fg-muted); } +.col-type { color: var(--info); font-size: 11px; } +.col-marks { display: inline-flex; gap: 3px; font-size: 9px; } +.mark-pk { color: var(--modified); } +.mark-ix { color: var(--success); } +.mark-nn { color: var(--fg-muted); } +.col-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.column-list li .mini, .index-list li .mini { margin-left: auto; } +.tree-actions { padding: 2px 6px 4px 34px; } + +.editor { + grid-column: 3; + grid-row: 2; + display: grid; + grid-template-rows: 30px minmax(0, 1fr); + min-width: 0; + min-height: 0; + overflow: hidden; +} +.tabbar { + background: var(--bg-elevated); + border-bottom: 1px solid var(--border); + display: flex; + align-items: stretch; + overflow-x: auto; +} +.tab { display: flex; align-items: center; background: var(--bg-base); border-right: 1px solid var(--border); border-top: 1px solid transparent; } +.tab-active { border-top-color: var(--accent); } +.tab-label { + background: none; + border: 0; + color: var(--fg-muted); + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 0 8px 0 12px; +} +.tab-active .tab-label { color: var(--fg-primary); } +.tab-empty { padding: 6px 10px; } +.tab-content { min-height: 0; overflow: hidden; } + +.welcome { height: 100%; display: flex; align-items: center; justify-content: center; color: var(--fg-muted); text-align: center; } +.welcome h1 { font-size: 16px; font-weight: 500; color: var(--fg-primary); } +.notice { padding: 16px; color: var(--fg-muted); } +.empty { text-align: center; color: var(--fg-muted); padding: 28px; } + +.bottom { + grid-column: 3; + grid-row: 3; + background: var(--bg-elevated); + border-top: 1px solid var(--border); + display: grid; + grid-template-rows: 28px minmax(0, 1fr); + min-height: 0; + overflow: hidden; +} +.bottom-head { display: flex; align-items: center; gap: 3px; border-bottom: 1px solid var(--border); padding: 0 4px; } +.bottom-head > button:not(.mini) { background: none; border: 0; color: var(--fg-primary); height: 100%; padding: 0 9px; } +.bottom-head > button.active { border-bottom: 1px solid var(--accent); } +.bottom-body { overflow: auto; padding: 4px 8px; } +.change-list, .message-list { list-style: none; margin: 0; padding: 0; } +.change-list li { + display: grid; + grid-template-columns: 160px minmax(0, 1fr) 24px; + align-items: center; + gap: 10px; + min-height: 26px; + border-bottom: 1px solid var(--border-subtle); +} +.change-list code { font-family: var(--ui-font); color: #c8c8c8; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.change-table { color: var(--info); } +.message-list li { min-height: 22px; border-bottom: 1px solid var(--border-subtle); } +.msg-time { color: var(--fg-muted); margin-right: 8px; } +.msg-error { color: var(--danger); } +.msg-warn { color: var(--warning); } +.sql-preview { margin-top: 6px; } +.sql-preview pre { white-space: pre-wrap; color: #c8c8c8; margin: 4px 0; } + +.statusbar { + grid-column: 1 / -1; + grid-row: 4; + background: var(--bg-statusbar); + color: #ffffff; + display: flex; + align-items: center; + gap: 16px; + padding: 0 8px; +} + +.splitter-v { + position: absolute; + top: 34px; + bottom: 22px; + left: calc(48px + var(--sidebar-width) - 2px); + width: 5px; + padding: 0; + border: 0; + background: transparent; + cursor: col-resize; + z-index: 6; +} +.splitter-h { + position: absolute; + left: calc(48px + var(--sidebar-width)); + right: 0; + bottom: calc(22px + var(--dock-height) - 2px); + height: 5px; + padding: 0; + border: 0; + background: transparent; + cursor: row-resize; + z-index: 6; +} +.splitter-v:hover, .splitter-h:hover { background: var(--accent); } + +/* ---------------------------------------------------------------- 工具栏 */ + +.toolbar { + display: flex; + align-items: center; + gap: 4px; + padding: 3px 6px; + background: var(--bg-elevated); + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} +.page-info { color: var(--fg-muted); padding: 0 4px; } + +/* ---------------------------------------------------------------- 数据表格 */ + +.table-editor { display: grid; grid-template-rows: auto minmax(0, 1fr); height: 100%; min-height: 0; } +.grid-scroll { overflow: auto; min-height: 0; position: relative; } +.data-grid { + border-collapse: collapse; + table-layout: fixed; + font-family: var(--grid-font); + font-size: var(--grid-font-size); + min-width: 100%; +} +.data-grid th, .data-grid td { + border-bottom: 1px solid var(--border-subtle); + border-right: 1px solid var(--border); + height: var(--grid-row-height); + text-align: left; + padding: 0 7px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.data-grid thead { background: var(--bg-elevated); position: sticky; top: 0; z-index: 2; } +.data-grid thead th { color: var(--info); font-weight: 400; } +.column-head { background: none; border: 0; color: var(--info); padding: 0; text-align: left; height: 100%; } +.data-grid thead small { float: right; color: var(--fg-muted); font-size: 10px; } +.filter-row th { position: sticky; top: var(--grid-row-height); background: var(--bg-elevated); z-index: 2; } +.filter-input { width: 100%; height: 20px; padding: 0 4px; } +.row-number { width: 46px; min-width: 46px; text-align: right; color: var(--fg-muted); background: var(--bg-elevated); } +.select-cell { width: 28px; min-width: 28px; } +.data-grid tbody tr:hover { background: #ffffff0a; } +.data-grid td[data-action="edit-cell"] { cursor: text; } +.dirty { background: #e2c08d14; position: relative; } +.dirty::before { + content: ""; + position: absolute; + top: 0; + left: 0; + border-top: 5px solid var(--modified); + border-right: 5px solid transparent; +} +.row-deleted td { color: var(--fg-disabled); text-decoration: line-through; } +.row-inserted td { border-left: 2px solid var(--modified); } +.inserted { background: #4ec9b00d; } +.null-value { color: var(--fg-muted); font-style: italic; } +.cell-input { + width: calc(100% + 14px); + height: calc(var(--grid-row-height) - 2px); + margin: 0 -7px; + border: 1px solid var(--accent); + background: var(--bg-base); + color: var(--fg-primary); + font: inherit; + padding: 0 6px; +} +.grid-loading { position: sticky; bottom: 0; left: 0; background: var(--bg-elevated); color: var(--info); padding: 4px 8px; } + +/* ---------------------------------------------------------------- SQL 控制台 */ + +.sql-console { display: grid; grid-template-rows: auto minmax(120px, 1fr) minmax(0, 1fr); height: 100%; min-height: 0; } +.sql-editor { display: flex; min-height: 0; position: relative; background: var(--bg-base); border-bottom: 1px solid var(--border); } +.sql-gutter { + margin: 0; + padding: 6px 8px 6px 4px; + color: var(--fg-muted); + text-align: right; + overflow: hidden; + white-space: pre; + font-family: var(--sql-font); + font-size: var(--sql-font-size); + line-height: var(--sql-line-height); + background: var(--bg-elevated); + user-select: none; + min-width: 38px; +} +.sql-layer { position: relative; flex: 1; min-width: 0; } +.sql-highlight, .sql-input { + margin: 0; + padding: 6px 8px; + font-family: var(--sql-font); + font-size: var(--sql-font-size); + line-height: var(--sql-line-height); + tab-size: var(--sql-tab-size); + white-space: pre; + overflow: auto; + border: 0; +} +.sql-highlight { position: absolute; inset: 0; pointer-events: none; color: #c8c8c8; } +.sql-input { position: absolute; inset: 0; width: 100%; height: 100%; background: transparent; color: transparent; caret-color: #ffffff; resize: none; } +.sql-input::selection { background: #264f78; } +.sql-minimap { width: 60px; background: var(--bg-elevated); border-left: 1px solid var(--border); position: relative; } +.minimap-thumb { position: absolute; left: 0; right: 0; background: #ffffff1f; } +.tok-keyword { color: var(--syn-keyword); } +.tok-string { color: var(--syn-string); } +.tok-number { color: var(--syn-number); } +.tok-comment { color: var(--syn-comment); } +.sql-result { overflow: auto; min-height: 0; padding: 0 8px 8px; } +.result-scroll { overflow: auto; } +.result-grid { border-collapse: collapse; margin-top: 6px; } +.result-grid th, .result-grid td { border: 1px solid var(--border); text-align: left; min-width: 100px; padding: 3px 6px; } +.result-grid th { background: var(--bg-elevated); color: var(--info); } + +/* ---------------------------------------------------------------- 设置 */ + +.settings-view { overflow: auto; height: 100%; padding: 0 18px 24px; } +.settings-view header { position: sticky; top: 0; background: var(--bg-base); z-index: 2; } +.settings-view h1 { font-size: 18px; font-weight: 500; margin: 14px 0 6px; } +.settings-group { border: 1px solid var(--border); margin: 12px 0; padding: 4px 12px 10px; } +.settings-group legend { color: var(--info); padding: 0 5px; font-weight: 700; } +.settings-footer { display: flex; align-items: center; gap: 10px; border-top: 1px solid var(--border); margin-top: 8px; padding-top: 8px; } +.preview { border: 1px solid var(--border-subtle); background: var(--bg-base); padding: 8px; margin: 12px 0; } +.preview strong { color: var(--fg-muted); display: block; margin-bottom: 4px; } +.server-settings { border-top: 1px solid var(--border); margin-top: 14px; } + +/* ---------------------------------------------------------------- 对话框 */ + +.dialog { + background: var(--bg-overlay); + border: 1px solid var(--border); + color: var(--fg-primary); + padding: 0; + width: 620px; + max-width: min(760px, 92vw); + max-height: min(760px, 88vh); +} +.dialog::backdrop { background: #0000008c; } +.dialog-body { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; max-height: 88vh; } +.dialog-header { background: var(--bg-titlebar); height: 34px; display: flex; align-items: center; padding: 0 10px; } +.dialog-header h2 { margin: 0; font-size: 13px; } +.dialog-content { padding: 12px; overflow: auto; } +.dialog-footer { display: flex; justify-content: flex-end; gap: 8px; padding: 8px; border-top: 1px solid var(--border); } +.column-rows { display: grid; gap: 6px; } +.column-row { display: grid; grid-template-columns: minmax(0, 1fr) 140px 24px; gap: 6px; align-items: center; } + +/* ---------------------------------------------------------------- 窄屏 */ + +@media (max-width: 720px) { + .workbench { grid-template-columns: 40px minmax(0, 1fr); } + .sidebar, .splitter-v { display: none; } + .editor, .bottom { grid-column: 2; } + .splitter-h { left: 40px; } + .brand-name { display: none; } +} diff --git a/chusql-web/test/Spec.hs b/chusql-web/test/Spec.hs index a38ff5a..34a8024 100644 --- a/chusql-web/test/Spec.hs +++ b/chusql-web/test/Spec.hs @@ -1,10 +1,14 @@ {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} module Main (main) where -import ChuSQL.Model (Column (..), Database, Table (..), Value (..)) -import ChuSQL.Storage.IPC (SchemaColumn (..), TableInfo (..)) -import ChuSQL.Syntax.AST (Expr (..), FromClause (..), Statement (..)) +import ChuSQL.Model (ColumnType (..), Database, Table (..), Value (..), plainColumn, pattern TInt, pattern TStr, pattern TBool) +import ChuSQL.Storage.IPC (Account (..), Request (..), SchemaColumn (..), TableInfo (..), setPipeName) +import ChuSQL.Web.Accounts +import Data.Either (isLeft, isRight) +import ChuSQL.Syntax.AST (Expr (..), FromClause (..), JoinKind (..), Statement (..)) +import ChuSQL.Syntax.Parser (parseStatement) import ChuSQL.Web.Actions ( ColumnSpec (..), CreateTableSpec (..), @@ -22,7 +26,7 @@ import ChuSQL.Web.Actions ( sqlLiteral, updateRowSql, ) -import ChuSQL.Web.Api ( +import ChuSQL.Web.API ( AppEnv (..), Live (..), defaultLive, @@ -38,7 +42,6 @@ import ChuSQL.Web.Auth ( SessionPolicy (..), createSession, defaultSessionPolicy, - deleteOtherSessions, deleteSession, hashLooksValid, hashPasswordWith, @@ -49,42 +52,43 @@ import ChuSQL.Web.Auth ( setSessionPolicy, verifyPassword, ) -import ChuSQL.Web.Backend (columnsFromStatement, ipcBackend, memoryBackend, tableInfoOf) +import ChuSQL.Web.Backend (beAccounts, columnsFromStatement, inDatabase, ipcBackend, memoryBackend, tableInfoOf) import ChuSQL.Web.Config ( WebConfig (..), + canonicalSettingKeys, defaultPassword, defaultUser, defaultWebConfig, - loadWebConfig, + loadWebConfigAt, resolveCredential, + resolvePipeName, usingDefaultCredentials, ) import ChuSQL.Web.RateLimit (newRateLimiter, rateLimitBlock, rateLimitRecord, setRateLimit) import ChuSQL.Web.Settings ( SettingItem (..), applySettings, - defaultOf, effectiveSettings, findItem, isRestartRequired, isRootOnly, liveKeys, readSettingsFile, - resolveSettingsFile, settingCatalogue, writeSettingsFile, ) import ChuSQL.Web.Static (contentTypeOf, readStatic, safeRelative) -import ChuSQL.Web.StorageProcess (StorageProcess (..), startStorageProcess, stopStorageProcess) -import ChuSQL.Web.UiSettings ( - readUiSettings, - resolveUiSettingsFile, +import ChuSQL.Web.StorageProcess (StorageProcess (..), platformBinaryName, startStorageProcess, storageChildArgs, stopStorageProcess, waitForStorage) +import ChuSQL.Web.TOML (defaultConfigFile, resolveConfigPath) +import ChuSQL.Web.UISettings ( + readUISettings, uiSettingsFileCandidates, - validateUiSettings, - writeUiSettings, + validateUISettings, + writeUISettings, ) import Control.Concurrent.MVar (newMVar) import Control.Exception (IOException, bracket, finally, try) +import Control.Monad (forM_) import Data.Aeson (decode) import qualified Data.Aeson as A import qualified Data.Aeson.Key as K @@ -93,7 +97,7 @@ import qualified Data.ByteString as BS import qualified Data.ByteString.Char8 as BSC import qualified Data.ByteString.Lazy as BL import Data.IORef (newIORef, readIORef, writeIORef) -import Data.List (isInfixOf, isSuffixOf, nub, sort) +import Data.List (isInfixOf, isPrefixOf, nub, partition, sort) import qualified Data.Map.Strict as Map import Data.Maybe (fromMaybe) import Data.Text (Text) @@ -101,6 +105,7 @@ import qualified Data.Text as T import qualified Data.Text.Encoding as TE import Data.Time.Calendar (fromGregorian) import Data.Time.Clock (UTCTime (..), addUTCTime, getCurrentTime) +import Data.Unique (hashUnique, newUnique) import qualified Data.Vector as V import Network.HTTP.Client ( Manager, @@ -116,25 +121,42 @@ import Network.HTTP.Types (Header, Method, methodGet, methodPost, statusCode) import Network.Wai (Application) import qualified Network.Wai.Handler.Warp as Warp import qualified Network.Wai.Test as WT -import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getTemporaryDirectory) +import System.Directory (XdgDirectory (XdgConfig), createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getTemporaryDirectory, getXdgDirectory, removePathForcibly) import qualified System.Directory -import System.Environment (lookupEnv, setEnv, unsetEnv) +import System.Environment (getEnvironment, lookupEnv, setEnv, unsetEnv) import System.Exit (ExitCode (..)) import System.FilePath (()) import System.IO (Handle) +import System.Info (os) import System.Process ( CreateProcess (cwd, std_err, std_out), ProcessHandle, StdStream (..), createProcess, proc, + readCreateProcessWithExitCode, waitForProcess, ) import Test.Hspec +import qualified System.Process as Process import Test.Hspec.Wai hiding (pendingWith) --- ChuSQL Web 测试,覆盖三层: --- 纯函数、路由级(内存后端)、端到端(真 Rust 存储进程)。 +-- ChuSQL Web 测试:纯函数、路由级(内存后端)、端到端(真 Rust 存储进程)。 + +-- | 夹具库名:服务不再有默认库,测试世界里这一个库扮演"已选中的库"。 +-- 它只是个普通库名(不再是保留名)——保留的只有 system。 +testDatabaseName :: String +testDatabaseName = "test" + +-- | 夹具库的请求头 +testDatabaseHeader :: Header +testDatabaseHeader = ("X-ChuSQL-Database", TE.encodeUtf8 (T.pack testDatabaseName)) + +-- | 没有显式指定库的请求都落到夹具库;要测"没选库"的用例请直接用 `request` +fixtureHeaders :: [Header] -> [Header] +fixtureHeaders hs + | any ((== "X-ChuSQL-Database") . fst) hs = hs + | otherwise = testDatabaseHeader : hs -- | users 5 行 + orders 1 行 testDb :: Database @@ -169,28 +191,38 @@ policy = SessionPolicy (8 * 3600) (24 * 3600) -- | 造一条 `SELECT *` selectStar :: FromClause -> Statement -selectStar from = Select ["*"] from Nothing [] Nothing +selectStar from = Select ["*"] from Nothing [] [] Nothing -- | 造一条显式列名的 SELECT selectThese :: [String] -> FromClause -> Statement -selectThese cols from = Select cols from Nothing [] Nothing +selectThese cols from = Select cols from Nothing [] [] Nothing -- | with 块里的会话类型别名 type TestSession a = WaiSession () a -- | 每个用例组一套:内存后端 + 可控时钟 + 临时静态目录 freshWorld :: FilePath -> IO AppEnv -freshWorld staticDir = do +freshWorld staticDir = freshWorldWith staticDir testCredential + +-- | 同上,但换一个 root 凭据(免密用例用) +freshWorldWith :: FilePath -> Credential -> IO AppEnv +freshWorldWith staticDir credential = do db <- newMVar testDb clock <- newIORef epoch sessions <- newSessionStore (readIORef clock) policy limiter <- newRateLimiter (readIORef clock) 5 (5 * 60) - env <- newAppEnv (memoryBackend db) sessions testCredential limiter staticDir + env <- newAppEnv (memoryBackend testDatabaseName db) sessions credential limiter staticDir path <- tempSettingsPath "default" removeIfExists path - uiPath <- tempUiSettingsPath "default" + uiPath <- tempUISettingsPath "default" removeIfExists uiPath - pure env{aeSettingsFile = path, aeUiSettingsFile = uiPath, aeEffective = Map.empty} + pure env{aeSettingsFile = path, aeUISettingsFile = uiPath, aeEffective = Map.empty} + +-- | 免密管理员的世界:空口令的凭据等价于"只允许管理员免密登录" +withPasswordlessWorld :: FilePath -> (AppEnv -> SpecWith ((), Application)) -> Spec +withPasswordlessWorld staticDir body = do + appEnv <- runIO (freshWorldWith staticDir (Credential "admin" "")) + with (webApp appEnv) (body appEnv) -- | 临时设置文件(每个用例组一个名字,互不干扰) tempSettingsPath :: String -> IO FilePath @@ -199,8 +231,8 @@ tempSettingsPath name = do pure (tmp ("chusql-web-test-" ++ name ++ "-settings.json")) -- | 临时 IDE 设置文件(同样按用例组取名) -tempUiSettingsPath :: String -> IO FilePath -tempUiSettingsPath name = do +tempUISettingsPath :: String -> IO FilePath +tempUISettingsPath name = do tmp <- getTemporaryDirectory pure (tmp ("chusql-web-test-" ++ name ++ "-ui-settings.json")) @@ -220,9 +252,9 @@ freshWorldAt staticDir name = do env <- freshWorld staticDir path <- tempSettingsPath name removeIfExists path - uiPath <- tempUiSettingsPath name + uiPath <- tempUISettingsPath name removeIfExists uiPath - pure env{aeSettingsFile = path, aeUiSettingsFile = uiPath} + pure env{aeSettingsFile = path, aeUISettingsFile = uiPath} -- | 临时静态目录,内容固定便于断言 makeStaticDir :: IO FilePath @@ -288,11 +320,15 @@ sessionCookieOf res = do raw <- lookup "Set-Cookie" (WT.simpleHeaders res) parseCookieHeader "chusql_session" (TE.decodeUtf8 raw) --- | 会话令牌变请求头 +-- | 会话令牌变请求头;顺带带上夹具库——服务没有默认库,缺头就是"没选库" cookieHeaders :: WT.SResponse -> [Header] cookieHeaders res = case sessionCookieOf res of Nothing -> [] - Just token -> [("Cookie", TE.encodeUtf8 ("chusql_session=" <> token))] + Just token -> [("Cookie", TE.encodeUtf8 ("chusql_session=" <> token)), testDatabaseHeader] + +-- | 把请求指到系统库 system(账号表与 system 库本身都在那里) +systemHeaders :: [Header] -> [Header] +systemHeaders hs = ("X-ChuSQL-Database", "system") : filter ((/= "X-ChuSQL-Database") . fst) hs -- | 带 JSON 体的请求 jsonRequest :: Method -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse @@ -311,17 +347,26 @@ loginAs user password = jsonRequest methodPost "/api/login" (loginBody user pass -- | 带请求头的 GET getWith :: [Header] -> BS.ByteString -> TestSession WT.SResponse -getWith headers path = request methodGet path headers "" +getWith headers path = request methodGet path (fixtureHeaders headers) "" -- | 带请求头的 JSON POST postWith :: [Header] -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse postWith headers path payload = - request methodPost path (("Content-Type", "application/json") : headers) payload + request methodPost path (fixtureHeaders (("Content-Type", "application/json") : headers)) payload -- | 带请求头的 JSON PUT(改设置用的是 PUT) putWith :: [Header] -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse putWith headers path payload = - request "PUT" path (("Content-Type", "application/json") : headers) payload + request "PUT" path (fixtureHeaders (("Content-Type", "application/json") : headers)) payload + +-- | 带请求头的 JSON PATCH(改行用的是 PATCH) +patchWith :: [Header] -> BS.ByteString -> BL.ByteString -> TestSession WT.SResponse +patchWith headers path payload = + request "PATCH" path (fixtureHeaders (("Content-Type", "application/json") : headers)) payload + +-- | 带请求头的 DELETE +deleteWith :: [Header] -> BS.ByteString -> TestSession WT.SResponse +deleteWith headers path = request "DELETE" path (fixtureHeaders headers) "" -- | 在 items 数组里按键找一项(找不到给 Null) headEntry :: Text -> [A.Value] -> A.Value @@ -337,6 +382,23 @@ check = liftIO hasStatus :: WT.SResponse -> Int -> IO () hasStatus res code = statusCode (WT.simpleStatus res) `shouldBe` code +-- | 状态码断言,失败时把响应体和状态码一起报出来(定位用) +expectStatusWith :: String -> WT.SResponse -> Int -> TestSession () +expectStatusWith label res code = + check $ + if statusCode (WT.simpleStatus res) == code + then pure () + else + expectationFailure + ( label + ++ ": expected " + ++ show code + ++ " but got " + ++ show (statusCode (WT.simpleStatus res)) + ++ " body=" + ++ show (WT.simpleBody res) + ) + -- | 临时设几个环境变量跑一段,跑完恢复原样(原来没有就删掉) withEnv :: [(String, String)] -> IO a -> IO a withEnv kvs act = do @@ -380,12 +442,15 @@ spec = do unitSpec actionsSpec staticSpec staticDir - frontendBundleSpec + staticAssetSpec + staticServeSpec authSpec staticDir + passwordlessSpec staticDir catalogSpec staticDir limitsSpec staticDir oneClickSpec staticDir sqlSpec staticDir + privilegesSpec staticDir settingsSpec staticDir uiSettingsSpec staticDir sortSpec staticDir @@ -401,12 +466,6 @@ unitSpec = do verifyPassword (hashPasswordWith 1000 (BS.replicate 16 1) "s3cret") "wrong" `shouldBe` False it "treats a broken encoded hash as mismatch instead of throwing" $ verifyPassword "garbage" "s3cret" `shouldBe` False - it "same password with different salts yields different hashes" $ - hashPasswordWith 1000 (BS.replicate 16 1) "s3cret" - `shouldNotBe` hashPasswordWith 1000 (BS.replicate 16 2) "s3cret" - it "encoded hash has four parts: algo$iterations$salt$hash" $ - T.splitOn "$" (hashPasswordWith 1000 (BS.replicate 16 3) "s3cret") - `shouldSatisfy` ((== 4) . length) describe "Sessions" $ do it "token is 64 hex chars and differs between calls" $ do @@ -454,25 +513,80 @@ unitSpec = do writeIORef clock (addUTCTime 11 epoch) gone <- lookupSession store token gone `shouldBe` Nothing - it "password change drops other sessions but keeps the current one" $ do - clock <- newIORef epoch - store <- newSessionStore (readIORef clock) policy - keep <- createSession store "admin" - other <- createSession store "admin" - removed <- deleteOtherSessions store keep - kept <- lookupSession store keep - dropped <- lookupSession store other - removed `shouldBe` 1 - kept `shouldBe` Just "admin" - dropped `shouldBe` Nothing - it "no other sessions means zero removed (visible in logs)" $ do - clock <- newIORef epoch - store <- newSessionStore (readIORef clock) policy - keep <- createSession store "admin" - removed <- deleteOtherSessions store keep - removed `shouldBe` 0 - stillThere <- lookupSession store keep - stillThere `shouldBe` Just "admin" + + describe "Account policy and lifecycle" $ do + it "rejects invalid configurable policy limits" $ do + mapM_ (\(key, value) -> applySettings Map.empty (Map.singleton key value) `shouldSatisfy` isLeft) + [("password-min-length", "7"), ("password-classes", "5")] + it "the admin is configuration only: no storage needed to sign in" $ do + db <- newMVar testDb + sessions <- newSessionStore (pure epoch) policy + let backend = (memoryBackend testDatabaseName db){beAccounts = const (pure (Left "unavailable"))} + service <- newAccounts backend sessions testCredential + authenticate service "admin" "s3cret" >>= (`shouldSatisfy` isRight) + listAccounts service >>= (`shouldSatisfy` isLeft) + it "enforces password length and character classes" $ do + let pol = defaultPasswordPolicy + passwordAllowed pol "tiny" `shouldSatisfy` isLeft + passwordAllowed pol "alllowercaseletters" `shouldSatisfy` isLeft + passwordAllowed pol "long-password" `shouldBe` Right () + it "the admin name is reserved and ordinary names are validated" $ do + db <- newMVar testDb + sessions <- newSessionStore (pure epoch) policy + service <- newAccounts (memoryBackend testDatabaseName db) sessions testCredential + runAccountCommand service (Root "admin") (CreateAccount "ADMIN" "another-password") >>= (`shouldSatisfy` isLeft) + runAccountCommand service (Root "admin") (CreateAccount "with space" "another-password") >>= (`shouldSatisfy` isLeft) + runAccountCommand service (Root "admin") (CreateAccount "alice" "tiny") >>= (`shouldSatisfy` isLeft) + it "creates, resets and drops ordinary accounts, and a reset revokes their sessions" $ do + db <- newMVar testDb + sessions <- newSessionStore (pure epoch) policy + service <- newAccounts (memoryBackend testDatabaseName db) sessions testCredential + let admin = Root "admin" + runAccountCommand service admin (CreateAccount "Alice" "alice-password") >>= (`shouldBe` Right ()) + Right alice <- authenticate service "alice" "alice-password" + Right bob <- authenticate service "admin" "s3cret" + runAccountCommand service admin (ResetAccountPassword "alice" "alice-other-pass") >>= (`shouldBe` Right ()) + currentPrincipal service alice >>= (`shouldSatisfy` isLeft) + currentPrincipal service bob >>= (`shouldSatisfy` isRight) + authenticate service "alice" "alice-password" >>= (`shouldSatisfy` isLeft) + authenticate service "alice" "alice-other-pass" >>= (`shouldSatisfy` isRight) + runAccountCommand service admin (DropAccount "alice") >>= (`shouldBe` Right ()) + authenticate service "alice" "alice-other-pass" >>= (`shouldSatisfy` isLeft) + runAccountCommand service admin (DropAccount "alice") >>= (`shouldSatisfy` isLeft) + it "an ordinary account cannot administer accounts" $ do + db <- newMVar testDb + sessions <- newSessionStore (pure epoch) policy + service <- newAccounts (memoryBackend testDatabaseName db) sessions testCredential + let admin = Root "admin" + runAccountCommand service admin (CreateAccount "alice" "alice-password") >>= (`shouldBe` Right ()) + Right token <- authenticate service "alice" "alice-password" + Right principal <- currentPrincipal service token + principalIsRoot principal `shouldBe` False + principalName principal `shouldBe` "alice" + runAccountCommand service principal (CreateAccount "bob" "bob-password") >>= (`shouldSatisfy` isLeft) + runAccountCommand service principal (DropAccount "alice") >>= (`shouldSatisfy` isLeft) + authenticate service "bob" "bob-password" >>= (`shouldSatisfy` isLeft) + it "a failed account write keeps the old password" $ do + db <- newMVar testDb + sessions <- newSessionStore (pure epoch) policy + let original = memoryBackend testDatabaseName db + backend = original{beAccounts = \req -> case req of + ReqAccountReset {} -> pure (Left "write failed") + _ -> beAccounts original req} + service <- newAccounts backend sessions testCredential + let admin = Root "admin" + runAccountCommand service admin (CreateAccount "alice" "alice-password") >>= (`shouldBe` Right ()) + runAccountCommand service admin (ResetAccountPassword "alice" "alice-other-pass") >>= (`shouldSatisfy` isLeft) + authenticate service "alice" "alice-password" >>= (`shouldSatisfy` isRight) + authenticate service "alice" "alice-other-pass" >>= (`shouldSatisfy` isLeft) + it "a legacy row named like the administrator can be dropped, but not recreated" $ do + db <- newMVar testDb + sessions <- newSessionStore (pure epoch) policy + _ <- beAccounts (memoryBackend testDatabaseName db) (ReqAccountCreate "admin" "legacy-hash") + service <- newAccounts (memoryBackend testDatabaseName db) sessions testCredential + runAccountCommand service (Root "admin") (CreateAccount "admin" "another-password") >>= (`shouldSatisfy` isLeft) + runAccountCommand service (Root "admin") (ResetAccountPassword "admin" "another-password") >>= (`shouldSatisfy` isLeft) + runAccountCommand service (Root "admin") (DropAccount "admin") >>= (`shouldBe` Right ()) describe "Login rate limiting" $ do it "below the limit is not blocked" $ do @@ -504,20 +618,10 @@ unitSpec = do setRateLimit rl 10 60 unlocked <- rateLimitBlock rl "admin" unlocked `shouldBe` Nothing - it "shortening the window allows immediately (settings hot reload)" $ do - clock <- newIORef epoch - rl <- newRateLimiter (readIORef clock) 3 3600 - mapM_ (const (rateLimitRecord rl "admin")) [1 :: Int, 2, 3] - setRateLimit rl 3 1 - writeIORef clock (addUTCTime 2 epoch) - open <- rateLimitBlock rl "admin" - open `shouldBe` Nothing describe "Cookie parsing" $ do it "picks the session token out of many cookies" $ parseCookieHeader "chusql_session" "a=1; chusql_session=abc123; b=2" `shouldBe` Just "abc123" - it "missing key yields Nothing" $ - parseCookieHeader "chusql_session" "a=1; b=2" `shouldBe` Nothing it "empty value does not count as a value" $ parseCookieHeader "chusql_session" "chusql_session=" `shouldBe` Nothing @@ -540,17 +644,17 @@ unitSpec = do machineReadable `shouldSatisfy` (not . null) all (not . T.null . siDefault) machineReadable `shouldBe` True [siDefault i | i <- settingCatalogue, siKind i == "secret"] `shouldSatisfy` all T.null - let envs = map siEnv settingCatalogue - all (not . T.null) envs `shouldBe` True - length envs `shouldBe` length (nub envs) - it "every item has an env var and can be found by key" $ do - all (not . T.null . siEnv) settingCatalogue `shouldBe` True - map (fmap siKey . findItem . siKey) settingCatalogue `shouldBe` map (Just . siKey) settingCatalogue + let pairs = [(siSection i, siTomlKey i) | i <- settingCatalogue] + [s | (s, _) <- pairs] `shouldSatisfy` all (not . T.null) + [k | (_, k) <- pairs] `shouldSatisfy` all (not . T.null) + length pairs `shouldBe` length (nub pairs) + findItem "pipe-name" `shouldSatisfy` maybe False (\i -> (siSection i, siTomlKey i) == ("server", "pipe_name")) + findItem "storage-page-size" `shouldSatisfy` maybe False (\i -> (siSection i, siTomlKey i) == ("page", "size")) it "critical settings are root-only, tuning ones are not" $ do isRootOnly "port" `shouldBe` True isRootOnly "host" `shouldBe` True isRootOnly "user" `shouldBe` True - isRootOnly "password-hash" `shouldBe` True + isRootOnly "password" `shouldBe` True isRootOnly "rows-per-page" `shouldBe` False isRootOnly "body-limit" `shouldBe` False it "live keys exist in the catalogue and are all integers" $ do @@ -562,10 +666,6 @@ unitSpec = do isRestartRequired "static-dir" `shouldBe` True isRestartRequired "rows-per-page" `shouldBe` False filter isRestartRequired liveKeys `shouldBe` [] - it "defaults are found, an unknown key gives an empty string" $ do - defaultOf "port" `shouldBe` "7777" - defaultOf "rows-per-page" `shouldBe` "25" - defaultOf "nope" `shouldBe` "" it "the flattened startup config carries the real port/account" $ do let flat = effectiveSettings defaultWebConfig Map.lookup "port" flat `shouldBe` Just "7777" @@ -605,27 +705,19 @@ unitSpec = do tmp <- getTemporaryDirectory missing <- readSettingsFile (tmp "chusql-web-test-does-not-exist.json") missing `shouldBe` Map.empty - it "settings file lookup: uses an existing file" $ do + it "the config file lives at the fixed place the installer writes" $ do + fixed <- defaultConfigFile + dir <- getXdgDirectory XdgConfig "ChuSQL" + fixed `shouldBe` (dir "chusql.toml") + it "an explicit --config path wins over the fixed place" $ do tmp <- getTemporaryDirectory - let web = tmp "chusql-web-test-resolve" - createDirectoryIfMissing True (web "script") - BS.writeFile (web "script" "chusql.settings.json") "{\"port\":\"9000\"}" - System.Directory.withCurrentDirectory web $ do - found <- resolveSettingsFile - found `shouldBe` ("script" "chusql.settings.json") - it "settings file lookup: picks the candidate in an existing dir when none exists" $ do - tmp <- getTemporaryDirectory - let root = tmp "chusql-web-test-resolve-root" - let web = root "chusql-web" - createDirectoryIfMissing True (root "script") - createDirectoryIfMissing True web - System.Directory.withCurrentDirectory web $ do - found <- resolveSettingsFile - found `shouldBe` (".." "script" "chusql.settings.json") + let explicit = tmp "chusql-web-test-explicit.toml" + resolveConfigPath (Just explicit) `shouldReturn` explicit + it "without --config the fixed place is used" $ do + fixed <- defaultConfigFile + resolveConfigPath Nothing `shouldReturn` fixed describe "Browsing SELECTs (column header click / WHERE filter cells)" $ do - it "no sort and no filter yields a plain SELECT" $ - selectRowsSql "users" Nothing [] `shouldBe` Right "SELECT * FROM users" it "column and direction produce ORDER BY" $ do selectRowsSql "users" (Just ("age", True)) [] `shouldBe` Right "SELECT * FROM users ORDER BY age ASC" selectRowsSql "users" (Just ("age", False)) [] `shouldBe` Right "SELECT * FROM users ORDER BY age DESC" @@ -681,6 +773,10 @@ unitSpec = do contentTypeOf "x.bin" `shouldBe` "application/octet-stream" describe "Column inference (headers even for empty results)" $ do + it "normalizes default qualified headers" $ do + fmap (columnsFromStatement testDb) (parseStatement "SELECT users.name FROM users WHERE FALSE") `shouldBe` Right ["name"] + it "infers headers for a SELECT without FROM" $ do + fmap (columnsFromStatement testDb) (parseStatement "SELECT 1 + 2 LIMIT 0") `shouldBe` Right ["1 + 2"] it "SELECT * expands to the table columns" $ columnsFromStatement testDb (selectStar (FromTable Nothing "users")) `shouldBe` ["id", "name", "age"] @@ -690,8 +786,8 @@ unitSpec = do it "JOIN SELECT * puts the left table first" $ columnsFromStatement testDb - (selectStar (FromJoin (FromTable Nothing "users") (Just "o") "orders" (Eq (Col "id") (Col "user_id")))) - `shouldBe` ["id", "name", "age", "o.id", "o.user_id"] + (selectStar (FromJoin InnerJoin (FromTable Nothing "users") (Just "o") "orders" (Eq (Col "users.id") (Col "o.user_id")))) + `shouldBe` ["users.id", "users.name", "users.age", "o.id", "o.user_id"] it "explicit column names are kept as-is" $ columnsFromStatement testDb (selectThese ["name"] (FromTable Nothing "users")) `shouldBe` ["name"] it "write statements have no result columns" $ @@ -713,34 +809,93 @@ unitSpec = do map scName (tiColumns info) `shouldBe` ["id", "name", "age"] tiStats info `shouldBe` [("id", 5, False), ("name", 5, False), ("age", 5, False)] - describe "Default account and credential source" $ do - it "the default account is root and uses the built-in demo password" $ do - let cfg = defaultWebConfig + describe "Database qualification (inDatabase)" $ do + let rewrite db sql = parseStatement sql >>= Right . inDatabase db + + it "prefixes the tables of a subquery as well" $ do + case rewrite "sales" "SELECT name FROM users WHERE id IN (SELECT user_id FROM orders)" of + Left err -> expectationFailure err + Right stmt -> do + let text = show stmt + ("sales.users" `isInfixOf` text) `shouldBe` True + ("sales.orders" `isInfixOf` text) `shouldBe` True + ("\"orders\"" `isInfixOf` text) `shouldBe` False + + it "keeps the JOIN kind while prefixing both sides" $ do + case rewrite "sales" "SELECT u.name FROM users u LEFT JOIN orders o ON u.id = o.user_id" of + Left err -> expectationFailure err + Right stmt -> do + let text = show stmt + ("LeftJoin" `isInfixOf` text) `shouldBe` True + ("sales.users" `isInfixOf` text) `shouldBe` True + ("sales.orders" `isInfixOf` text) `shouldBe` True + ("\"orders\"" `isInfixOf` text) `shouldBe` False + + it "qualifies a subquery that sits inside a JOIN condition" $ do + case rewrite "sales" "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id AND EXISTS (SELECT 1 FROM orders x WHERE x.user_id = u.id)" of + Left err -> expectationFailure err + Right stmt -> do + let text = show stmt + ("sales.users" `isInfixOf` text) `shouldBe` True + ("sales.orders" `isInfixOf` text) `shouldBe` True + ("\"orders\"" `isInfixOf` text) `shouldBe` False + + it "leaves table names alone when no database is selected" $ do + case rewrite "" "SELECT name FROM users WHERE id IN (SELECT user_id FROM orders)" of + Left err -> expectationFailure err + Right stmt -> do + let text = show stmt + ("\"users\"" `isInfixOf` text) `shouldBe` True + ("\"orders\"" `isInfixOf` text) `shouldBe` True + ("test." `isInfixOf` text) `shouldBe` False + + describe "Administrator credential source (settings file only)" $ do + it "with nothing configured the built-in demo account is used" $ do defaultUser `shouldBe` "root" - wcUser cfg `shouldBe` "root" - usingDefaultCredentials cfg `shouldBe` True - cred <- resolveCredential cfg + usingDefaultCredentials Map.empty `shouldBe` True + cred <- resolveCredential defaultWebConfig Map.empty credUser cred `shouldBe` defaultUser verifyPassword (credEncoded cred) defaultPassword `shouldBe` True - it "a plain password wins and the default stops working" $ do - let cfg = defaultWebConfig{wcPassword = Just "s3cret"} - usingDefaultCredentials cfg `shouldBe` False - cred <- resolveCredential cfg + it "a plain password in the settings file wins" $ do + let saved = Map.singleton "password" "s3cret" + usingDefaultCredentials saved `shouldBe` False + cred <- resolveCredential defaultWebConfig saved verifyPassword (credEncoded cred) "s3cret" `shouldBe` True verifyPassword (credEncoded cred) defaultPassword `shouldBe` False - it "a provided hash is used directly (not re-hashed)" $ do + it "an explicitly empty password makes the administrator passwordless" $ do + let saved = Map.singleton "password" "" + usingDefaultCredentials saved `shouldBe` False + cred <- resolveCredential defaultWebConfig saved + credUser cred `shouldBe` defaultUser + credEncoded cred `shouldBe` "" + -- 空口令不是"能匹配任意口令"的哈希:它是一个明确的空编码串 + verifyPassword (credEncoded cred) "" `shouldBe` False + it "the retired password-hash key is not a setting and never a credential" $ do let encoded = hashPasswordWith 1000 (BS.replicate 16 9) "from-hash" - cred <- resolveCredential defaultWebConfig{wcPasswordHash = Just encoded} - credEncoded cred `shouldBe` encoded - it "the account name can be changed" $ do - cred <- resolveCredential defaultWebConfig{wcUser = "tester"} + cred <- resolveCredential defaultWebConfig (Map.singleton "password-hash" encoded) + verifyPassword (credEncoded cred) defaultPassword `shouldBe` True + applySettings Map.empty (Map.singleton "password-hash" encoded) `shouldSatisfy` isLeftE + it "the administrator name comes from the file, and --user still wins" $ do + cred <- resolveCredential defaultWebConfig (Map.fromList [("user", "tester"), ("password", "s3cret")]) credUser cred `shouldBe` "tester" + fromCli <- resolveCredential defaultWebConfig{wcUser = "from-cli"} (Map.singleton "user" "tester") + credUser fromCli `shouldBe` "from-cli" + it "the password environment variables are no longer consulted" $ do + tmp <- getTemporaryDirectory + let missing = tmp "chusql-web-test-cred-env.toml" + removeIfExists missing + withEnv [("CHUSQL_WEB_PASSWORD", "from-env"), ("CHUSQL_WEB_USER", "env-user")] $ do + cfg <- loadWebConfigAt missing + wcUser cfg `shouldBe` "" + cred <- resolveCredential cfg Map.empty + credUser cred `shouldBe` defaultUser + verifyPassword (credEncoded cred) "from-env" `shouldBe` False describe "Config: built-in defaults" $ do - it "port defaults to 7777, account defaults to admin/chusql" $ do + it "port defaults to 7777 and the administrator name is not baked into the config" $ do wcPort defaultWebConfig `shouldBe` 7777 wcHost defaultWebConfig `shouldBe` "127.0.0.1" - wcUser defaultWebConfig `shouldBe` defaultUser + wcUser defaultWebConfig `shouldBe` "" wcCookieSecure defaultWebConfig `shouldBe` False it "session / rate limit / paging / row limits all have explicit defaults" $ do wcSessionIdle defaultWebConfig `shouldBe` 8 * 3600 @@ -753,23 +908,16 @@ unitSpec = do wcMaxSqlLength defaultWebConfig `shouldBe` 20000 wcBodyLimit defaultWebConfig `shouldBe` 65536 - describe "Config: environment variables override defaults" $ do - it "port / host / static dir / account" $ do + describe "Config: environment variables are ignored (the file comes from --config)" $ do + it "port / host / static dir / limits in the environment change nothing" $ do + tmp <- getTemporaryDirectory + let missing = tmp "chusql-web-test-ignored-env.toml" + removeIfExists missing withEnv [ ("CHUSQL_WEB_PORT", "8123") , ("CHUSQL_WEB_HOST", "0.0.0.0") , ("CHUSQL_WEB_STATIC", "mysite") - , ("CHUSQL_WEB_USER", "tester") - ] - $ do - cfg <- loadWebConfig - wcPort cfg `shouldBe` 8123 - wcHost cfg `shouldBe` "0.0.0.0" - wcStaticDir cfg `shouldBe` "mysite" - wcUser cfg `shouldBe` "tester" - it "session / rate limit / paging / rows / body limit" $ do - withEnv - [ ("CHUSQL_WEB_SESSION_IDLE", "60") + , ("CHUSQL_WEB_SESSION_IDLE", "60") , ("CHUSQL_WEB_SESSION_MAX", "120") , ("CHUSQL_WEB_LOGIN_MAX_ATTEMPTS", "3") , ("CHUSQL_WEB_LOGIN_WINDOW", "30") @@ -781,128 +929,299 @@ unitSpec = do , ("CHUSQL_WEB_COOKIE_SECURE", "1") ] $ do - cfg <- loadWebConfig - wcSessionIdle cfg `shouldBe` 60 - wcSessionMax cfg `shouldBe` 120 - wcLoginMaxAttempts cfg `shouldBe` 3 - wcLoginWindow cfg `shouldBe` 30 - wcPageSize cfg `shouldBe` 7 - wcMaxPageSize cfg `shouldBe` 70 - wcMaxRows cfg `shouldBe` 9 - wcMaxSqlLength cfg `shouldBe` 1234 - wcBodyLimit cfg `shouldBe` 2048 - wcCookieSecure cfg `shouldBe` True - it "unsetting the env var restores the default port 7777" $ do - withEnv [("CHUSQL_WEB_PORT", "8123")] $ do - tuned <- loadWebConfig - wcPort tuned `shouldBe` 8123 - restored <- loadWebConfig - wcPort restored `shouldBe` 7777 + cfg <- loadWebConfigAt missing + cfg `shouldBe` defaultWebConfig + it "the retired endpoint variables change nothing either" $ do + tmp <- getTemporaryDirectory + let missing = tmp "chusql-web-test-ignored-endpoint.toml" + removeIfExists missing + withEnv + [ ("CHUSQL_PIPE", "from-env-pipe") + , ("CHUSQL_DATA_DIR", "from-env-data") + , ("CHUSQL_STORAGE_SERVER", "from-env-server") + ] + $ do + cfg <- loadWebConfigAt missing + wcPipeName cfg `shouldBe` Nothing + wcDataDir cfg `shouldBe` Nothing + wcStorageServer cfg `shouldBe` Nothing - describe "Launcher script (script/chusql.ps1)" $ do - it "script/ has chusql.ps1 / chusql.cmd, default port 7777, covering all three modules" $ do - script <- readUtf8 (".." "script" "chusql.ps1") - batch <- readUtf8 (".." "script" "chusql.cmd") - batch `shouldSatisfy` T.isInfixOf "chusql.ps1" - script `shouldSatisfy` T.isInfixOf "chusql-storage" - script `shouldSatisfy` T.isInfixOf "chusql-web.exe" - script `shouldSatisfy` T.isInfixOf "/api/status" - script `shouldSatisfy` T.isInfixOf "7777" - it "has web / cli / config commands, cli is a placeholder (dev notice + still starts web)" $ do - script <- readUtf8 (".." "script" "chusql.ps1") - script `shouldSatisfy` T.isInfixOf "CLI interface is under development." - script `shouldSatisfy` T.isInfixOf "'web'" - script `shouldSatisfy` T.isInfixOf "'cli'" - script `shouldSatisfy` T.isInfixOf "config set" - it "settings persist to a file (config set/unset/reset + precedence note)" $ do - script <- readUtf8 (".." "script" "chusql.ps1") - script `shouldSatisfy` T.isInfixOf "chusql.settings.json" - script `shouldSatisfy` T.isInfixOf "command line > settings file > environment > default" - script `shouldSatisfy` T.isInfixOf "Save-SavedSettings" - it "child window hidden + logs flow back to the launcher window" $ do - script <- readUtf8 (".." "script" "chusql.ps1") - script `shouldSatisfy` T.isInfixOf "-WindowStyle Hidden" - script `shouldSatisfy` T.isInfixOf "Show-NewLogLines" - script `shouldSatisfy` T.isInfixOf "RedirectStandardOutput" - it "the script exposes settings as switches (port/session/paging/storage tuning)" $ do - script <- readUtf8 (".." "script" "chusql.ps1") - script `shouldSatisfy` T.isInfixOf "CHUSQL_WEB_SESSION_IDLE" - script `shouldSatisfy` T.isInfixOf "CHUSQL_WEB_MAX_ROWS" - script `shouldSatisfy` T.isInfixOf "CHUSQL_BUFFER_POOL_SIZE" - script `shouldSatisfy` T.isInfixOf "CHUSQL_PAGE_SIZE" - it "the Rust artifact is named chusql-storage (no longer server)" $ do - script <- readUtf8 (".." "script" "chusql.ps1") - script `shouldSatisfy` T.isInfixOf "target\\release\\chusql-storage.exe" - script `shouldSatisfy` (not . T.isInfixOf "target\\release\\server.exe") + describe "Config: the sections of the global chusql.toml" $ do + it "reads the section and accepts both underscore and hyphen spellings" $ do + tmp <- getTemporaryDirectory + let path = tmp "chusql-web-test-global.toml" + writeFile path "[page]\nsize = 8192\n\n[web]\nstatic_dir = \"webroot\"\nsession_idle = 4242\nrows_per_page = 33\n" + cfg <- loadWebConfigAt path + wcStaticDir cfg `shouldBe` "webroot" + wcSessionIdle cfg `shouldBe` 4242 + wcPageSize cfg `shouldBe` 33 + -- 别的层的分区不归 web 管,没给的键回到内置默认 + wcPort cfg `shouldBe` 7777 + wcMaxRows cfg `shouldBe` 1000 + saved <- readSettingsFile path + Map.lookup "rows-per-page" saved `shouldBe` Just "33" + it "the endpoint comes from [server] pipe_name and [storage] data_dir, the executable from [web] storage_server" $ do + tmp <- getTemporaryDirectory + let path = tmp "chusql-web-test-endpoint.toml" + writeFile + path + "[server]\npipe_name = \"joint-pipe\"\n\n[storage]\ndata_dir = \"../localdata\"\n\n[web]\nstorage_server = \"chusql-storage.exe\"\n" + cfg <- loadWebConfigAt path + wcPipeName cfg `shouldBe` Just "joint-pipe" + resolvePipeName cfg `shouldBe` "joint-pipe" + wcDataDir cfg `shouldBe` Just "../localdata" + wcStorageServer cfg `shouldBe` Just "chusql-storage.exe" + it "without a file the pipe name falls back to the shared default" $ do + resolvePipeName defaultWebConfig `shouldBe` "chusql-joint" + it "a port in the environment no longer wins over the file" $ do + tmp <- getTemporaryDirectory + let path = tmp "chusql-web-test-global-env.toml" + writeFile path "[web]\nport = 1234\nhost = \"0.0.0.0\"\n" + withEnv [("CHUSQL_WEB_PORT", "4321")] $ do + cfg <- loadWebConfigAt path + wcPort cfg `shouldBe` 1234 + wcHost cfg `shouldBe` "0.0.0.0" + it "the canonical hyphen key wins when both spellings are present" $ do + let both = Map.fromList [("session-idle", "1"), ("session_idle", "2")] + Map.lookup "session-idle" (canonicalSettingKeys both) `shouldBe` Just "1" + it "a missing config file falls back to the built-in defaults" $ do + tmp <- getTemporaryDirectory + let missing = tmp "chusql-web-test-missing-config.toml" + removeIfExists missing + cfg <- loadWebConfigAt missing + wcPort cfg `shouldBe` wcPort defaultWebConfig + wcPageSize cfg `shouldBe` wcPageSize defaultWebConfig + + describe "Spawning the storage process" $ do + it "points the child at the same config file on its command line" $ do + storageChildArgs ("scripts" "chusql.toml") + `shouldBe` ["--config", "scripts" "chusql.toml"] + it "passes nothing else" $ do + storageChildArgs "chusql.toml" `shouldBe` ["--config", "chusql.toml"] + + describe "Installer scripts (scripts/)" $ do + it "install.sh releases files, writes chusql.toml, sets PATH and removes the package" $ do + installer <- readUtf8 (".." "scripts" "install.sh") + installer `shouldSatisfy` T.isInfixOf "chusql-storage" + installer `shouldSatisfy` T.isInfixOf "chusql.toml" + installer `shouldSatisfy` T.isInfixOf "PATH" + installer `shouldSatisfy` T.isInfixOf "rm -rf" + -- 一份脚本三种来源:包内(旁边有 bin/)、在线(管道进来时 $0 是 sh,按平台取 release 资源)、 + -- 显式归档或源码。去掉任何一条,从 GitHub 装的用法就断了。 + it "install.sh picks the package by platform and can fall back to source" $ do + installer <- readUtf8 (".." "scripts" "install.sh") + installer `shouldSatisfy` T.isInfixOf "bin/chusql-storage" + installer `shouldSatisfy` T.isInfixOf "uname -s" + installer `shouldSatisfy` T.isInfixOf "uname -m" + installer `shouldSatisfy` T.isInfixOf "releases/latest/download" + installer `shouldSatisfy` T.isInfixOf "chusql-$component-$label.$ext" + installer `shouldSatisfy` T.isInfixOf ".sha256" + installer `shouldSatisfy` T.isInfixOf "--url" + installer `shouldSatisfy` T.isInfixOf "--from-source" + installer `shouldSatisfy` T.isInfixOf "stack build --fast" + -- static_dir 是相对路径,启动器必须先切到安装目录;少了这一句,从别处执行 + -- csql-web 就会因为找不到 static/ 直接退出(Windows 的 .ps1 靠 -WorkingDirectory)。 + it "csql-web.sh switches to the install dir before starting" $ do + launcher <- readUtf8 (".." "scripts" "csql-web.sh") + launcher `shouldSatisfy` T.isInfixOf "cd \"$home_dir\"" + launcher `shouldSatisfy` T.isInfixOf "chusql-storage" + -- 默认数据目录按平台惯例算,四处必须说同一件事:Rust 代码、配置示例、模板、文档。 + -- 跟安装脚本装的位置也是一处:Windows 装到 %LOCALAPPDATA%\ChuSQL、Unix 装到 ~/.local/share/chusql, + -- 数据都放在它下面的 data/ 里。 + it "the default data dir follows the platform, and code, template and docs agree" $ do + rust <- readUtf8 (".." "chusql-storage" "src" "config.rs") + rust `shouldSatisfy` T.isInfixOf "default_data_dir" + rust `shouldSatisfy` T.isInfixOf "LOCALAPPDATA" + rust `shouldSatisfy` T.isInfixOf "XDG_DATA_HOME" + rust `shouldSatisfy` (not . T.isInfixOf "DEFAULT_DATA_DIR") + + example <- readUtf8 (".." "chusql-storage" "chusql-storage.toml.example") + -- 示例文件说自己列的值就是默认值:data_dir 不能是能生效的一行 + example `shouldSatisfy` (not . T.isInfixOf "\ndata_dir =") + example `shouldSatisfy` T.isInfixOf "%LOCALAPPDATA%" + example `shouldSatisfy` T.isInfixOf "XDG_DATA_HOME" + + template <- readUtf8 (".." "scripts" "chusql.toml") + -- 模板那行是安装时被 sed / -replace 替换的占位,必须保持能生效 + template `shouldSatisfy` T.isInfixOf "\ndata_dir = " + template `shouldSatisfy` T.isInfixOf "%LOCALAPPDATA%" + + configDoc <- readUtf8 (".." "doc" "config.md") + configDoc `shouldSatisfy` T.isInfixOf "%LOCALAPPDATA%\\ChuSQL\\data" + configDoc `shouldSatisfy` T.isInfixOf "chusql/data" + -- 端点规则两侧必须一致:Rust 服务端和 Haskell 前端算出来的必须是同一个路径。 + it "Rust and Haskell agree on where the Unix socket lives" $ do + rustEndpoint <- readUtf8 (".." "chusql-storage" "src" "endpoint.rs") + haskellIpc <- readUtf8 (".." "chusql-engine" "src" "ChuSQL" "Storage" "IPC.hs") + mapM_ + ( \src -> do + src `shouldSatisfy` T.isInfixOf "XDG_RUNTIME_DIR" + src `shouldSatisfy` T.isInfixOf "TMPDIR" + src `shouldSatisfy` T.isInfixOf "/tmp" + src `shouldSatisfy` T.isInfixOf ".sock" + ) + [rustEndpoint, haskellIpc] + it "the Linux pipeline builds both toolchains and runs every test suite" $ do + pipeline <- readUtf8 (".." ".github" "workflows" "linux.yml") + pipeline `shouldSatisfy` T.isInfixOf "ubuntu-latest" + pipeline `shouldSatisfy` T.isInfixOf "cargo test --release" + pipeline `shouldSatisfy` T.isInfixOf "cargo clippy" + pipeline `shouldSatisfy` T.isInfixOf "stack test --fast" + -- 打包与安装冒烟只在发版流水线里做(tag 触发 / 手动 dispatch),这条流水线不再出包 + pipeline `shouldSatisfy` (not . T.isInfixOf "package.ps1") + pipeline `shouldSatisfy` (not . T.isInfixOf "smoke-linux.sh") + -- 仓库里没有 rustfmt.toml,历史代码不是按当前 rustfmt 排的:格式化不是门禁 + pipeline `shouldSatisfy` (not . T.isInfixOf "cargo fmt") + -- 装的人不该被要求装两个工具链:发版流水线出预编译包,install.sh 按同一套平台标签去取。 + -- 装配与打归档原来在 scripts/package.ps1 里,脚本删掉后直接写在流水线里(两个平台各一段)。 + it "the release pipeline publishes one archive per platform, tagged the way install.sh looks it up" $ do + release <- readUtf8 (".." ".github" "workflows" "release.yml") + release `shouldSatisfy` T.isInfixOf "linux-x86_64" + release `shouldSatisfy` T.isInfixOf "macos-arm64" + release `shouldSatisfy` T.isInfixOf "macos-x86_64" + release `shouldSatisfy` T.isInfixOf "windows-x86_64" + release `shouldSatisfy` T.isInfixOf "cargo build --release" + release `shouldSatisfy` T.isInfixOf "stack build --fast chusql-web:exe:chusql-web" + release `shouldSatisfy` T.isInfixOf "stack build --fast chusql-cli:exe:csql" + release `shouldSatisfy` T.isInfixOf "tar -czf" + release `shouldSatisfy` T.isInfixOf "Compress-Archive" + release `shouldSatisfy` T.isInfixOf "scripts/install.sh" + release `shouldSatisfy` (not . T.isInfixOf "package.ps1") + release `shouldSatisfy` T.isInfixOf "sha256" + release `shouldSatisfy` T.isInfixOf "gh release upload" + it "the storage binary name follows the platform (a stray .exe must not win on Linux)" $ do + platformBinaryName "mingw32" "chusql-storage" `shouldBe` "chusql-storage.exe" + platformBinaryName "linux" "chusql-storage" `shouldBe` "chusql-storage" + -- 同一 target 目录里可能躺着一份别处交叉编译出来的 .exe:谁都不许再写 + -- 「.exe 优先、无后缀兜底」这种候选表 + engine <- readUtf8 (".." "chusql-engine" "test" "Spec.hs") + bench <- readUtf8 (".." "benchmark" "src" "Main.hs") + storage <- readUtf8 ("src" "ChuSQL" "Web" "StorageProcess.hs") + mapM_ + (\src -> src `shouldSatisfy` (not . T.isInfixOf "\"chusql-storage.exe\", \"chusql-storage\"")) + [engine, bench, storage] + + -- 细节分到 doc/ 下四份,README 只留面向用户的关键内容;链接断了或者文档没了,等于没写。 + describe "Documentation (doc/)" $ do + it "the README keeps only the essentials and links the split documents" $ do + readme <- readUtf8 (".." "README.md") + mapM_ + (\target -> readme `shouldSatisfy` T.isInfixOf ("doc/" <> target <> ".md")) + ["install", "config", "commands", "architecture"] + -- 安装选项与配置键的长表已经搬走,不该在 README 里再长回来 + readme `shouldSatisfy` (not . T.isInfixOf "--from-source") + readme `shouldSatisfy` (not . T.isInfixOf "pool_size") + -- 仓库布局这类开发者信息属于 doc/architecture.md,不属于 README + readme `shouldSatisfy` (not . T.isInfixOf "chusql-engine/") + it "each split document covers what its name promises" $ do + let expectations = + [ ("install", ["install.sh", "install.ps1", "--component", "--install-dir", "--from-source", "rm -rf"]) + , ("config", ["[web]", "[page]", "pipe_name", "data_dir", "host", "port"]) + , ("commands", ["csql", "--format", "\\dt", "CREATE ROLE", "GRANT", "/api/roles"]) + , ("architecture", ["chusql-storage", "chusql-web", "csql", "pipe_name", "data_dir", "CREATE DATABASE"]) + ] + mapM_ + ( \(name, needles) -> do + doc <- readUtf8 (".." "doc" name <> ".md") + mapM_ (\needle -> doc `shouldSatisfy` T.isInfixOf needle) needles + ) + expectations + -- doc/ 只写面向用户的内容:构建、测试、打包这些开发流程不进文档 + it "the split documents stay user-facing (no build, test or packaging recipes)" $ do + let developerOnly = + [ "package.ps1" + , "stack build" + , "stack test" + , "cargo build" + , "cargo test" + , "cargo clippy" + , ".stack-work" + , ".github/workflows" + , "smoke-linux.sh" + ] + mapM_ + ( \name -> do + doc <- readUtf8 (".." "doc" name <> ".md") + mapM_ (\needle -> doc `shouldSatisfy` (not . T.isInfixOf needle)) developerOnly + ) + ["install", "config", "commands", "architecture"] staticSpec :: FilePath -> Spec staticSpec staticDir = describe "Static files" $ do - it "reads index.html from the temp dir" $ do - content <- readStatic staticDir "index.html" - content `shouldSatisfy` maybe False (not . BS.null) it "an unreadable file gives Nothing" $ readStatic staticDir "nope.js" `shouldReturn` Nothing - it "a traversal path is unreadable" $ - readStatic staticDir "../secret.token" `shouldReturn` Nothing -frontendBundleSpec :: Spec -frontendBundleSpec = describe "React frontend build artifacts" $ do - present <- runIO (doesDirectoryExist "static") - let asset name = "static" name +-- | 手写静态前端:源文件入库,没有打包器,也不该再有 Node 工具链 +staticAssetSpec :: Spec +staticAssetSpec = describe "Static front end (hand-written, no bundler)" $ do + let dir = "static" + asset name = dir name readAsset name = readUtf8 (asset name) - -- | static/ 是构建产物,没构建就跳过 - check body - | present = body - | otherwise = pendingWith "static/ is not built; run npm run build under chusql-web/frontend" - it "entry HTML exists and is non-empty" $ check $ do - html <- BS.readFile (asset "index.html") - html `shouldSatisfy` (not . BS.null) - it "entry declares the Chinese page language" $ check $ do - html <- readAsset "index.html" - html `shouldSatisfy` T.isInfixOf "lang=\"zh-CN\"" - it "entry loads the app script only from /static/" $ check $ do + it "the app shell is present and loads both assets from /static/" $ do html <- readAsset "index.html" + html `shouldSatisfy` T.isInfixOf "id=\"root\"" html `shouldSatisfy` T.isInfixOf "src=\"/static/app.js\"" - it "entry loads styles only from /static/" $ check $ do - html <- readAsset "index.html" html `shouldSatisfy` T.isInfixOf "href=\"/static/style.css\"" - it "entry has no inline script" $ check $ do + -- 服务端把 nonce 元信息插在 前面,这个标记不能丢 + html `shouldSatisfy` T.isInfixOf "" + it "the shell has no inline script or style (the CSP allows neither)" $ do html <- readAsset "index.html" html `shouldSatisfy` (not . T.isInfixOf "