大改,整理项目结构,完成rust ffi调用,写好tcp接口
This commit is contained in:
1 parent
df6d348995
commit
9758439b82
118 files changed
+7995
-6213
No files matched your search
+15
-13
@@ -1,8 +1,8 @@
|
|||||||
# Linux 流水线:Rust 存储 + Haskell 引擎/Web/CLI 的构建与测试。
|
# Linux 流水线:Rust 存储 + Haskell 引擎/Web/CLI 的构建与测试。
|
||||||
#
|
#
|
||||||
# 这里每条命令都在 WSL Debian(cargo 1.98 / GHC 9.10.3 / stack 3.11.1)上跑通过,
|
# 这里每条命令都在 WSL Debian(cargo 1.98 / GHC 9.10.3 / stack 3.11.1)上跑通过,
|
||||||
# 见仓库根 README 的「构建与测试」段。IPC 用例需要 chusql-storage 可执行文件,
|
# 见仓库根 README 的「构建与测试」段。Haskell 这边链接并装入 libchusql_core_storage.so,
|
||||||
# 所以 Rust 先构建,Haskell 的 engine/web 用例才能真跑而不是 pending。
|
# 所以 Rust 先构建,LD_LIBRARY_PATH 指向它的输出目录,Haskell 用例才能真跑而不是 pending。
|
||||||
# 打包和发版资源走 .github/workflows/release.yml(tag 触发,也可手动 dispatch)。
|
# 打包和发版资源走 .github/workflows/release.yml(tag 触发,也可手动 dispatch)。
|
||||||
name: linux
|
name: linux
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ concurrency:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
|
LD_LIBRARY_PATH: ${{ github.workspace }}/chusql-core/storage/target/release
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
linux:
|
linux:
|
||||||
@@ -39,24 +40,20 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry
|
||||||
~/.cargo/git
|
~/.cargo/git
|
||||||
chusql-storage/target
|
chusql-core/storage/target
|
||||||
key: ${{ runner.os }}-cargo-${{ hashFiles('chusql-storage/Cargo.lock') }}
|
key: ${{ runner.os }}-cargo-${{ hashFiles('chusql-core/storage/Cargo.lock') }}
|
||||||
restore-keys: ${{ runner.os }}-cargo-
|
restore-keys: ${{ runner.os }}-cargo-
|
||||||
|
|
||||||
- name: cargo build (release)
|
- name: cargo build (release)
|
||||||
working-directory: chusql-storage
|
working-directory: chusql-core/storage
|
||||||
run: cargo build --release
|
run: cargo build --release
|
||||||
|
|
||||||
- name: cargo build (debug, IPC 用例要它)
|
|
||||||
working-directory: chusql-storage
|
|
||||||
run: cargo build --bin chusql-storage
|
|
||||||
|
|
||||||
- name: cargo test
|
- name: cargo test
|
||||||
working-directory: chusql-storage
|
working-directory: chusql-core/storage
|
||||||
run: cargo test --release
|
run: cargo test --release
|
||||||
|
|
||||||
- name: cargo clippy (-D warnings)
|
- name: cargo clippy (-D warnings)
|
||||||
working-directory: chusql-storage
|
working-directory: chusql-core/storage
|
||||||
run: cargo clippy --all-targets -- -D warnings
|
run: cargo clippy --all-targets -- -D warnings
|
||||||
|
|
||||||
# 故意不做 rustfmt 检查:仓库里没有 rustfmt.toml,历史代码也不是按当前 rustfmt 的
|
# 故意不做 rustfmt 检查:仓库里没有 rustfmt.toml,历史代码也不是按当前 rustfmt 的
|
||||||
@@ -76,7 +73,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.stack
|
~/.stack
|
||||||
chusql-engine/.stack-work
|
chusql-core/engine/.stack-work
|
||||||
|
chusql-server/.stack-work
|
||||||
chusql-web/.stack-work
|
chusql-web/.stack-work
|
||||||
chusql-cli/.stack-work
|
chusql-cli/.stack-work
|
||||||
key: ${{ runner.os }}-stack-${{ hashFiles('**/stack.yaml', '**/stack.yaml.lock') }}
|
key: ${{ runner.os }}-stack-${{ hashFiles('**/stack.yaml', '**/stack.yaml.lock') }}
|
||||||
@@ -87,7 +85,11 @@ jobs:
|
|||||||
run: stack build --fast
|
run: stack build --fast
|
||||||
|
|
||||||
- name: engine tests
|
- name: engine tests
|
||||||
working-directory: chusql-engine
|
working-directory: chusql-core/engine
|
||||||
|
run: stack test --fast
|
||||||
|
|
||||||
|
- name: server tests
|
||||||
|
working-directory: chusql-server
|
||||||
run: stack test --fast
|
run: stack test --fast
|
||||||
|
|
||||||
- name: web tests
|
- name: web tests
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# 发版流水线:打 tag(v*)时为四个平台各出 web / cli 两个发行包,附 sha256,挂到 Release。
|
# 发版流水线:打 tag(v*)时为四个平台各出一个发行包,附 sha256,挂到 Release。
|
||||||
#
|
#
|
||||||
# 资源命名(install.sh 按 <os>-<arch> 挑资源,见 scripts/install.sh):
|
# 资源命名(install.sh / install.ps1 按 <os>-<arch> 挑资源,见 scripts/install.sh):
|
||||||
# chusql-web-linux-x86_64.tar.gz chusql-cli-linux-x86_64.tar.gz
|
# chusql-linux-x86_64.tar.gz chusql-windows-x86_64.zip
|
||||||
# chusql-web-windows-x86_64.zip chusql-cli-windows-x86_64.zip
|
# chusql-macos-x86_64.tar.gz chusql-macos-arm64.tar.gz
|
||||||
# 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
|
# 一个平台一个包,里面 Web 前端与命令行客户端两部分都在;装哪几个由包内的安装脚本
|
||||||
|
# 当场问(非交互时用 --component / -Component 指定)。
|
||||||
#
|
#
|
||||||
# 装的人拿到的就是编好的二进制,不需要 GHC / Rust:
|
# 装的人拿到的就是编好的二进制,不需要 GHC / Rust:
|
||||||
# curl -fsSL https://raw.githubusercontent.com/TheBadRoger/ChuSQL/main/scripts/install.sh \
|
# curl -fsSL https://raw.githubusercontent.com/TheBadRoger/ChuSQL/main/scripts/install.sh \
|
||||||
@@ -33,9 +34,9 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: ${{ matrix.platform }} / ${{ matrix.component }}
|
name: ${{ matrix.platform }}
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
timeout-minutes: 120
|
timeout-minutes: 150
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -44,7 +45,6 @@ jobs:
|
|||||||
- { os: windows-latest, platform: windows-x86_64 }
|
- { os: windows-latest, platform: windows-x86_64 }
|
||||||
- { os: macos-13, platform: macos-x86_64 }
|
- { os: macos-13, platform: macos-x86_64 }
|
||||||
- { os: macos-14, platform: macos-arm64 }
|
- { os: macos-14, platform: macos-arm64 }
|
||||||
component: [web, cli]
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -58,8 +58,8 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry
|
||||||
~/.cargo/git
|
~/.cargo/git
|
||||||
chusql-storage/target
|
chusql-core/storage/target
|
||||||
key: ${{ runner.os }}-${{ matrix.platform }}-cargo-${{ hashFiles('chusql-storage/Cargo.lock') }}
|
key: ${{ runner.os }}-${{ matrix.platform }}-cargo-${{ hashFiles('chusql-core/storage/Cargo.lock') }}
|
||||||
restore-keys: ${{ runner.os }}-cargo-
|
restore-keys: ${{ runner.os }}-cargo-
|
||||||
|
|
||||||
- name: Haskell toolchain
|
- name: Haskell toolchain
|
||||||
@@ -74,7 +74,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.stack
|
~/.stack
|
||||||
chusql-engine/.stack-work
|
chusql-core/engine/.stack-work
|
||||||
chusql-web/.stack-work
|
chusql-web/.stack-work
|
||||||
chusql-cli/.stack-work
|
chusql-cli/.stack-work
|
||||||
key: ${{ runner.os }}-${{ matrix.platform }}-stack-${{ hashFiles('**/stack.yaml', '**/stack.yaml.lock') }}
|
key: ${{ runner.os }}-${{ matrix.platform }}-stack-${{ hashFiles('**/stack.yaml', '**/stack.yaml.lock') }}
|
||||||
@@ -82,59 +82,62 @@ jobs:
|
|||||||
|
|
||||||
# ---------- 构建 ----------
|
# ---------- 构建 ----------
|
||||||
- name: build storage (Rust)
|
- name: build storage (Rust)
|
||||||
working-directory: chusql-storage
|
working-directory: chusql-core/storage
|
||||||
run: cargo build --release
|
run: cargo build --release
|
||||||
|
|
||||||
- name: build web front end
|
- name: build web front end
|
||||||
if: matrix.component == 'web'
|
|
||||||
working-directory: chusql-cli
|
working-directory: chusql-cli
|
||||||
run: stack build --fast chusql-web:exe:chusql-web
|
run: stack build --fast chusql-web:exe:chusql-web
|
||||||
|
|
||||||
- name: build cli
|
- name: build cli
|
||||||
if: matrix.component == 'cli'
|
|
||||||
working-directory: chusql-cli
|
working-directory: chusql-cli
|
||||||
run: stack build --fast chusql-cli:exe:csql
|
run: stack build --fast chusql-cli:exe:csql
|
||||||
|
|
||||||
# ---------- 装配 + 打归档(原来放在一个独立的打包脚本里,那个脚本已经删掉)----------
|
- name: build tcp server
|
||||||
|
working-directory: chusql-cli
|
||||||
|
run: stack build --fast chusql-server:exe:chusql-server
|
||||||
|
|
||||||
|
# ---------- 装配 + 打归档(CI 里直接写在这里;本机打同一个包用 scripts/package.sh)----------
|
||||||
# 归档里是包内容本身(bin/、scripts/、static/、install.sh 就在根上),安装脚本按这个布局找文件。
|
# 归档里是包内容本身(bin/、scripts/、static/、install.sh 就在根上),安装脚本按这个布局找文件。
|
||||||
- name: package (Unix)
|
- name: package (Unix)
|
||||||
if: runner.os != 'Windows'
|
if: runner.os != 'Windows'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
component='${{ matrix.component }}'
|
name="chusql-${{ matrix.platform }}"
|
||||||
name="chusql-$component-${{ matrix.platform }}"
|
|
||||||
stage="dist/$name"
|
stage="dist/$name"
|
||||||
rm -rf "$stage"
|
rm -rf "$stage"
|
||||||
mkdir -p "$stage/bin" "$stage/scripts"
|
mkdir -p "$stage/bin" "$stage/scripts"
|
||||||
|
|
||||||
cp chusql-storage/target/release/chusql-storage "$stage/bin/"
|
cp chusql-core/storage/target/release/libchusql_core_storage.* "$stage/bin/"
|
||||||
|
|
||||||
stack_root="$(cd chusql-cli && stack path --local-install-root | tail -n 1 | tr -d '\r')"
|
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
|
for front_name in chusql-web chusql-server csql; do
|
||||||
front="$stack_root/bin/$front_name"
|
front="$stack_root/bin/$front_name"
|
||||||
if [ ! -f "$front" ]; then
|
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)"
|
front="$(find chusql-web/.stack-work chusql-cli/.stack-work -name "$front_name" -type f 2>/dev/null | head -n 1 || true)"
|
||||||
fi
|
fi
|
||||||
[ -n "$front" ] && [ -f "$front" ] || { echo "front end binary not found: $front_name" >&2; exit 1; }
|
[ -n "$front" ] && [ -f "$front" ] || { echo "front end binary not found: $front_name" >&2; exit 1; }
|
||||||
cp "$front" "$stage/bin/"
|
cp "$front" "$stage/bin/"
|
||||||
|
done
|
||||||
|
|
||||||
# Haskell 一般静态链接,靶目录里真有共享库就一起带上
|
# Haskell 一般静态链接,靶目录里真有共享库就一起带上;改名前的旧库(libchusql_storage.*)不能进包
|
||||||
for lib in chusql-storage/target/release/*.so chusql-storage/target/release/*.dylib; do
|
for lib in chusql-core/storage/target/release/*.so chusql-core/storage/target/release/*.dylib; do
|
||||||
[ -f "$lib" ] && cp "$lib" "$stage/bin/"
|
[ -f "$lib" ] || continue
|
||||||
|
case "$(basename "$lib")" in
|
||||||
|
libchusql_storage.*|libchusql_storage-*) continue ;;
|
||||||
|
esac
|
||||||
|
cp "$lib" "$stage/bin/"
|
||||||
done
|
done
|
||||||
|
|
||||||
cp scripts/chusql.toml scripts/init.sql "$stage/scripts/"
|
cp scripts/chusql.toml scripts/init.sql "$stage/scripts/"
|
||||||
cp scripts/install.ps1 scripts/install.sh "$stage/"
|
cp scripts/install.ps1 scripts/install.sh "$stage/"
|
||||||
if [ "$component" = web ]; then
|
|
||||||
mkdir -p "$stage/static"
|
mkdir -p "$stage/static"
|
||||||
cp -R chusql-web/static/. "$stage/static/"
|
cp -R chusql-web/static/. "$stage/static/"
|
||||||
cp scripts/csql-web.sh "$stage/"
|
cp scripts/csql-web.sh "$stage/"
|
||||||
if [ -f scripts/csql-web.ps1 ]; then cp scripts/csql-web.ps1 "$stage/"; fi
|
if [ -f scripts/csql-web.ps1 ]; then cp scripts/csql-web.ps1 "$stage/"; fi
|
||||||
fi
|
|
||||||
|
|
||||||
chmod +x "$stage/install.sh"
|
chmod +x "$stage/install.sh" "$stage/csql-web.sh"
|
||||||
if [ -f "$stage/csql-web.sh" ]; then chmod +x "$stage/csql-web.sh"; fi
|
|
||||||
|
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
tar -czf "dist/$name.tar.gz" -C "$stage" .
|
tar -czf "dist/$name.tar.gz" -C "$stage" .
|
||||||
@@ -146,16 +149,15 @@ jobs:
|
|||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$component = '${{ matrix.component }}'
|
$name = "chusql-${{ matrix.platform }}"
|
||||||
$name = "chusql-$component-${{ matrix.platform }}"
|
|
||||||
$stage = "dist/$name"
|
$stage = "dist/$name"
|
||||||
if (Test-Path $stage) { Remove-Item -Recurse -Force $stage }
|
if (Test-Path $stage) { Remove-Item -Recurse -Force $stage }
|
||||||
New-Item -ItemType Directory -Force -Path "$stage/bin", "$stage/scripts" | Out-Null
|
New-Item -ItemType Directory -Force -Path "$stage/bin", "$stage/scripts" | Out-Null
|
||||||
|
|
||||||
Copy-Item 'chusql-storage/target/release/chusql-storage.exe' "$stage/bin/"
|
Copy-Item 'chusql-core/storage/target/release/chusql_core_storage.dll' "$stage/bin/"
|
||||||
|
|
||||||
$stackRoot = (& stack path --local-install-root | Select-Object -Last 1).Trim()
|
$stackRoot = (& stack path --local-install-root | Select-Object -Last 1).Trim()
|
||||||
$frontName = if ($component -eq 'web') { 'chusql-web.exe' } else { 'csql.exe' }
|
foreach ($frontName in @('chusql-web.exe', 'chusql-server.exe', 'csql.exe')) {
|
||||||
$front = Join-Path $stackRoot "bin/$frontName"
|
$front = Join-Path $stackRoot "bin/$frontName"
|
||||||
if (-not (Test-Path $front)) {
|
if (-not (Test-Path $front)) {
|
||||||
$front = Get-ChildItem -Path 'chusql-web/.stack-work', 'chusql-cli/.stack-work' -Recurse -Filter $frontName -File -ErrorAction SilentlyContinue |
|
$front = Get-ChildItem -Path 'chusql-web/.stack-work', 'chusql-cli/.stack-work' -Recurse -Filter $frontName -File -ErrorAction SilentlyContinue |
|
||||||
@@ -163,18 +165,19 @@ jobs:
|
|||||||
}
|
}
|
||||||
if (-not $front -or -not (Test-Path $front)) { throw "front end binary not found: $frontName" }
|
if (-not $front -or -not (Test-Path $front)) { throw "front end binary not found: $frontName" }
|
||||||
Copy-Item $front "$stage/bin/"
|
Copy-Item $front "$stage/bin/"
|
||||||
|
}
|
||||||
|
|
||||||
Get-ChildItem 'chusql-storage/target/release' -Filter '*.dll' -File -ErrorAction SilentlyContinue |
|
# 第三方 dll 才要连带上:名字带 chusql 的是本 crate 产物或改名前的残留,上面已按名拷过
|
||||||
|
Get-ChildItem 'chusql-core/storage/target/release' -Filter '*.dll' -File -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.Name -notlike 'chusql*' } |
|
||||||
ForEach-Object { Copy-Item $_.FullName "$stage/bin/" }
|
ForEach-Object { Copy-Item $_.FullName "$stage/bin/" }
|
||||||
|
|
||||||
Copy-Item 'scripts/chusql.toml', 'scripts/init.sql' "$stage/scripts/"
|
Copy-Item 'scripts/chusql.toml', 'scripts/init.sql' "$stage/scripts/"
|
||||||
Copy-Item 'scripts/install.ps1', 'scripts/install.sh' $stage
|
Copy-Item 'scripts/install.ps1', 'scripts/install.sh' $stage
|
||||||
if ($component -eq 'web') {
|
|
||||||
New-Item -ItemType Directory -Force -Path "$stage/static" | Out-Null
|
New-Item -ItemType Directory -Force -Path "$stage/static" | Out-Null
|
||||||
Copy-Item 'chusql-web/static/*' "$stage/static" -Recurse
|
Copy-Item 'chusql-web/static/*' "$stage/static" -Recurse
|
||||||
Copy-Item 'scripts/csql-web.sh' $stage
|
Copy-Item 'scripts/csql-web.sh' $stage
|
||||||
if (Test-Path 'scripts/csql-web.ps1') { Copy-Item 'scripts/csql-web.ps1' $stage }
|
if (Test-Path 'scripts/csql-web.ps1') { Copy-Item 'scripts/csql-web.ps1' $stage }
|
||||||
}
|
|
||||||
|
|
||||||
New-Item -ItemType Directory -Force -Path dist | Out-Null
|
New-Item -ItemType Directory -Force -Path dist | Out-Null
|
||||||
Compress-Archive -Path "$stage/*" -DestinationPath "dist/$name.zip" -Force
|
Compress-Archive -Path "$stage/*" -DestinationPath "dist/$name.zip" -Force
|
||||||
@@ -193,7 +196,7 @@ jobs:
|
|||||||
- name: upload
|
- name: upload
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.platform }}-${{ matrix.component }}
|
name: ${{ matrix.platform }}
|
||||||
path: |
|
path: |
|
||||||
dist/*.zip
|
dist/*.zip
|
||||||
dist/*.tar.gz
|
dist/*.tar.gz
|
||||||
|
|||||||
+10
-7
@@ -1,12 +1,12 @@
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# ChuSQL 单仓库 .gitignore
|
# ChuSQL 单仓库 .gitignore
|
||||||
# chusql-engine/ Haskell (Stack + Cabal)
|
# chusql-core/engine/ Haskell (Stack + Cabal)
|
||||||
# chusql-storage/ Rust (Cargo)
|
# chusql-core/storage/ Rust (Cargo)
|
||||||
# chusql-web/ 前端 (手写 HTML / CSS / JS,无 Node 工具链)
|
# chusql-web/ 前端 (手写 HTML / CSS / JS,无 Node 工具链)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# Haskell / Stack / Cabal —— chusql-engine
|
# Haskell / Stack / Cabal —— chusql-core/engine
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
.stack-work/
|
.stack-work/
|
||||||
dist-newstyle/
|
dist-newstyle/
|
||||||
@@ -26,21 +26,24 @@ cabal.project.local
|
|||||||
cabal.project.local~
|
cabal.project.local~
|
||||||
|
|
||||||
# 锁文件要入库,不要忽略:
|
# 锁文件要入库,不要忽略:
|
||||||
# chusql-engine/stack.yaml.lock
|
# chusql-core/engine/stack.yaml.lock
|
||||||
# cabal.project.freeze
|
# cabal.project.freeze
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# Rust / Cargo —— chusql-storage
|
# Rust / Cargo —— chusql-core/storage
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
target/
|
target/
|
||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
|
|
||||||
# 运行时数据(Rust 服务默认数据目录 CHUSQL_DATA_DIR=data/),不入库
|
# 运行时数据(Rust 服务默认数据目录 CHUSQL_DATA_DIR=data/),不入库
|
||||||
chusql-storage/data/
|
chusql-core/storage/data/
|
||||||
|
|
||||||
# 一键启动脚本的默认数据目录与日志,不入库
|
# 一键启动脚本的默认数据目录与日志,不入库
|
||||||
localdata/
|
localdata/
|
||||||
|
|
||||||
|
# 本机打出来的发行包(scripts/package.sh / package.ps1 的产物),不入库
|
||||||
|
releases/
|
||||||
|
|
||||||
# 前端 IDE 设置(字体等,由 /api/ui-settings 读写),本机专用,不入库
|
# 前端 IDE 设置(字体等,由 /api/ui-settings 读写),本机专用,不入库
|
||||||
scripts/chusql.ui.settings.json
|
scripts/chusql.ui.settings.json
|
||||||
|
|
||||||
@@ -74,7 +77,7 @@ scripts/chusql.ui.settings.json
|
|||||||
\#*\#
|
\#*\#
|
||||||
.\#*
|
.\#*
|
||||||
agent_credientials
|
agent_credientials
|
||||||
|
.dsh
|
||||||
# 注意:.zed/tasks.json 是本仓库共享的构建任务(stack build / run / test / repl),
|
# 注意:.zed/tasks.json 是本仓库共享的构建任务(stack build / run / test / repl),
|
||||||
# 需要入库,所以这里不忽略 .zed/
|
# 需要入库,所以这里不忽略 .zed/
|
||||||
|
|
||||||
|
|||||||
+6
-15
@@ -2,7 +2,7 @@
|
|||||||
{
|
{
|
||||||
"label": "haskell: stack build",
|
"label": "haskell: stack build",
|
||||||
"command": "stack build",
|
"command": "stack build",
|
||||||
"cwd": "$ZED_WORKTREE_ROOT/chusql-engine",
|
"cwd": "$ZED_WORKTREE_ROOT/chusql-core/engine",
|
||||||
"reveal": "always",
|
"reveal": "always",
|
||||||
"hide": "on_success",
|
"hide": "on_success",
|
||||||
"save": "all",
|
"save": "all",
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
{
|
{
|
||||||
"label": "haskell: stack test",
|
"label": "haskell: stack test",
|
||||||
"command": "stack test",
|
"command": "stack test",
|
||||||
"cwd": "$ZED_WORKTREE_ROOT/chusql-engine",
|
"cwd": "$ZED_WORKTREE_ROOT/chusql-core/engine",
|
||||||
"reveal": "always",
|
"reveal": "always",
|
||||||
"hide": "never",
|
"hide": "never",
|
||||||
"save": "all",
|
"save": "all",
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
{
|
{
|
||||||
"label": "haskell: stack run",
|
"label": "haskell: stack run",
|
||||||
"command": "stack run",
|
"command": "stack run",
|
||||||
"cwd": "$ZED_WORKTREE_ROOT/chusql-engine",
|
"cwd": "$ZED_WORKTREE_ROOT/chusql-core/engine",
|
||||||
"reveal": "always",
|
"reveal": "always",
|
||||||
"hide": "never",
|
"hide": "never",
|
||||||
"save": "all",
|
"save": "all",
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
{
|
{
|
||||||
"label": "haskell: stack repl",
|
"label": "haskell: stack repl",
|
||||||
"command": "stack ghci",
|
"command": "stack ghci",
|
||||||
"cwd": "$ZED_WORKTREE_ROOT/chusql-engine",
|
"cwd": "$ZED_WORKTREE_ROOT/chusql-core/engine",
|
||||||
"reveal": "always",
|
"reveal": "always",
|
||||||
"hide": "never",
|
"hide": "never",
|
||||||
"save": "all",
|
"save": "all",
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
"label": "rust: cargo check",
|
"label": "rust: cargo check",
|
||||||
"command": "cargo",
|
"command": "cargo",
|
||||||
"args": ["check", "--all-targets"],
|
"args": ["check", "--all-targets"],
|
||||||
"cwd": "$ZED_WORKTREE_ROOT/chusql-storage",
|
"cwd": "$ZED_WORKTREE_ROOT/chusql-core/storage",
|
||||||
"reveal": "always",
|
"reveal": "always",
|
||||||
"hide": "on_success",
|
"hide": "on_success",
|
||||||
"save": "all",
|
"save": "all",
|
||||||
@@ -44,16 +44,7 @@
|
|||||||
"label": "rust: cargo test",
|
"label": "rust: cargo test",
|
||||||
"command": "cargo",
|
"command": "cargo",
|
||||||
"args": ["test"],
|
"args": ["test"],
|
||||||
"cwd": "$ZED_WORKTREE_ROOT/chusql-storage",
|
"cwd": "$ZED_WORKTREE_ROOT/chusql-core/storage",
|
||||||
"reveal": "always",
|
|
||||||
"hide": "never",
|
|
||||||
"save": "all",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "rust: cargo run server",
|
|
||||||
"command": "cargo",
|
|
||||||
"args": ["run", "--bin", "server"],
|
|
||||||
"cwd": "$ZED_WORKTREE_ROOT/chusql-storage",
|
|
||||||
"reveal": "always",
|
"reveal": "always",
|
||||||
"hide": "never",
|
"hide": "never",
|
||||||
"save": "all",
|
"save": "all",
|
||||||
|
|||||||
@@ -14,9 +14,17 @@
|
|||||||
- 索引由存储层管理,优化器只判断可点查条件。
|
- 索引由存储层管理,优化器只判断可点查条件。
|
||||||
- 优化不能改变结果语义。
|
- 优化不能改变结果语义。
|
||||||
|
|
||||||
|
## 注释规范
|
||||||
|
- 文件级:外部引用语句(Haskell `import`、Rust `use`、JS `import`、HTML 的 `<link>`/`<script>`、
|
||||||
|
CSS `@import`、脚本的依赖/参数区)之后写一段注释,概括本文件的功能;没有引用语句的文件写在
|
||||||
|
文件开头。长度不超过 3 行、合计不超过 90 字。
|
||||||
|
- 函数级:每个函数(含 Haskell 的 `where`/`let` 内函数、Rust 的私有 `fn`)前用一行不超过
|
||||||
|
30 字的注释说明它做什么。
|
||||||
|
- 只写"做什么",不写设计理由、历史与被删掉的旧实现;那些放 `design-decisions.txt` /
|
||||||
|
`known-issues.txt`。
|
||||||
|
|
||||||
## 协作规则
|
## 协作规则
|
||||||
- engine / web / storage 使用规定测试入口。
|
- engine / web / storage 使用规定测试入口。
|
||||||
- 注释、文案保持简短。
|
|
||||||
- 改动说明只在交流中记录。
|
- 改动说明只在交流中记录。
|
||||||
|
|
||||||
## 当前重点
|
## 当前重点
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
优点:
|
优点:
|
||||||
|
|
||||||
- 轻量:两个可执行文件加一份静态资源,解包即用,没有 Node / Python 之类的运行时依赖
|
- 轻量:几个可执行文件加两个动态库、一份静态资源,解包即用,没有 Node / Python 之类的运行时依赖
|
||||||
- 部署容易:Linux 与 macOS 一条命令装完并启动,Windows 解压后跑一次安装脚本
|
- 部署容易:Linux 与 macOS 一条命令装完并启动,Windows 解压后跑一次安装脚本
|
||||||
- 配置统一:一个 `chusql.toml` 同时管住存储、Web 与命令行
|
- 配置统一:一个 `chusql.toml` 同时管住存储、Web 与命令行
|
||||||
- 账号统一:Web 管理端与命令行共用同一个管理员和同一张账号表
|
- 账号统一:Web 管理端与命令行共用同一个管理员和同一张账号表
|
||||||
@@ -14,16 +14,17 @@
|
|||||||
Linux / macOS(自动识别当前平台,取对应发行包):
|
Linux / macOS(自动识别当前平台,取对应发行包):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl -fsSL https://github.com/TheBadRoger/ChuSQL/releases/latest/download/install.sh | sh -s -- --component web
|
curl -fsSL https://raw.githubusercontent.com/TheBadRoger/ChuSQL/main/scripts/install.sh | sh -s -- --component web
|
||||||
```
|
```
|
||||||
|
|
||||||
Windows:下载 `chusql-web-windows-x86_64.zip`,解压后在 PowerShell 里执行
|
Windows:下载 `chusql-windows-x86_64.zip`,解压后在 PowerShell 里执行
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\install.ps1 -Component web
|
.\install.ps1 -Component web
|
||||||
```
|
```
|
||||||
|
|
||||||
只装命令行客户端就把 `web` 换成 `cli`,入口变成 `csql`。安装位置、目录结构与卸载方式见
|
不写 `--component` 时脚本会逐个问「装 cli 吗」「装 web 吗」(默认装 `csql`、不装 Web);只装命令行
|
||||||
|
客户端就把 `web` 换成 `cli`,两个都要用 `both`。安装位置、目录结构与卸载方式见
|
||||||
[doc/install.md](doc/install.md)。
|
[doc/install.md](doc/install.md)。
|
||||||
|
|
||||||
## 启动
|
## 启动
|
||||||
@@ -32,7 +33,7 @@ Windows:下载 `chusql-web-windows-x86_64.zip`,解压后在 PowerShell 里
|
|||||||
csql-web
|
csql-web
|
||||||
```
|
```
|
||||||
|
|
||||||
浏览器打开 http://127.0.0.1:7777 ,默认账号 `root`,口令是安装时给的那个。服务启动后只有系统库 `system`(里面是账号表),**没有默认工作库**:自己建一个库再用 `USE <库名>` 选中它。
|
浏览器打开 http://127.0.0.1:7778 ,默认账号 `root`,口令是安装时给的那个。服务启动后只有系统库 `system`(里面是账号表),**没有默认工作库**:自己建一个库再用 `USE <库名>` 选中它。
|
||||||
换口令、换端口、改数据目录都在安装时写好的 `chusql.toml` 里。
|
换口令、换端口、改数据目录都在安装时写好的 `chusql.toml` 里。
|
||||||
|
|
||||||
## 文档
|
## 文档
|
||||||
@@ -40,6 +41,6 @@ csql-web
|
|||||||
| 文档 | 内容 |
|
| 文档 | 内容 |
|
||||||
| --------------------------------- | ------------------------------------------------------------------------ |
|
| --------------------------------- | ------------------------------------------------------------------------ |
|
||||||
| [安装与卸载](doc/install.md) | 三种安装方式、全部安装选项、装完的目录结构、从源码安装、卸载 |
|
| [安装与卸载](doc/install.md) | 三种安装方式、全部安装选项、装完的目录结构、从源码安装、卸载 |
|
||||||
| [配置选项](doc/config.md) | 配置位置与优先级、全部配置键与默认值、端点规则、口令与安全 |
|
| [配置选项](doc/config.md) | 配置位置与优先级、全部配置键与默认值、数据目录、口令与安全 |
|
||||||
| [命令与选项](doc/commands.md) | `csql` 与 `chusql-web` 的选项、元命令、账号与角色权限、REST 接口 |
|
| [命令与选项](doc/commands.md) | `csql` 与 `chusql-web` 的选项、元命令、账号与角色权限、REST 接口 |
|
||||||
| [架构与组件](doc/architecture.md) | 三个组件怎么分工、一次请求经过什么、数据落在哪 |
|
| [架构与组件](doc/architecture.md) | 三个组件怎么分工、一次请求经过什么、数据落在哪 |
|
||||||
@@ -2,16 +2,22 @@ cabal-version: 2.4
|
|||||||
|
|
||||||
name: chusql-fullchain
|
name: chusql-fullchain
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
synopsis: ChuSQL 全链路基准:Haskell 引擎 + Rust 存储进程一起跑
|
synopsis: ChuSQL 全链路基准:起真 server,全部请求走 TCP
|
||||||
|
description: 以基准客户端身份起一个 chusql-server 进程(存储与引擎都在那个进程里),
|
||||||
|
再经 TCP JSON-lines 协议打登录、ping、catalog、查询等场景。
|
||||||
|
本程序不链引擎、不链 server 库,只用 TCP 客户端与协议模型。
|
||||||
build-type: Simple
|
build-type: Simple
|
||||||
|
|
||||||
executable chusql-fullchain
|
executable chusql-fullchain
|
||||||
main-is: Main.hs
|
main-is: Main.hs
|
||||||
hs-source-dirs: src
|
hs-source-dirs: src
|
||||||
|
build-tool-depends: chusql-server:chusql-server
|
||||||
build-depends: base >= 4.14 && < 5
|
build-depends: base >= 4.14 && < 5
|
||||||
, chusql-engine
|
, chusql-core-model
|
||||||
|
, chusql-interface
|
||||||
, directory
|
, directory
|
||||||
, filepath
|
, filepath
|
||||||
, process
|
, process
|
||||||
|
, text
|
||||||
default-language: Haskell2010
|
default-language: Haskell2010
|
||||||
ghc-options: -O2 -Wall
|
ghc-options: -O2 -Wall
|
||||||
+286
-213
@@ -1,40 +1,39 @@
|
|||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
module Main where
|
module Main where
|
||||||
|
|
||||||
import ChuSQL.Engine (runStatementM)
|
import ChuSQL.Core.Protocol (QueryResult (..), TableInfo (..))
|
||||||
import ChuSQL.Model (Row, Value (..))
|
import ChuSQL.Interface.Link (Client, clientPing, closeClient, connectClient)
|
||||||
import ChuSQL.Storage (IndexResult (..))
|
import ChuSQL.Interface.Session (
|
||||||
import ChuSQL.Storage.IPC (
|
Session,
|
||||||
IPCStorage (..),
|
authenticateSession,
|
||||||
Request (..),
|
catalog,
|
||||||
Response (..),
|
newSession,
|
||||||
TableInfo (..),
|
runStatement,
|
||||||
closeConnection,
|
switchDatabase,
|
||||||
doInsert,
|
|
||||||
doListCatalog,
|
|
||||||
doLookupByColumn,
|
|
||||||
sendRequest,
|
|
||||||
setPipeName,
|
|
||||||
)
|
)
|
||||||
import ChuSQL.Syntax.Parser (parseStatement)
|
import Control.Concurrent (forkIO, threadDelay)
|
||||||
import Control.Concurrent (threadDelay)
|
import Control.Exception (IOException, bracket, evaluate, try)
|
||||||
import Control.Exception (SomeException, bracket, evaluate, try)
|
import Control.Monad (unless, when)
|
||||||
import Control.Monad (filterM, unless, when)
|
import Data.List (intercalate)
|
||||||
import Data.List (intercalate, isInfixOf)
|
import Data.Text (Text)
|
||||||
|
import qualified Data.Text as T
|
||||||
import GHC.Clock (getMonotonicTime)
|
import GHC.Clock (getMonotonicTime)
|
||||||
import System.Directory (
|
import System.Directory (
|
||||||
createDirectoryIfMissing,
|
createDirectoryIfMissing,
|
||||||
doesFileExist,
|
findExecutable,
|
||||||
getTemporaryDirectory,
|
getTemporaryDirectory,
|
||||||
removeDirectoryRecursive,
|
removeDirectoryRecursive,
|
||||||
)
|
)
|
||||||
import System.Environment (getArgs)
|
import System.Environment (getArgs)
|
||||||
import System.Exit (exitFailure)
|
import System.Exit (ExitCode (..), exitFailure)
|
||||||
import System.FilePath ((</>))
|
import System.FilePath ((</>))
|
||||||
import System.Info (os)
|
import System.Info (os)
|
||||||
|
import System.IO (Handle, hGetLine)
|
||||||
import System.Process (
|
import System.Process (
|
||||||
CreateProcess (..),
|
CreateProcess (std_err, std_out),
|
||||||
ProcessHandle,
|
ProcessHandle,
|
||||||
StdStream (..),
|
StdStream (CreatePipe, NoStream),
|
||||||
createProcess,
|
createProcess,
|
||||||
proc,
|
proc,
|
||||||
terminateProcess,
|
terminateProcess,
|
||||||
@@ -42,103 +41,51 @@ import System.Process (
|
|||||||
)
|
)
|
||||||
import Text.Printf (printf)
|
import Text.Printf (printf)
|
||||||
|
|
||||||
-- 全链路基准:Haskell 引擎与 Rust 存储进程一起跑,语句走完整链路并打印墙上耗时。
|
-- 全链路基准:客户端经 TCP 连 chusql-server,落盘到进程内 Rust 存储。
|
||||||
|
-- 每条语句都走完整链路:解析、语义检查、提交。
|
||||||
|
-- 对照项:ping 与 catalog。
|
||||||
|
|
||||||
-- | 找 Rust 存储进程:优先 release
|
-- | 一个基准世界:真 server 进程 + 一条 TCP 会话
|
||||||
findServer :: FilePath -> IO FilePath
|
data Bench = Bench
|
||||||
findServer "" = do
|
{ bProcess :: ProcessHandle
|
||||||
let dirs =
|
, bDataDir :: FilePath
|
||||||
[ "chusql-storage/target"
|
, bClient :: Client
|
||||||
, "../chusql-storage/target"
|
, bSession :: Session
|
||||||
]
|
}
|
||||||
modes = ["release", "debug"]
|
|
||||||
-- 名字跟平台走:同一个 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
|
|
||||||
(p : _) -> do
|
|
||||||
unless ("release" `isInfixOf` p) $
|
|
||||||
putStrLn "!! using the debug storage binary; numbers will be slower, run: cargo build --release"
|
|
||||||
pure p
|
|
||||||
[] -> do
|
|
||||||
putStrLn "Rust storage binary not found. Build it first:"
|
|
||||||
putStrLn " cd chusql-storage && cargo build --release"
|
|
||||||
exitFailure
|
|
||||||
findServer p = do
|
|
||||||
ok <- doesFileExist p
|
|
||||||
if ok
|
|
||||||
then pure p
|
|
||||||
else do
|
|
||||||
putStrLn ("storage binary not found: " ++ p)
|
|
||||||
exitFailure
|
|
||||||
|
|
||||||
-- | 起存储进程并等管道可连:调参走 --config 指的那份配置文件,环境原样继承
|
-- | 跑一条 SQL 文本(整条链路)
|
||||||
startServer :: FilePath -> FilePath -> IO ProcessHandle
|
runSql :: Bench -> String -> IO (Either Text QueryResult)
|
||||||
startServer serverPath configPath = do
|
runSql bench sql = runStatement (bSession bench) (T.pack sql)
|
||||||
(_, _, _, ph) <-
|
|
||||||
createProcess
|
|
||||||
(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"
|
|
||||||
exitFailure
|
|
||||||
pure ph
|
|
||||||
|
|
||||||
-- | 轮询 ping,直到对面应答
|
|
||||||
waitReady :: Int -> IO Bool
|
|
||||||
waitReady 0 = pure False
|
|
||||||
waitReady n = do
|
|
||||||
r <- try (sendRequest ReqPing) :: IO (Either SomeException Response)
|
|
||||||
case r of
|
|
||||||
Right RespPong -> pure True
|
|
||||||
_ -> threadDelay 25000 >> waitReady (n - 1)
|
|
||||||
|
|
||||||
-- | 停存储进程并清掉缓存连接
|
|
||||||
stopServer :: ProcessHandle -> FilePath -> IO ()
|
|
||||||
stopServer ph dataDir = do
|
|
||||||
closeConnection
|
|
||||||
terminateProcess ph
|
|
||||||
_ <- waitForProcess ph
|
|
||||||
_ <- try (removeDirectoryRecursive dataDir) :: IO (Either SomeException ())
|
|
||||||
pure ()
|
|
||||||
|
|
||||||
-- | 量一次墙上耗时并强制求出行数
|
-- | 量一次墙上耗时并强制求出行数
|
||||||
timedRows :: IO (Either String [Row]) -> IO (Double, Either String Int)
|
timedQuery :: Bench -> String -> IO (Double, Either Text Int)
|
||||||
timedRows act = do
|
timedQuery bench sql = do
|
||||||
t0 <- getMonotonicTime
|
t0 <- getMonotonicTime
|
||||||
r <- act
|
r <- runSql bench sql
|
||||||
n <- case r of
|
n <- case r of
|
||||||
Left _ -> pure (-1)
|
Left _ -> pure (-1)
|
||||||
Right rows -> evaluate (length rows)
|
Right result -> evaluate (qrRowCount result)
|
||||||
t1 <- getMonotonicTime
|
t1 <- getMonotonicTime
|
||||||
pure (t1 - t0, either Left (const (Right n)) r)
|
pure (t1 - t0, either Left (const (Right n)) r)
|
||||||
|
|
||||||
-- | 跑一条 SQL 文本(整条链路)
|
|
||||||
runSql :: String -> IO (Either String [Row])
|
|
||||||
runSql sql = case parseStatement sql of
|
|
||||||
Left e -> pure (Left e)
|
|
||||||
Right stmt -> runIPCStorage (runStatementM stmt)
|
|
||||||
|
|
||||||
-- | 打印一行结果
|
-- | 打印一行结果
|
||||||
report :: String -> Either String Int -> Double -> IO ()
|
report :: String -> Either Text Int -> Double -> IO ()
|
||||||
report label result dt = case result of
|
report label result dt = case result of
|
||||||
Left e -> printf "%-38s %10s !! %s\n" label "FAILED" e
|
Left e -> printf "%-38s %10s !! %s\n" label ("FAILED" :: String) (T.unpack e)
|
||||||
Right n -> printf "%-38s %8.1f ms %8d rows\n" label (dt * 1000) n
|
Right n -> printf "%-38s %8.1f ms %8d rows\n" label (dt * 1000) n
|
||||||
|
|
||||||
-- | 单独量一条可重复的 SQL(先预热)
|
-- | 单独量一条可重复的 SQL(先预热)
|
||||||
benchSql :: String -> String -> IO (Either String Int)
|
benchSql :: Bench -> String -> String -> IO (Either Text Int)
|
||||||
benchSql label sql = do
|
benchSql bench label sql = do
|
||||||
_ <- runSql sql
|
_ <- runSql bench sql
|
||||||
(dt, r) <- timedRows (runSql sql)
|
(dt, r) <- timedQuery bench sql
|
||||||
report label r dt
|
report label r dt
|
||||||
pure r
|
pure r
|
||||||
|
|
||||||
-- | 只跑一次的语句(重跑会报错)
|
-- | 只跑一次的语句(重跑会报错)
|
||||||
benchSqlOnce :: String -> String -> IO (Either String Int)
|
benchSqlOnce :: Bench -> String -> String -> IO (Either Text Int)
|
||||||
benchSqlOnce label sql = do
|
benchSqlOnce bench label sql = do
|
||||||
(dt, r) <- timedRows (runSql sql)
|
(dt, r) <- timedQuery bench sql
|
||||||
report label r dt
|
report label r dt
|
||||||
pure r
|
pure r
|
||||||
|
|
||||||
@@ -157,10 +104,10 @@ orderSql u j =
|
|||||||
(j `mod` 50)
|
(j `mod` 50)
|
||||||
|
|
||||||
-- | 批量插入并报平均耗时
|
-- | 批量插入并报平均耗时
|
||||||
batchInsert :: String -> Int -> (Int -> String) -> IO (Either String ())
|
batchInsert :: Bench -> String -> Int -> (Int -> String) -> IO (Either Text ())
|
||||||
batchInsert label n mkSql = do
|
batchInsert bench label n mkSql = do
|
||||||
t0 <- getMonotonicTime
|
t0 <- getMonotonicTime
|
||||||
results <- mapM (runSql . mkSql) [1 .. n]
|
results <- mapM (runSql bench . mkSql) [1 .. n]
|
||||||
t1 <- getMonotonicTime
|
t1 <- getMonotonicTime
|
||||||
let total = t1 - t0
|
let total = t1 - t0
|
||||||
errs = [e | Left e <- results]
|
errs = [e | Left e <- results]
|
||||||
@@ -173,66 +120,66 @@ batchInsert label n mkSql = do
|
|||||||
(total * 1000 / fromIntegral n)
|
(total * 1000 / fromIntegral n)
|
||||||
pure (Right ())
|
pure (Right ())
|
||||||
(e : _) -> do
|
(e : _) -> do
|
||||||
printf "%-38s %10s !! %s\n" label "FAILED" e
|
printf "%-38s %10s !! %s\n" label ("FAILED" :: String) (T.unpack e)
|
||||||
pure (Left e)
|
pure (Left e)
|
||||||
|
|
||||||
-- | 一次点查(原始 IPC,不经过引擎)
|
-- | 纯 TCP 往返:只过协议层,不过引擎与存储
|
||||||
benchLookup :: String -> String -> Int -> Int -> IO ()
|
benchPing :: Bench -> Int -> IO ()
|
||||||
benchLookup table column key times = do
|
benchPing bench times = do
|
||||||
_ <- doLookupByColumn table column (VInt key)
|
_ <- clientPing (bClient bench)
|
||||||
t0 <- getMonotonicTime
|
t0 <- getMonotonicTime
|
||||||
results <- mapM (const (doLookupByColumn table column (VInt key))) [1 .. times]
|
results <- mapM (const (clientPing (bClient bench))) [1 .. times]
|
||||||
t1 <- getMonotonicTime
|
t1 <- getMonotonicTime
|
||||||
let total = t1 - t0
|
let total = t1 - t0
|
||||||
hit = length [() | Right (IndexRows (_ : _)) <- results]
|
hits = length [() | Right _ <- results]
|
||||||
printf
|
printf
|
||||||
"%-38s %8.1f ms %7.3f ms/op (hits %d/%d)\n"
|
"%-38s %8.1f ms %7.3f ms/op (pongs %d/%d)\n"
|
||||||
"lookup_by_index (raw IPC, by id)"
|
("tcp round trip (ping, no engine)" :: String)
|
||||||
(total * 1000)
|
(total * 1000)
|
||||||
(total * 1000 / fromIntegral times)
|
(total * 1000 / fromIntegral times)
|
||||||
hit
|
hits
|
||||||
|
times
|
||||||
|
|
||||||
|
-- | 一次点查(走语句,等于把 id 索引那一支也量进去)
|
||||||
|
benchLookup :: Bench -> Int -> Int -> IO ()
|
||||||
|
benchLookup bench key times = do
|
||||||
|
let sql = printf "SELECT name FROM users WHERE id = %d" key
|
||||||
|
_ <- runSql bench sql
|
||||||
|
t0 <- getMonotonicTime
|
||||||
|
results <- mapM (const (runSql bench sql)) [1 .. times]
|
||||||
|
t1 <- getMonotonicTime
|
||||||
|
let total = t1 - t0
|
||||||
|
hits = length [() | Right result <- results, qrRowCount result > 0]
|
||||||
|
printf
|
||||||
|
"%-38s %8.1f ms %7.3f ms/op (hits %d/%d)\n"
|
||||||
|
("point lookup by id (statement, over TCP)" :: String)
|
||||||
|
(total * 1000)
|
||||||
|
(total * 1000 / fromIntegral times)
|
||||||
|
hits
|
||||||
times
|
times
|
||||||
|
|
||||||
-- | 量 list_catalog(只取结构,不取行)
|
-- | 量 list_catalog(只取结构,不取行)
|
||||||
benchListCatalog :: Int -> IO ()
|
benchCatalog :: Bench -> Int -> IO ()
|
||||||
benchListCatalog n = do
|
benchCatalog bench n = do
|
||||||
|
_ <- catalog (bSession bench)
|
||||||
t0 <- getMonotonicTime
|
t0 <- getMonotonicTime
|
||||||
_ <- mapM (const doListCatalog) [1 .. n]
|
results <- mapM (const (catalog (bSession bench))) [1 .. n]
|
||||||
t1 <- getMonotonicTime
|
t1 <- getMonotonicTime
|
||||||
let total = t1 - t0
|
let total = t1 - t0
|
||||||
|
hits = length [() | Right _ <- results]
|
||||||
printf
|
printf
|
||||||
"%-38s %8.1f ms %7.3f ms/op\n"
|
"%-38s %8.1f ms %7.3f ms/op (%d/%d ok)\n"
|
||||||
"list_catalog (schema only)"
|
("list_catalog (schema only, over TCP)" :: String)
|
||||||
(total * 1000)
|
(total * 1000)
|
||||||
(total * 1000 / fromIntegral n)
|
(total * 1000 / fromIntegral n)
|
||||||
|
hits
|
||||||
-- | 量原始 IPC 插入(不经过引擎)
|
n
|
||||||
benchRawInsert :: String -> [Row] -> IO ()
|
|
||||||
benchRawInsert table rows = do
|
|
||||||
t0 <- getMonotonicTime
|
|
||||||
results <- mapM (\r -> doInsert table r) rows
|
|
||||||
t1 <- getMonotonicTime
|
|
||||||
let total = t1 - t0
|
|
||||||
n = length rows
|
|
||||||
errs = [e | Left e <- results]
|
|
||||||
case errs of
|
|
||||||
[] ->
|
|
||||||
printf
|
|
||||||
"%-38s %8.1f ms %7.3f ms/row\n"
|
|
||||||
("raw IPC insert x " ++ show n)
|
|
||||||
(total * 1000)
|
|
||||||
(total * 1000 / fromIntegral n)
|
|
||||||
(e : _) -> printf "%-38s %10s !! %s\n" "raw IPC insert" "FAILED" e
|
|
||||||
|
|
||||||
-- | 造一行 users 数据(给原始 IPC 用)
|
|
||||||
userRow :: Int -> Row
|
|
||||||
userRow i = [("id", VInt i), ("name", VStr ("user" ++ show i)), ("age", VInt (i `mod` 100))]
|
|
||||||
|
|
||||||
-- | 依次跑一批语句,返回 (耗时秒, 错误清单)
|
-- | 依次跑一批语句,返回 (耗时秒, 错误清单)
|
||||||
runBatch :: [String] -> IO (Double, [String])
|
runBatch :: Bench -> [String] -> IO (Double, [Text])
|
||||||
runBatch sqls = do
|
runBatch bench sqls = do
|
||||||
t0 <- getMonotonicTime
|
t0 <- getMonotonicTime
|
||||||
results <- mapM runSql sqls
|
results <- mapM (runSql bench) sqls
|
||||||
t1 <- getMonotonicTime
|
t1 <- getMonotonicTime
|
||||||
pure (t1 - t0, [e | Left e <- results])
|
pure (t1 - t0, [e | Left e <- results])
|
||||||
|
|
||||||
@@ -240,6 +187,7 @@ runBatch sqls = do
|
|||||||
chunked :: Int -> Int -> [[Int]]
|
chunked :: Int -> Int -> [[Int]]
|
||||||
chunked size n = go [1 .. n]
|
chunked size n = go [1 .. n]
|
||||||
where
|
where
|
||||||
|
-- | 递归切分
|
||||||
go [] = []
|
go [] = []
|
||||||
go xs = let (a, b) = splitAt size xs in a : go b
|
go xs = let (a, b) = splitAt size xs in a : go b
|
||||||
|
|
||||||
@@ -264,11 +212,11 @@ printStats i =
|
|||||||
)
|
)
|
||||||
|
|
||||||
-- | 主场景
|
-- | 主场景
|
||||||
runScenarios :: Int -> Int -> IO ()
|
runScenarios :: Bench -> Int -> Int -> IO ()
|
||||||
runScenarios u o = do
|
runScenarios bench u o = do
|
||||||
putStrLn "\n[create tables]"
|
putStrLn "\n[create tables]"
|
||||||
r1 <- benchSqlOnce "CREATE TABLE users" "CREATE TABLE users (id int, name str, age int)"
|
r1 <- benchSqlOnce bench "CREATE TABLE users" "CREATE TABLE users (id int, name str, age int)"
|
||||||
r2 <- benchSqlOnce "CREATE TABLE orders" "CREATE TABLE orders (id int, user_id int, product str)"
|
r2 <- benchSqlOnce bench "CREATE TABLE orders" "CREATE TABLE orders (id int, user_id int, product str)"
|
||||||
case (r1, r2) of
|
case (r1, r2) of
|
||||||
(Right _, Right _) -> pure ()
|
(Right _, Right _) -> pure ()
|
||||||
_ -> do
|
_ -> do
|
||||||
@@ -276,91 +224,246 @@ runScenarios u o = do
|
|||||||
exitFailure
|
exitFailure
|
||||||
|
|
||||||
putStrLn "\n[write: every INSERT is a full durable commit]"
|
putStrLn "\n[write: every INSERT is a full durable commit]"
|
||||||
_ <- batchInsert (printf "%d INSERT INTO users" u) u userSql
|
_ <- batchInsert bench (printf "%d INSERT INTO users" u) u userSql
|
||||||
_ <- batchInsert (printf "%d INSERT INTO orders" o) o (orderSql u)
|
_ <- batchInsert bench (printf "%d INSERT INTO orders" o) o (orderSql u)
|
||||||
|
|
||||||
putStrLn "\n[breakdown: same INSERT split in two, both via raw IPC, no engine]"
|
putStrLn "\n[breakdown: protocol round trip vs one catalog call, both over TCP]"
|
||||||
_ <- benchSqlOnce "CREATE TABLE raw_users" "CREATE TABLE raw_users (id int, name str, age int)"
|
benchPing bench 200
|
||||||
benchListCatalog 200
|
benchCatalog bench 200
|
||||||
benchRawInsert "raw_users" [userRow i | i <- [1 .. u]]
|
|
||||||
|
|
||||||
putStrLn "\n[read/query: full statement, including parsing and semantic checks]"
|
putStrLn "\n[read/query: full statement, including parsing and semantic checks]"
|
||||||
_ <- benchSql "SELECT * FROM users" "SELECT * FROM users"
|
_ <- benchSql bench "SELECT * FROM users" "SELECT * FROM users"
|
||||||
ageR <- benchSql "SELECT name FROM users WHERE age > 90" "SELECT name FROM users WHERE age > 90"
|
ageR <- benchSql bench "SELECT name FROM users WHERE age > 90" "SELECT name FROM users WHERE age > 90"
|
||||||
|
|
||||||
putStrLn "\n[read/query: join]"
|
putStrLn "\n[read/query: join]"
|
||||||
joinR <-
|
joinR <-
|
||||||
benchSql
|
benchSql
|
||||||
|
bench
|
||||||
"JOIN + WHERE (hash join)"
|
"JOIN + WHERE (hash join)"
|
||||||
"SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id WHERE u.age > 90"
|
"SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id WHERE u.age > 90"
|
||||||
|
|
||||||
putStrLn "\n[point lookup]"
|
putStrLn "\n[point lookup]"
|
||||||
benchLookup "users" "id" (u `div` 2) 100
|
benchLookup bench (u `div` 2) 100
|
||||||
_ <- benchSql "SELECT ... WHERE id = k (via Lookup)" (printf "SELECT name FROM users WHERE id = %d" (u `div` 2))
|
|
||||||
|
|
||||||
putStrLn "\n[batch insert: one statement with N rows, one durable commit]"
|
putStrLn "\n[batch insert: one statement with N rows, one durable commit]"
|
||||||
_ <- benchSqlOnce "CREATE TABLE batch_t" "CREATE TABLE batch_t (id int, name str, age int)"
|
_ <- benchSqlOnce bench "CREATE TABLE batch_t" "CREATE TABLE batch_t (id int, name str, age int)"
|
||||||
let groups = chunked 100 u
|
let groups = chunked 100 u
|
||||||
(bt, batchErrs) <- runBatch (map multiInsertSql groups)
|
perStmt = maximum (0 : map length groups)
|
||||||
|
(bt, batchErrs) <- runBatch bench (map multiInsertSql groups)
|
||||||
case batchErrs of
|
case batchErrs of
|
||||||
[] ->
|
[] ->
|
||||||
printf
|
printf
|
||||||
"%-38s %8.1f ms %7.3f ms/row (%d rows/stmt x %d statements)\n"
|
"%-38s %8.1f ms %7.3f ms/row (%d rows/stmt x %d statements)\n"
|
||||||
"multi-row INSERT"
|
("multi-row INSERT" :: String)
|
||||||
(bt * 1000)
|
(bt * 1000)
|
||||||
(bt * 1000 / fromIntegral u)
|
(bt * 1000 / fromIntegral u)
|
||||||
(100 :: Int)
|
(perStmt :: Int)
|
||||||
(length groups)
|
(length groups)
|
||||||
(e : _) -> printf "%-38s %10s !! %s\n" "multi-row INSERT" "FAILED" e
|
(e : _) -> printf "%-38s %10s !! %s\n" ("multi-row INSERT" :: String) ("FAILED" :: String) (T.unpack e)
|
||||||
|
|
||||||
putStrLn "\n[row delete: cost tracks the scan plus one commit, not the row count]"
|
putStrLn "\n[row delete: cost tracks the scan plus one commit, not the row count]"
|
||||||
_ <- benchSqlOnce "DELETE FROM users WHERE id = k (1 row)" (printf "DELETE FROM users WHERE id = %d" (u `div` 2))
|
_ <- benchSqlOnce bench "DELETE FROM users WHERE id = k (1 row)" (printf "DELETE FROM users WHERE id = %d" (u `div` 2))
|
||||||
_ <- benchSqlOnce "DELETE FROM users (all)" "DELETE FROM users"
|
_ <- benchSqlOnce bench "DELETE FROM users (all)" "DELETE FROM users"
|
||||||
|
|
||||||
putStrLn "\n[secondary index: falls back to scan, then uses the index once built]"
|
putStrLn "\n[secondary index: falls back to scan, then uses the index once built]"
|
||||||
_ <- benchSqlOnce "CREATE TABLE items" "CREATE TABLE items (id int, code int)"
|
_ <- benchSqlOnce bench "CREATE TABLE items" "CREATE TABLE items (id int, code int)"
|
||||||
let itemGroups = chunked 100 o
|
let itemGroups = chunked 100 o
|
||||||
itemSql ids =
|
itemSql ids =
|
||||||
"INSERT INTO items (id, code) VALUES "
|
"INSERT INTO items (id, code) VALUES "
|
||||||
++ intercalate ", " [printf "(%d, %d)" i (i * 7 + 1) | i <- ids]
|
++ intercalate ", " [printf "(%d, %d)" i (i * 7 + 1) | i <- ids]
|
||||||
(it, itemErrs) <- runBatch (map itemSql itemGroups)
|
(it, itemErrs) <- runBatch bench (map itemSql itemGroups)
|
||||||
case itemErrs of
|
case itemErrs of
|
||||||
[] -> pure ()
|
[] -> pure ()
|
||||||
(e : _) -> printf " !! %s\n" e
|
(e : _) -> printf " !! %s\n" (T.unpack e)
|
||||||
printf " (loaded %d rows into items: %.1f ms)\n" o (it * 1000)
|
printf " (loaded %d rows into items: %.1f ms)\n" o (it * 1000)
|
||||||
let targetCode = (o `div` 2) * 7 + 1
|
let targetCode = (o `div` 2) * 7 + 1
|
||||||
_ <-
|
_ <-
|
||||||
benchSql
|
benchSql
|
||||||
|
bench
|
||||||
"SELECT ... WHERE code = k (no index)"
|
"SELECT ... WHERE code = k (no index)"
|
||||||
(printf "SELECT id FROM items WHERE code = %d" targetCode)
|
(printf "SELECT id FROM items WHERE code = %d" targetCode)
|
||||||
_ <- benchSqlOnce "CREATE INDEX ON items (code)" "CREATE INDEX ON items (code)"
|
_ <- benchSqlOnce bench "CREATE INDEX ON items (code)" "CREATE INDEX ON items (code)"
|
||||||
_ <-
|
_ <-
|
||||||
benchSql
|
benchSql
|
||||||
|
bench
|
||||||
"SELECT ... WHERE code = k (via index)"
|
"SELECT ... WHERE code = k (via index)"
|
||||||
(printf "SELECT id FROM items WHERE code = %d" targetCode)
|
(printf "SELECT id FROM items WHERE code = %d" targetCode)
|
||||||
|
|
||||||
putStrLn "\n[catalog stats]"
|
putStrLn "\n[catalog stats]"
|
||||||
cat <- doListCatalog
|
cat <- catalog (bSession bench)
|
||||||
case cat of
|
case cat of
|
||||||
Left e -> printf " failed to read stats: %s\n" e
|
Left e -> printf " failed to read stats: %s\n" (T.unpack e)
|
||||||
Right infos -> mapM_ printStats infos
|
Right infos -> mapM_ printStats infos
|
||||||
|
|
||||||
putStrLn "\n[checks]"
|
putStrLn "\n[checks]"
|
||||||
let expectAge = 9 * (u `div` 100) + max 0 (u `mod` 100 - 90)
|
let expectAge = 9 * (u `div` 100) + max 0 (u `mod` 100 - 90)
|
||||||
printf
|
printf
|
||||||
"age > 90 matched %s (expected %d rows from the data distribution)\n"
|
"age > 90 matched %s (expected %d rows from the data distribution)\n"
|
||||||
(either show show ageR)
|
(either T.unpack show ageR)
|
||||||
expectAge
|
expectAge
|
||||||
when (u == o) $
|
when (u == o) $
|
||||||
printf
|
printf
|
||||||
"JOIN matched %s (one order per user, should equal the line above = %d)\n"
|
"JOIN matched %s (one order per user, should equal the line above = %d)\n"
|
||||||
(either show show joinR)
|
(either T.unpack show joinR)
|
||||||
expectAge
|
expectAge
|
||||||
unless (u == o) $
|
unless (u == o) $
|
||||||
putStrLn "(users and orders differ in size; no simple formula, printing only)"
|
putStrLn "(users and orders differ in size; no simple formula, printing only)"
|
||||||
printf "multi-row INSERT loaded %d rows (expected %d)\n" u u
|
loadedCheck <- runSql bench "SELECT id FROM batch_t"
|
||||||
|
printf
|
||||||
|
"multi-row INSERT loaded %s rows (expected %d)\n"
|
||||||
|
(either T.unpack (show . qrRowCount) loadedCheck)
|
||||||
|
u
|
||||||
printf "secondary index: code = %d should find id = %d\n" targetCode (o `div` 2)
|
printf "secondary index: code = %d should find id = %d\n" targetCode (o `div` 2)
|
||||||
|
|
||||||
|
-- | 基准用的库名
|
||||||
|
benchDatabase :: String
|
||||||
|
benchDatabase = "bench"
|
||||||
|
|
||||||
|
-- | 服务端没有默认库:先开一个库,再把之后的语句都落进它
|
||||||
|
prepareDatabase :: Session -> IO ()
|
||||||
|
prepareDatabase session = do
|
||||||
|
created <- runStatement session ("CREATE DATABASE " <> T.pack benchDatabase)
|
||||||
|
case created of
|
||||||
|
Left e -> die ("cannot create the benchmark database: " ++ T.unpack e)
|
||||||
|
Right _ -> do
|
||||||
|
used <- switchDatabase session (T.pack benchDatabase)
|
||||||
|
case used of
|
||||||
|
Left e -> die ("cannot select the benchmark database: " ++ T.unpack e)
|
||||||
|
Right () -> pure ()
|
||||||
|
where
|
||||||
|
-- | 报错退出
|
||||||
|
die message = do
|
||||||
|
putStrLn message
|
||||||
|
exitFailure
|
||||||
|
|
||||||
|
-- | 按平台补上 .exe 后缀
|
||||||
|
binaryName :: String -> String
|
||||||
|
binaryName stem = if os == "mingw32" then stem ++ ".exe" else stem
|
||||||
|
|
||||||
|
-- | 在 PATH 里找 server 可执行文件
|
||||||
|
locateServerExe :: IO FilePath
|
||||||
|
locateServerExe = do
|
||||||
|
found <- findExecutable (binaryName "chusql-server")
|
||||||
|
case found of
|
||||||
|
Just path -> pure path
|
||||||
|
Nothing -> do
|
||||||
|
putStrLn "chusql-server was not found on PATH: run `stack build` first"
|
||||||
|
exitFailure
|
||||||
|
|
||||||
|
-- | 从 server 输出里读出监听端口
|
||||||
|
readListeningPort :: Handle -> IO Int
|
||||||
|
readListeningPort handle = do
|
||||||
|
line <- hGetLine handle
|
||||||
|
case portFromBanner line of
|
||||||
|
Just port -> pure port
|
||||||
|
Nothing -> readListeningPort handle
|
||||||
|
|
||||||
|
-- | 取一行里最后一串数字
|
||||||
|
portFromBanner :: String -> Maybe Int
|
||||||
|
portFromBanner line
|
||||||
|
| not ("listening:" `isInfixOf` line) = Nothing
|
||||||
|
| otherwise = case span isDigit (dropWhile (not . isDigit) (reverse line)) of
|
||||||
|
([], _) -> Nothing
|
||||||
|
(digits, _) -> Just (read (reverse digits))
|
||||||
|
|
||||||
|
-- | 判断子串是否出现
|
||||||
|
isInfixOf :: String -> String -> Bool
|
||||||
|
isInfixOf needle haystack = any (prefix needle) (tails haystack)
|
||||||
|
where
|
||||||
|
-- | 前缀匹配
|
||||||
|
prefix [] _ = True
|
||||||
|
prefix _ [] = False
|
||||||
|
prefix (x : xs) (y : ys) = x == y && prefix xs ys
|
||||||
|
-- | 所有后缀
|
||||||
|
tails [] = [[]]
|
||||||
|
tails s@(_ : rest) = s : tails rest
|
||||||
|
|
||||||
|
-- | 判断是不是数字字符
|
||||||
|
isDigit :: Char -> Bool
|
||||||
|
isDigit ch = ch >= '0' && ch <= '9'
|
||||||
|
|
||||||
|
-- | 把 server 的 stdout 抽干,免得管道塞满
|
||||||
|
drainHandle :: Handle -> IO ()
|
||||||
|
drainHandle handle = do
|
||||||
|
result <- try (hGetLine handle) :: IO (Either IOException String)
|
||||||
|
case result of
|
||||||
|
Left _ -> pure ()
|
||||||
|
Right _ -> drainHandle handle
|
||||||
|
|
||||||
|
-- | 起 server、连上去、登录、开库
|
||||||
|
startBench :: IO Bench
|
||||||
|
startBench = do
|
||||||
|
bin <- locateServerExe
|
||||||
|
tmp <- getTemporaryDirectory
|
||||||
|
stamp <- getMonotonicTime
|
||||||
|
let name = "chusql-fullchain-" ++ show (round (stamp * 1e6) :: Int)
|
||||||
|
dataDir = tmp </> name
|
||||||
|
configPath = tmp </> (name ++ ".toml")
|
||||||
|
-- TOML 的普通字符串会吃反斜杠,路径统一用正斜杠
|
||||||
|
slashed = map (\c -> if c == '\\' then '/' else c) dataDir
|
||||||
|
createDirectoryIfMissing True dataDir
|
||||||
|
writeFile configPath (benchConfigText slashed)
|
||||||
|
printf "config : %s\n" configPath
|
||||||
|
printf "data dir : %s\n" dataDir
|
||||||
|
(_, Just out, _, process) <-
|
||||||
|
createProcess (proc bin ["--config", configPath]){std_out = CreatePipe, std_err = NoStream}
|
||||||
|
port <- readListeningPort out
|
||||||
|
_ <- forkIO (drainHandle out)
|
||||||
|
printf "server : pid launched, tcp port %d\n" port
|
||||||
|
linked <- connectClient "127.0.0.1" port
|
||||||
|
case linked of
|
||||||
|
Left err -> do
|
||||||
|
putStrLn ("cannot connect to the server: " ++ T.unpack err)
|
||||||
|
stopProcess process
|
||||||
|
exitFailure
|
||||||
|
Right client -> do
|
||||||
|
session <- newSession client
|
||||||
|
auth <- authenticateSession session (T.pack "admin") (T.pack "s3cret")
|
||||||
|
case auth of
|
||||||
|
Left err -> do
|
||||||
|
putStrLn ("cannot sign in: " ++ T.unpack err)
|
||||||
|
closeClient client
|
||||||
|
stopProcess process
|
||||||
|
exitFailure
|
||||||
|
Right () -> do
|
||||||
|
prepareDatabase session
|
||||||
|
pure (Bench process dataDir client session)
|
||||||
|
|
||||||
|
-- | 基准用的配置:管理员凭据、随机端口、临时数据目录
|
||||||
|
benchConfigText :: String -> String
|
||||||
|
benchConfigText dataDir =
|
||||||
|
unlines
|
||||||
|
[ "[web]"
|
||||||
|
, "user = \"admin\""
|
||||||
|
, "password = \"s3cret\""
|
||||||
|
, ""
|
||||||
|
, "[server]"
|
||||||
|
, "host = \"127.0.0.1\""
|
||||||
|
, "port = 0"
|
||||||
|
, ""
|
||||||
|
, "[storage]"
|
||||||
|
, "data_dir = \"" ++ dataDir ++ "\""
|
||||||
|
, ""
|
||||||
|
, "[log]"
|
||||||
|
, "level = \"error\""
|
||||||
|
]
|
||||||
|
|
||||||
|
-- | 收摊:关会话、停 server、清数据目录
|
||||||
|
stopBench :: Bench -> IO ()
|
||||||
|
stopBench bench = do
|
||||||
|
putStrLn "\n[teardown] closing the TCP session, stopping the server, removing the data dir"
|
||||||
|
closeClient (bClient bench)
|
||||||
|
stopProcess (bProcess bench)
|
||||||
|
_ <- try (removeDirectoryRecursive (bDataDir bench)) :: IO (Either IOException ())
|
||||||
|
pure ()
|
||||||
|
|
||||||
|
-- | 杀掉 server 进程
|
||||||
|
stopProcess :: ProcessHandle -> IO ()
|
||||||
|
stopProcess process = do
|
||||||
|
terminateProcess process
|
||||||
|
_ <- try (waitForProcess process) :: IO (Either IOException ExitCode)
|
||||||
|
pure ()
|
||||||
|
|
||||||
-- | 主入口
|
-- | 主入口
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main = do
|
main = do
|
||||||
@@ -368,48 +471,18 @@ main = do
|
|||||||
let pick i d = if length args > i then args !! i else d
|
let pick i d = if length args > i then args !! i else d
|
||||||
u = read (pick 0 "200") :: Int
|
u = read (pick 0 "200") :: Int
|
||||||
o = read (pick 1 "200") :: Int
|
o = read (pick 1 "200") :: Int
|
||||||
serverArg = pick 2 ""
|
printf "full-chain benchmark: TCP client --> chusql-server --> in-process Rust storage\n"
|
||||||
serverPath <- findServer serverArg
|
|
||||||
|
|
||||||
tmp <- getTemporaryDirectory
|
|
||||||
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
|
|
||||||
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
|
printf "dataset : users = %d rows, orders = %d rows\n" u o
|
||||||
printf "timing : wall clock (monotonic); every statement runs the full chain\n"
|
printf "timing : wall clock (monotonic); every statement runs the full chain over TCP\n"
|
||||||
|
printf "note : the client no longer links the engine or the storage library\n"
|
||||||
bracket
|
bracket
|
||||||
(do
|
(do
|
||||||
t1 <- getMonotonicTime
|
t1 <- getMonotonicTime
|
||||||
ph <- startServer serverPath configPath
|
bench <- startBench
|
||||||
t2 <- getMonotonicTime
|
t2 <- getMonotonicTime
|
||||||
printf "\n[startup] storage ready in %.2f s\n" (t2 - t1)
|
printf "\n[startup] server up, storage ready, signed in: %.2f s\n" (t2 - t1)
|
||||||
pure ph
|
threadDelay 25000
|
||||||
|
pure bench
|
||||||
)
|
)
|
||||||
(\ph -> do
|
stopBench
|
||||||
putStrLn "\n[teardown] stopping storage and removing the data dir"
|
(\bench -> runScenarios bench u o)
|
||||||
stopServer ph dataDir
|
|
||||||
)
|
|
||||||
(const (runScenarios u o))
|
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
resolver: lts-24.59
|
resolver: lts-24.59
|
||||||
|
|
||||||
|
extra-lib-dirs:
|
||||||
|
- ../chusql-core/storage/target/release
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
- .
|
- .
|
||||||
- ../chusql-engine
|
- ../chusql-core/engine
|
||||||
|
- ../chusql-core/model
|
||||||
|
- ../chusql-interface
|
||||||
|
- ../chusql-server
|
||||||
+27
-71
@@ -11,19 +11,11 @@ import ChuSQL.CLI.Session (
|
|||||||
newSession,
|
newSession,
|
||||||
switchDatabase,
|
switchDatabase,
|
||||||
)
|
)
|
||||||
import ChuSQL.Web.Auth (Credential (..))
|
import ChuSQL.Interface.Auth (Credential (..))
|
||||||
import ChuSQL.Web.Backend (ipcBackend)
|
import ChuSQL.Interface.Config (ServerConfig (..), loadServerConfigAt, loadWebConfigAt, resolveCredential, resolvePlainPassword)
|
||||||
import ChuSQL.Web.Config (WebConfig (..), defaultPassword, loadWebConfigAt, resolveCredential, resolvePipeName)
|
import ChuSQL.Interface.Link (Client, closeClient, connectClient)
|
||||||
import ChuSQL.Web.Settings (readSettingsFile)
|
import ChuSQL.Interface.Settings (readSettingsFile)
|
||||||
import ChuSQL.Web.TOML (resolveConfigPath)
|
import ChuSQL.Interface.TOML (resolveConfigPath)
|
||||||
import ChuSQL.Web.StorageProcess (
|
|
||||||
StorageProcess,
|
|
||||||
findStorageServer,
|
|
||||||
startStorageProcessAt,
|
|
||||||
stopStorageProcess,
|
|
||||||
waitForStorage,
|
|
||||||
)
|
|
||||||
import ChuSQL.Storage.IPC (setPipeName)
|
|
||||||
import Control.Exception (finally)
|
import Control.Exception (finally)
|
||||||
import Control.Monad (unless)
|
import Control.Monad (unless)
|
||||||
import Data.IORef (newIORef)
|
import Data.IORef (newIORef)
|
||||||
@@ -39,7 +31,7 @@ import System.Exit (exitFailure)
|
|||||||
import System.FilePath ((</>))
|
import System.FilePath ((</>))
|
||||||
import System.IO (hSetEncoding, stderr, stdout, utf8)
|
import System.IO (hSetEncoding, stderr, stdout, utf8)
|
||||||
|
|
||||||
-- chusql-cli:与 Web 管理端并列的前端,直连存储与引擎,账号与 Web 共用。
|
-- chusql-cli:与 Web 管理端并列的前端,接到拥有存储的 server 上跑 SQL,账号与 Web 共用。
|
||||||
|
|
||||||
data Options = Options
|
data Options = Options
|
||||||
{ optUser :: Maybe Text
|
{ optUser :: Maybe Text
|
||||||
@@ -48,11 +40,10 @@ data Options = Options
|
|||||||
, optFormat :: OutputFormat
|
, optFormat :: OutputFormat
|
||||||
, optExecute :: Maybe Text
|
, optExecute :: Maybe Text
|
||||||
, optHistory :: Maybe FilePath
|
, optHistory :: Maybe FilePath
|
||||||
, optPipe :: Maybe String
|
|
||||||
, optStorage :: Maybe FilePath
|
|
||||||
, optConfig :: Maybe FilePath
|
, optConfig :: Maybe FilePath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- | 命令行参数解析器
|
||||||
optionsParser :: Parser Options
|
optionsParser :: Parser Options
|
||||||
optionsParser =
|
optionsParser =
|
||||||
Options
|
Options
|
||||||
@@ -78,13 +69,13 @@ optionsParser =
|
|||||||
)
|
)
|
||||||
<*> optional (T.pack <$> strOption (long "execute" <> short 'e' <> metavar "SQL" <> help "Run one statement and exit"))
|
<*> 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 "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)"))
|
<*> optional (strOption (long "config" <> metavar "FILE" <> help "Config file to read (default: the fixed chusql.toml under the system config directory)"))
|
||||||
|
|
||||||
|
-- | 把 --format 的取值解析成格式
|
||||||
formatReader :: String -> Either String OutputFormat
|
formatReader :: String -> Either String OutputFormat
|
||||||
formatReader raw = maybe (Left "format must be table, json or csv") Right (parseFormat (T.pack raw))
|
formatReader raw = maybe (Left "format must be table, json or csv") Right (parseFormat (T.pack raw))
|
||||||
|
|
||||||
|
-- | 命令行界面与帮助
|
||||||
parserInfo :: ParserInfo Options
|
parserInfo :: ParserInfo Options
|
||||||
parserInfo =
|
parserInfo =
|
||||||
info
|
info
|
||||||
@@ -94,6 +85,7 @@ parserInfo =
|
|||||||
<> header "csql - the ChuSQL command line client"
|
<> header "csql - the ChuSQL command line client"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
-- | 解析参数、登录 server 后进入会话
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main = do
|
main = do
|
||||||
hSetEncoding stdout utf8
|
hSetEncoding stdout utf8
|
||||||
@@ -101,31 +93,29 @@ main = do
|
|||||||
options <- execParser parserInfo
|
options <- execParser parserInfo
|
||||||
configPath <- resolveConfigPath (optConfig options)
|
configPath <- resolveConfigPath (optConfig options)
|
||||||
base <- loadWebConfigAt configPath
|
base <- loadWebConfigAt configPath
|
||||||
let settingsFile = configPath
|
saved <- readSettingsFile configPath
|
||||||
saved <- readSettingsFile settingsFile
|
|
||||||
credential <- resolveCredential base saved
|
credential <- resolveCredential base saved
|
||||||
|
config <- loadServerConfigAt configPath
|
||||||
let rootName = credUser credential
|
let rootName = credUser credential
|
||||||
userName = fromMaybe rootName (optUser options)
|
userName = fromMaybe rootName (optUser options)
|
||||||
pipeName = resolvePipe options base
|
|
||||||
setPipeName pipeName
|
|
||||||
password <- resolvePassword (optPromptPassword options) userName rootName saved
|
password <- resolvePassword (optPromptPassword options) userName rootName saved
|
||||||
storage <- acquireStorage options pipeName settingsFile
|
connected <- connectClient (T.pack (scHost config)) (scPort config)
|
||||||
case storage of
|
case connected of
|
||||||
Left message -> reportError message >> exitFailure
|
Left message -> reportError message >> exitFailure
|
||||||
Right spawned ->
|
Right client ->
|
||||||
connect options credential settingsFile userName password
|
connect options client userName password
|
||||||
`finally` maybe (pure ()) stopStorageProcess spawned
|
`finally` closeClient client
|
||||||
|
|
||||||
-- | 接上存储,登录,然后进交互或跑单条语句
|
-- | 连 server、登录,之后转入会话
|
||||||
connect :: Options -> Credential -> FilePath -> Text -> Text -> IO ()
|
connect :: Options -> Client -> Text -> Text -> IO ()
|
||||||
connect options credential settingsFile userName password = do
|
connect options client userName password = do
|
||||||
backend <- ipcBackend
|
session <- newSession client
|
||||||
session <- newSession backend credential settingsFile
|
|
||||||
signedIn <- authenticateSession session userName password
|
signedIn <- authenticateSession session userName password
|
||||||
case signedIn of
|
case signedIn of
|
||||||
Left message -> reportError message >> exitFailure
|
Left message -> reportError message >> exitFailure
|
||||||
Right () -> runSession options session
|
Right () -> runSession options session
|
||||||
|
|
||||||
|
-- | 执行 -e 的单条语句,否则进交互循环
|
||||||
runSession :: Options -> Session -> IO ()
|
runSession :: Options -> Session -> IO ()
|
||||||
runSession options session = do
|
runSession options session = do
|
||||||
formatRef <- newIORef (optFormat options)
|
formatRef <- newIORef (optFormat options)
|
||||||
@@ -140,7 +130,7 @@ runSession options session = do
|
|||||||
historyFile <- resolveHistory (optHistory options)
|
historyFile <- resolveHistory (optHistory options)
|
||||||
runRepl session formatRef historyFile
|
runRepl session formatRef historyFile
|
||||||
|
|
||||||
-- | 只有 -d 明确给了一个库才切;没给就保持"未选库"(服务没有默认库)
|
-- | -d 指定了库才切,否则保持未选库
|
||||||
selectDatabase :: Session -> Text -> IO Bool
|
selectDatabase :: Session -> Text -> IO Bool
|
||||||
selectDatabase _ wanted | T.null wanted = pure True
|
selectDatabase _ wanted | T.null wanted = pure True
|
||||||
selectDatabase session wanted
|
selectDatabase session wanted
|
||||||
@@ -151,54 +141,20 @@ selectDatabase session wanted
|
|||||||
Left message -> reportError message >> pure False
|
Left message -> reportError message >> pure False
|
||||||
Right () -> pure True
|
Right () -> pure True
|
||||||
|
|
||||||
-- | 口令:-p 强制交互输入;否则配置里的 root 明文(同 Web)> 交互输入
|
-- | 决定口令是交互输入还是取自配置
|
||||||
resolvePassword :: Bool -> Text -> Text -> Map.Map Text Text -> IO Text
|
resolvePassword :: Bool -> Text -> Text -> Map.Map Text Text -> IO Text
|
||||||
resolvePassword forcePrompt userName rootName saved
|
resolvePassword forcePrompt userName rootName saved
|
||||||
| forcePrompt = promptPassword userName
|
| forcePrompt = promptPassword userName
|
||||||
| T.toLower userName == T.toLower rootName = pure (configuredPassword saved)
|
| T.toLower userName == T.toLower rootName = pure (resolvePlainPassword saved)
|
||||||
| otherwise = promptPassword userName
|
| otherwise = promptPassword userName
|
||||||
where
|
|
||||||
configuredPassword values = case nonEmpty (Map.lookup "password" values) of
|
|
||||||
Just plain -> plain
|
|
||||||
Nothing -> defaultPassword
|
|
||||||
|
|
||||||
|
-- | 交互式读取口令
|
||||||
promptPassword :: Text -> IO Text
|
promptPassword :: Text -> IO Text
|
||||||
promptPassword user = do
|
promptPassword user = do
|
||||||
entered <- runInputT defaultSettings (getPassword (Just '*') (T.unpack user <> "'s password: "))
|
entered <- runInputT defaultSettings (getPassword (Just '*') (T.unpack user <> "'s password: "))
|
||||||
pure (maybe "" T.pack entered)
|
pure (maybe "" T.pack entered)
|
||||||
|
|
||||||
-- | 先找已经在跑的存储;--storage 给了可执行文件才自己拉一个
|
-- | 取历史文件路径,没给就用默认
|
||||||
acquireStorage :: Options -> String -> FilePath -> IO (Either Text (Maybe StorageProcess))
|
|
||||||
acquireStorage options pipeName configPath = do
|
|
||||||
up <- waitForStorage 4
|
|
||||||
case optStorage options of
|
|
||||||
Nothing
|
|
||||||
| up -> pure (Right Nothing)
|
|
||||||
| otherwise ->
|
|
||||||
pure
|
|
||||||
( Left
|
|
||||||
( "no storage process answered on pipe "
|
|
||||||
<> T.pack pipeName
|
|
||||||
<> "; start the server first, or pass --storage <path>"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Just given
|
|
||||||
| up -> pure (Right Nothing)
|
|
||||||
| otherwise -> do
|
|
||||||
found <- findStorageServer (Just given)
|
|
||||||
case found of
|
|
||||||
Left err -> pure (Left (T.pack err))
|
|
||||||
Right binary -> fmap (either (Left . T.pack) (Right . Just)) (startStorageProcessAt binary pipeName configPath)
|
|
||||||
|
|
||||||
-- | 管名:--pipe > chusql.toml 的 [server] pipe_name(缺省 chusql-joint)
|
|
||||||
resolvePipe :: Options -> WebConfig -> String
|
|
||||||
resolvePipe options base = fromMaybe (resolvePipeName base) (optPipe options)
|
|
||||||
|
|
||||||
nonEmpty :: Maybe Text -> Maybe Text
|
|
||||||
nonEmpty given = case T.strip <$> given of
|
|
||||||
Just text | not (T.null text) -> Just text
|
|
||||||
_ -> Nothing
|
|
||||||
|
|
||||||
resolveHistory :: Maybe FilePath -> IO (Maybe FilePath)
|
resolveHistory :: Maybe FilePath -> IO (Maybe FilePath)
|
||||||
resolveHistory (Just path) = pure (Just path)
|
resolveHistory (Just path) = pure (Just path)
|
||||||
resolveHistory Nothing = do
|
resolveHistory Nothing = do
|
||||||
|
|||||||
+19
-22
@@ -8,9 +8,9 @@ module Repl
|
|||||||
|
|
||||||
import ChuSQL.CLI.Format (OutputFormat, formatName, renderResult, renderRows)
|
import ChuSQL.CLI.Format (OutputFormat, formatName, renderResult, renderRows)
|
||||||
import ChuSQL.CLI.Script (Meta (..), errorHint, metaHelp, parseMeta, takeStatement)
|
import ChuSQL.CLI.Script (Meta (..), errorHint, metaHelp, parseMeta, takeStatement)
|
||||||
import ChuSQL.CLI.Session (Session, accountTable, catalog, databases, roleViews, runStatement, sessionDatabase, switchDatabase)
|
import ChuSQL.CLI.Session (Session, catalog, databases, roleViews, runStatement, sessionDatabase, switchDatabase)
|
||||||
import ChuSQL.Storage.IPC (SchemaColumn (..), TableInfo (..))
|
import ChuSQL.Core.Protocol (SchemaColumn (..), TableInfo (..))
|
||||||
import ChuSQL.Web.Privileges (Grant (..), RoleView (..))
|
import ChuSQL.Interface.Protocol (Grant (..), RoleView (..))
|
||||||
import Control.Monad (forM_, when)
|
import Control.Monad (forM_, when)
|
||||||
import Control.Monad.IO.Class (liftIO)
|
import Control.Monad.IO.Class (liftIO)
|
||||||
import Data.IORef (IORef, readIORef, writeIORef)
|
import Data.IORef (IORef, readIORef, writeIORef)
|
||||||
@@ -22,7 +22,7 @@ import System.IO (hIsTerminalDevice, stderr, stdin)
|
|||||||
|
|
||||||
-- 交互层:提示符、多行输入、历史、元命令与错误提示。
|
-- 交互层:提示符、多行输入、历史、元命令与错误提示。
|
||||||
|
|
||||||
-- | 交互循环;historyPath 为 Nothing 就不落盘历史
|
-- | 交互循环,没有历史文件就不落盘
|
||||||
runRepl :: Session -> IORef OutputFormat -> Maybe FilePath -> IO ()
|
runRepl :: Session -> IORef OutputFormat -> Maybe FilePath -> IO ()
|
||||||
runRepl session formatRef historyPath =
|
runRepl session formatRef historyPath =
|
||||||
runInputT (defaultSettings{historyFile = historyPath}) (loop session formatRef T.empty)
|
runInputT (defaultSettings{historyFile = historyPath}) (loop session formatRef T.empty)
|
||||||
@@ -41,6 +41,7 @@ runStatementOnce session format sql = do
|
|||||||
Left message -> reportError message >> pure False
|
Left message -> reportError message >> pure False
|
||||||
Right answer -> TIO.putStrLn (renderResult format answer) >> pure True
|
Right answer -> TIO.putStrLn (renderResult format answer) >> pure True
|
||||||
|
|
||||||
|
-- | 读一行输入,分派到元命令或语句缓冲
|
||||||
loop :: Session -> IORef OutputFormat -> Text -> InputT IO ()
|
loop :: Session -> IORef OutputFormat -> Text -> InputT IO ()
|
||||||
loop session formatRef buffer = do
|
loop session formatRef buffer = do
|
||||||
prompt <- liftIO (promptFor session buffer)
|
prompt <- liftIO (promptFor session buffer)
|
||||||
@@ -62,7 +63,7 @@ loop session formatRef buffer = do
|
|||||||
remaining <- drain session formatRef extended
|
remaining <- drain session formatRef extended
|
||||||
loop session formatRef remaining
|
loop session formatRef remaining
|
||||||
|
|
||||||
-- | 提示符带上当前库;续行换个样子,方便看出语句还没写完;管道输入时不出提示符
|
-- | 生成提示符;续行与非终端输入另有样式
|
||||||
promptFor :: Session -> Text -> IO Text
|
promptFor :: Session -> Text -> IO Text
|
||||||
promptFor session buffer = do
|
promptFor session buffer = do
|
||||||
interactive <- hIsTerminalDevice stdin
|
interactive <- hIsTerminalDevice stdin
|
||||||
@@ -83,6 +84,7 @@ drain session formatRef buffer = case takeStatement buffer of
|
|||||||
when (not (T.null statement)) (runOne session formatRef statement)
|
when (not (T.null statement)) (runOne session formatRef statement)
|
||||||
drain session formatRef rest
|
drain session formatRef rest
|
||||||
|
|
||||||
|
-- | 执行一条语句并打印结果
|
||||||
runOne :: Session -> IORef OutputFormat -> Text -> InputT IO ()
|
runOne :: Session -> IORef OutputFormat -> Text -> InputT IO ()
|
||||||
runOne session formatRef statement = do
|
runOne session formatRef statement = do
|
||||||
result <- liftIO (runStatement session statement)
|
result <- liftIO (runStatement session statement)
|
||||||
@@ -92,6 +94,7 @@ runOne session formatRef statement = do
|
|||||||
format <- liftIO (readIORef formatRef)
|
format <- liftIO (readIORef formatRef)
|
||||||
outputStrLn (T.unpack (renderResult format answer))
|
outputStrLn (T.unpack (renderResult format answer))
|
||||||
|
|
||||||
|
-- | 执行元命令,返回是否继续循环
|
||||||
runMeta :: Session -> IORef OutputFormat -> Meta -> InputT IO Bool
|
runMeta :: Session -> IORef OutputFormat -> Meta -> InputT IO Bool
|
||||||
runMeta session formatRef meta = case meta of
|
runMeta session formatRef meta = case meta of
|
||||||
MetaQuit -> pure False
|
MetaQuit -> pure False
|
||||||
@@ -120,17 +123,16 @@ listDatabases session = do
|
|||||||
Left message -> liftIO (reportError message)
|
Left message -> liftIO (reportError message)
|
||||||
Right names -> outputStrLn (T.unpack (renderRows ["database"] (map (: []) names)))
|
Right names -> outputStrLn (T.unpack (renderRows ["database"] (map (: []) names)))
|
||||||
|
|
||||||
-- | \dt:表清单(base 里补上账号表)
|
-- | \dt:表清单(含 server 补的账号表)
|
||||||
listTables :: Session -> InputT IO ()
|
listTables :: Session -> InputT IO ()
|
||||||
listTables session = do
|
listTables session = do
|
||||||
answer <- liftIO (catalog session)
|
answer <- liftIO (catalog session)
|
||||||
case answer of
|
case answer of
|
||||||
Left message -> liftIO (reportError message)
|
Left message -> liftIO (reportError message)
|
||||||
Right tables -> do
|
Right tables -> outputStrLn (T.unpack (renderRows headers (map tableRow tables)))
|
||||||
allTables <- liftIO (withAccountTable session tables)
|
|
||||||
outputStrLn (T.unpack (renderRows headers (map tableRow allTables)))
|
|
||||||
where
|
where
|
||||||
headers = ["table", "columns", "rows", "indexes"]
|
headers = ["table", "columns", "rows", "indexes"]
|
||||||
|
-- | 把表信息排成一行
|
||||||
tableRow info =
|
tableRow info =
|
||||||
[ T.pack (tiTable info)
|
[ T.pack (tiTable info)
|
||||||
, tshow (length (tiColumns info))
|
, tshow (length (tiColumns info))
|
||||||
@@ -144,14 +146,15 @@ describeTable session name = do
|
|||||||
answer <- liftIO (catalog session)
|
answer <- liftIO (catalog session)
|
||||||
case answer of
|
case answer of
|
||||||
Left message -> liftIO (reportError message)
|
Left message -> liftIO (reportError message)
|
||||||
Right tables -> do
|
Right tables ->
|
||||||
allTables <- liftIO (withAccountTable session tables)
|
case filter (matches name) tables of
|
||||||
case filter (matches name) allTables of
|
|
||||||
[] -> liftIO (reportError ("unknown table: " <> name))
|
[] -> liftIO (reportError ("unknown table: " <> name))
|
||||||
(info : _) -> outputStrLn (T.unpack (renderRows headers (map columnRow (tiColumns info))))
|
(info : _) -> outputStrLn (T.unpack (renderRows headers (map columnRow (tiColumns info))))
|
||||||
where
|
where
|
||||||
headers = ["column", "type", "primary", "unique", "nullable", "auto"]
|
headers = ["column", "type", "primary", "unique", "nullable", "auto"]
|
||||||
|
-- | 表名忽略大小写比较
|
||||||
matches wanted info = T.toLower (T.pack (tiTable info)) == T.toLower wanted
|
matches wanted info = T.toLower (T.pack (tiTable info)) == T.toLower wanted
|
||||||
|
-- | 把列信息排成一行
|
||||||
columnRow column =
|
columnRow column =
|
||||||
[ T.pack (scName column)
|
[ T.pack (scName column)
|
||||||
, T.pack (scType column)
|
, T.pack (scType column)
|
||||||
@@ -160,6 +163,7 @@ describeTable session name = do
|
|||||||
, yesNo (scNullable column)
|
, yesNo (scNullable column)
|
||||||
, yesNo (scAutoIncrement column)
|
, yesNo (scAutoIncrement column)
|
||||||
]
|
]
|
||||||
|
-- | 布尔值转成 yes/no
|
||||||
yesNo True = "yes"
|
yesNo True = "yes"
|
||||||
yesNo False = "no"
|
yesNo False = "no"
|
||||||
|
|
||||||
@@ -171,22 +175,15 @@ listRoles session = do
|
|||||||
Left message -> liftIO (reportError message)
|
Left message -> liftIO (reportError message)
|
||||||
Right roles -> outputStrLn (T.unpack (renderRows ["role", "grants", "members"] (map roleRow roles)))
|
Right roles -> outputStrLn (T.unpack (renderRows ["role", "grants", "members"] (map roleRow roles)))
|
||||||
where
|
where
|
||||||
|
-- | 把角色信息排成一行
|
||||||
roleRow view =
|
roleRow view =
|
||||||
[ roleName view
|
[ roleName view
|
||||||
, if null (roleGrants view) then "-" else T.intercalate ", " (map grantText (roleGrants view))
|
, if null (roleGrants view) then "-" else T.intercalate ", " (map grantText (roleGrants view))
|
||||||
, if null (roleMembers view) then "-" else T.intercalate ", " (roleMembers view)
|
, if null (roleMembers view) then "-" else T.intercalate ", " (roleMembers view)
|
||||||
]
|
]
|
||||||
|
-- | 拼一条授权说明
|
||||||
grantText grant = grantPrivilege grant <> " on " <> grantObject grant
|
grantText grant = grantPrivilege grant <> " on " <> grantObject grant
|
||||||
|
|
||||||
-- | 当前库是 system 时,把账号表也列出来(与 Web 端一致)
|
-- | 用 show 转成 Text
|
||||||
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 :: Show a => a -> Text
|
||||||
tshow = T.pack . show
|
tshow = T.pack . show
|
||||||
+13
-12
@@ -2,9 +2,9 @@ cabal-version: 2.4
|
|||||||
|
|
||||||
name: chusql-cli
|
name: chusql-cli
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
synopsis: ChuSQL 命令行客户端:直连引擎与存储,与 Web 管理端共用账号
|
synopsis: ChuSQL 命令行客户端:直连数据库服务,与 Web 管理端共用账号
|
||||||
description: ChuSQL 的命令行前端。直接走命名管道接 Rust 存储进程与 Haskell 引擎,
|
description: ChuSQL 的命令行前端。自己不开存储、不链引擎,所有 SQL 与会话
|
||||||
不经过 Web REST;root 凭据与 __chusql_users 表与 Web 管理端完全共用。
|
都走 chusql-server 的 TCP 端点;账号表与权限服务与 Web 管理端完全共用。
|
||||||
build-type: Simple
|
build-type: Simple
|
||||||
|
|
||||||
library
|
library
|
||||||
@@ -15,11 +15,9 @@ library
|
|||||||
build-depends: base >= 4.14 && < 5
|
build-depends: base >= 4.14 && < 5
|
||||||
, aeson
|
, aeson
|
||||||
, bytestring
|
, bytestring
|
||||||
, chusql-engine
|
, chusql-core-model
|
||||||
, chusql-web
|
, chusql-interface
|
||||||
, scientific
|
|
||||||
, text
|
, text
|
||||||
, time
|
|
||||||
default-language: Haskell2010
|
default-language: Haskell2010
|
||||||
ghc-options: -Wall
|
ghc-options: -Wall
|
||||||
|
|
||||||
@@ -28,10 +26,9 @@ executable csql
|
|||||||
other-modules: Repl
|
other-modules: Repl
|
||||||
hs-source-dirs: app
|
hs-source-dirs: app
|
||||||
build-depends: base
|
build-depends: base
|
||||||
, aeson
|
|
||||||
, chusql-cli
|
, chusql-cli
|
||||||
, chusql-engine
|
, chusql-core-model
|
||||||
, chusql-web
|
, chusql-interface
|
||||||
, containers
|
, containers
|
||||||
, directory
|
, directory
|
||||||
, filepath
|
, filepath
|
||||||
@@ -40,6 +37,11 @@ executable csql
|
|||||||
, text
|
, text
|
||||||
default-language: Haskell2010
|
default-language: Haskell2010
|
||||||
ghc-options: -Wall
|
ghc-options: -Wall
|
||||||
|
-- 运行期找 libchusql_core_storage.so:$ORIGIN 让二进制与动态库同目录即可
|
||||||
|
if os(linux)
|
||||||
|
ld-options: -Wl,-rpath,$ORIGIN
|
||||||
|
if os(osx)
|
||||||
|
ld-options: -Wl,-rpath,@loader_path
|
||||||
|
|
||||||
test-suite chusql-cli-test
|
test-suite chusql-cli-test
|
||||||
type: exitcode-stdio-1.0
|
type: exitcode-stdio-1.0
|
||||||
@@ -49,8 +51,7 @@ test-suite chusql-cli-test
|
|||||||
, aeson
|
, aeson
|
||||||
, bytestring
|
, bytestring
|
||||||
, chusql-cli
|
, chusql-cli
|
||||||
, chusql-engine
|
, chusql-core-model
|
||||||
, chusql-web
|
|
||||||
, hspec
|
, hspec
|
||||||
, text
|
, text
|
||||||
default-language: Haskell2010
|
default-language: Haskell2010
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ module ChuSQL.CLI.Format
|
|||||||
, displayValue
|
, displayValue
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import ChuSQL.CLI.Session (QueryResult (..), queryResultJson)
|
import ChuSQL.Core.Model (Value (..))
|
||||||
import ChuSQL.Model (Value (..))
|
import ChuSQL.Core.Protocol (QueryResult (..), queryResultJson)
|
||||||
import qualified Data.Aeson as A
|
import qualified Data.Aeson as A
|
||||||
import qualified Data.ByteString.Lazy as BL
|
import qualified Data.ByteString.Lazy as BL
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
@@ -25,6 +25,7 @@ import Data.Text.Encoding (decodeUtf8)
|
|||||||
data OutputFormat = FormatTable | FormatJson | FormatCsv
|
data OutputFormat = FormatTable | FormatJson | FormatCsv
|
||||||
deriving (Eq, Show)
|
deriving (Eq, Show)
|
||||||
|
|
||||||
|
-- | 格式的对外名字
|
||||||
formatName :: OutputFormat -> Text
|
formatName :: OutputFormat -> Text
|
||||||
formatName FormatTable = "table"
|
formatName FormatTable = "table"
|
||||||
formatName FormatJson = "json"
|
formatName FormatJson = "json"
|
||||||
@@ -38,6 +39,7 @@ parseFormat raw = case T.toLower (T.strip raw) of
|
|||||||
"csv" -> Just FormatCsv
|
"csv" -> Just FormatCsv
|
||||||
_ -> Nothing
|
_ -> Nothing
|
||||||
|
|
||||||
|
-- | 按格式渲染查询结果
|
||||||
renderResult :: OutputFormat -> QueryResult -> Text
|
renderResult :: OutputFormat -> QueryResult -> Text
|
||||||
renderResult FormatTable = renderTable
|
renderResult FormatTable = renderTable
|
||||||
renderResult FormatJson = renderJson
|
renderResult FormatJson = renderJson
|
||||||
@@ -47,10 +49,12 @@ renderResult FormatCsv = renderCsv
|
|||||||
renderJson :: QueryResult -> Text
|
renderJson :: QueryResult -> Text
|
||||||
renderJson = decodeUtf8 . BL.toStrict . A.encode . queryResultJson
|
renderJson = decodeUtf8 . BL.toStrict . A.encode . queryResultJson
|
||||||
|
|
||||||
|
-- | 渲染成 CSV
|
||||||
renderCsv :: QueryResult -> Text
|
renderCsv :: QueryResult -> Text
|
||||||
renderCsv result = T.unlines (header : map rowLine (qrRows result))
|
renderCsv result = T.unlines (header : map rowLine (qrRows result))
|
||||||
where
|
where
|
||||||
header = T.intercalate "," (map csvField (qrColumns result))
|
header = T.intercalate "," (map csvField (qrColumns result))
|
||||||
|
-- | 拼一行 CSV
|
||||||
rowLine row = T.intercalate "," (map (csvField . csvCell) row)
|
rowLine row = T.intercalate "," (map (csvField . csvCell) row)
|
||||||
|
|
||||||
-- | CSV 里的空值就是空字段
|
-- | CSV 里的空值就是空字段
|
||||||
@@ -64,6 +68,7 @@ csvField field
|
|||||||
| T.any (`elem` (",\"\n\r" :: String)) field = "\"" <> T.replace "\"" "\"\"" field <> "\""
|
| T.any (`elem` (",\"\n\r" :: String)) field = "\"" <> T.replace "\"" "\"\"" field <> "\""
|
||||||
| otherwise = field
|
| otherwise = field
|
||||||
|
|
||||||
|
-- | 渲染成等宽表格
|
||||||
renderTable :: QueryResult -> Text
|
renderTable :: QueryResult -> Text
|
||||||
renderTable result
|
renderTable result
|
||||||
| null (qrColumns result) = "OK"
|
| null (qrColumns result) = "OK"
|
||||||
@@ -74,10 +79,13 @@ renderRows :: [Text] -> [[Text]] -> Text
|
|||||||
renderRows columns rawRows = T.unlines (border : headerLine : border : body ++ [border, summary])
|
renderRows columns rawRows = T.unlines (border : headerLine : border : body ++ [border, summary])
|
||||||
where
|
where
|
||||||
widths = [columnWidth index | index <- [0 .. length columns - 1]]
|
widths = [columnWidth index | index <- [0 .. length columns - 1]]
|
||||||
|
-- | 一列的显示宽度,有上限
|
||||||
columnWidth index =
|
columnWidth index =
|
||||||
min maxColumnWidth (maximum (T.length (columns !! index) : [T.length (cellAt row index) | row <- rawRows]))
|
min maxColumnWidth (maximum (T.length (columns !! index) : [T.length (cellAt row index) | row <- rawRows]))
|
||||||
maxColumnWidth = 64
|
maxColumnWidth = 64
|
||||||
|
-- | 取某行某列,缺的当空
|
||||||
cellAt row index = if index < length row then row !! index else ""
|
cellAt row index = if index < length row then row !! index else ""
|
||||||
|
-- | 拼一行带竖线的表行
|
||||||
cellLine cells = "| " <> T.intercalate " | " (zipWith pad widths (cells ++ repeat "")) <> " |"
|
cellLine cells = "| " <> T.intercalate " | " (zipWith pad widths (cells ++ repeat "")) <> " |"
|
||||||
headerLine = cellLine columns
|
headerLine = cellLine columns
|
||||||
body = map cellLine rawRows
|
body = map cellLine rawRows
|
||||||
@@ -93,6 +101,7 @@ pad width text = clipped <> T.replicate (width - T.length clipped) " "
|
|||||||
| width <= 3 = T.take width text
|
| width <= 3 = T.take width text
|
||||||
| otherwise = T.take (width - 3) text <> "..."
|
| otherwise = T.take (width - 3) text <> "..."
|
||||||
|
|
||||||
|
-- | 把值转成可显示的文本
|
||||||
displayValue :: Value -> Text
|
displayValue :: Value -> Text
|
||||||
displayValue VNull = "NULL"
|
displayValue VNull = "NULL"
|
||||||
displayValue (VInt n) = T.pack (show n)
|
displayValue (VInt n) = T.pack (show n)
|
||||||
|
|||||||
@@ -57,15 +57,18 @@ parseMeta raw = do
|
|||||||
"format" -> selectFormat argument
|
"format" -> selectFormat argument
|
||||||
other -> MetaUnknown other
|
other -> MetaUnknown other
|
||||||
where
|
where
|
||||||
|
-- | 拼出切库命令
|
||||||
connectTo argument
|
connectTo argument
|
||||||
| T.null argument = MetaUnknown "c"
|
| T.null argument = MetaUnknown "c"
|
||||||
| otherwise = MetaConnect argument
|
| otherwise = MetaConnect argument
|
||||||
|
-- | 解析格式参数
|
||||||
selectFormat argument = maybe (MetaUnknown "format") MetaSetFormat (parseFormat argument)
|
selectFormat argument = maybe (MetaUnknown "format") MetaSetFormat (parseFormat argument)
|
||||||
|
|
||||||
-- | 取出第一条以分号结束的语句与后面的残余;字符串与注释里的分号不算
|
-- | 取出第一条语句,忽略引号与注释里的分号
|
||||||
takeStatement :: Text -> Maybe (Text, Text)
|
takeStatement :: Text -> Maybe (Text, Text)
|
||||||
takeStatement = go ScanNormal [] . T.unpack
|
takeStatement = go ScanNormal [] . T.unpack
|
||||||
where
|
where
|
||||||
|
-- | 逐字符扫描语句边界
|
||||||
go _ _ [] = Nothing
|
go _ _ [] = Nothing
|
||||||
go ScanNormal acc (';' : rest) = Just (T.strip (T.pack (reverse acc)), T.pack rest)
|
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 ScanLineComment ('-' : '-' : acc) rest
|
||||||
|
|||||||
@@ -1,270 +1,5 @@
|
|||||||
{-# LANGUAGE OverloadedStrings #-}
|
module ChuSQL.CLI.Session (module ChuSQL.Interface.Session) where
|
||||||
|
|
||||||
module ChuSQL.CLI.Session
|
import ChuSQL.Interface.Session
|
||||||
( QueryResult (..)
|
|
||||||
, Session
|
|
||||||
, newSession
|
|
||||||
, sessionUser
|
|
||||||
, sessionIsAdmin
|
|
||||||
, sessionDatabase
|
|
||||||
, isPlainIdentifier
|
|
||||||
, authenticateSession
|
|
||||||
, switchDatabase
|
|
||||||
, runStatement
|
|
||||||
, catalog
|
|
||||||
, accountTable
|
|
||||||
, roleViews
|
|
||||||
, databases
|
|
||||||
, queryResultJson
|
|
||||||
) where
|
|
||||||
|
|
||||||
import ChuSQL.Model (Value (..))
|
-- 命令行会话:转出前端共享的会话接口,一切请求走 server。
|
||||||
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
|
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
resolver: lts-24.59
|
resolver: lts-24.59
|
||||||
|
|
||||||
|
extra-lib-dirs:
|
||||||
|
- ../chusql-core/storage/target/release
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
- .
|
- .
|
||||||
- ../chusql-engine
|
- ../chusql-core/engine
|
||||||
|
- ../chusql-server
|
||||||
- ../chusql-web
|
- ../chusql-web
|
||||||
|
- ../chusql-core/model
|
||||||
|
- ../chusql-interface
|
||||||
+7
-150
@@ -1,52 +1,31 @@
|
|||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
{-# LANGUAGE PatternSynonyms #-}
|
|
||||||
|
|
||||||
module Main (main) where
|
module Main (main) where
|
||||||
|
|
||||||
import ChuSQL.CLI.Format (OutputFormat (..), parseFormat, renderCsv, renderJson, renderTable)
|
import ChuSQL.CLI.Format (OutputFormat (..), parseFormat, renderCsv, renderJson, renderTable)
|
||||||
import ChuSQL.CLI.Script (Meta (..), errorHint, parseMeta, statementComplete, stripTerminator, takeStatement)
|
import ChuSQL.CLI.Script (Meta (..), errorHint, parseMeta, statementComplete, stripTerminator, takeStatement)
|
||||||
import ChuSQL.CLI.Session (
|
import ChuSQL.Core.Model (Value (..))
|
||||||
QueryResult (..),
|
import ChuSQL.Core.Protocol (QueryResult (..), queryResultJson)
|
||||||
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 Data.Aeson (decode)
|
||||||
import qualified Data.Aeson as A
|
import qualified Data.Aeson as A
|
||||||
import qualified Data.ByteString as BS
|
|
||||||
import qualified Data.ByteString.Lazy as BL
|
import qualified Data.ByteString.Lazy as BL
|
||||||
import Data.Either (isLeft, isRight)
|
|
||||||
import Data.List (nub, sort)
|
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
import Data.Text.Encoding (encodeUtf8)
|
import Data.Text.Encoding (encodeUtf8)
|
||||||
import Test.Hspec
|
import Test.Hspec
|
||||||
|
|
||||||
-- chusql-cli 测试:输出格式、交互层纯逻辑与共享账号会话。
|
-- chusql-cli 测试:只覆盖本前端的纯逻辑(输出格式、语句缓冲与元命令)。
|
||||||
|
|
||||||
|
-- | 测试入口
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main = hspec spec
|
main = hspec spec
|
||||||
|
|
||||||
|
-- | 全部测试用例
|
||||||
spec :: Spec
|
spec :: Spec
|
||||||
spec = do
|
spec = do
|
||||||
formatSpec
|
formatSpec
|
||||||
scriptSpec
|
scriptSpec
|
||||||
sessionSpec
|
|
||||||
roleSpec
|
|
||||||
|
|
||||||
|
-- | 输出格式的用例
|
||||||
formatSpec :: Spec
|
formatSpec :: Spec
|
||||||
formatSpec = describe "CLI: output formats" $ do
|
formatSpec = describe "CLI: output formats" $ do
|
||||||
it "renders a boxed table and marks NULL" $
|
it "renders a boxed table and marks NULL" $
|
||||||
@@ -79,6 +58,7 @@ formatSpec = describe "CLI: output formats" $ do
|
|||||||
parseFormat "csv" `shouldBe` Just FormatCsv
|
parseFormat "csv" `shouldBe` Just FormatCsv
|
||||||
parseFormat "xml" `shouldBe` Nothing
|
parseFormat "xml" `shouldBe` Nothing
|
||||||
|
|
||||||
|
-- | 语句缓冲与元命令的用例
|
||||||
scriptSpec :: Spec
|
scriptSpec :: Spec
|
||||||
scriptSpec = do
|
scriptSpec = do
|
||||||
describe "CLI: statement buffering" $ do
|
describe "CLI: statement buffering" $ do
|
||||||
@@ -117,114 +97,6 @@ scriptSpec = do
|
|||||||
parseMeta "\\nope" `shouldBe` Just (MetaUnknown "nope")
|
parseMeta "\\nope" `shouldBe` Just (MetaUnknown "nope")
|
||||||
parseMeta "\\f xml" `shouldBe` Just (MetaUnknown "format")
|
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
|
||||||
sampleResult =
|
sampleResult =
|
||||||
QueryResult
|
QueryResult
|
||||||
@@ -234,18 +106,3 @@ sampleResult =
|
|||||||
, qrTruncated = False
|
, qrTruncated = False
|
||||||
, qrDatabase = Nothing
|
, qrDatabase = Nothing
|
||||||
}
|
}
|
||||||
|
|
||||||
-- | 测试库:三行 users
|
|
||||||
testDb :: Database
|
|
||||||
testDb =
|
|
||||||
[ ( "users"
|
|
||||||
, Table
|
|
||||||
"users"
|
|
||||||
[("id", TInt), ("name", TStr)]
|
|
||||||
[[("id", VInt i), ("name", VStr ("user" ++ show i))] | i <- [1 .. 3]]
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
-- | 测试账号:迭代次数压低(跑得快),口令 `s3cret`
|
|
||||||
testCredential :: Credential
|
|
||||||
testCredential = Credential "admin" (hashPasswordWith 1000 (BS.replicate 16 7) "s3cret")
|
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
module Main where
|
module Main where
|
||||||
|
|
||||||
import ChuSQL.Algebra.Op (renderPlan)
|
import ChuSQL.Core.Engine.Algebra.Op (renderPlan)
|
||||||
import ChuSQL.Algebra.Optimize (optimize)
|
import ChuSQL.Core.Engine.Algebra.Optimize (optimize)
|
||||||
import ChuSQL.Algebra.Planner (translate)
|
import ChuSQL.Core.Engine.Algebra.Planner (translate)
|
||||||
import ChuSQL.Engine
|
import ChuSQL.Core.Engine
|
||||||
import ChuSQL.Model
|
import ChuSQL.Core.Model
|
||||||
import ChuSQL.Syntax.AST
|
import ChuSQL.Core.Engine.Syntax.AST
|
||||||
import ChuSQL.Syntax.Parser
|
import ChuSQL.Core.Engine.Syntax.Parser
|
||||||
|
|
||||||
-- 命令行演示:跑几条语句,打印结果与优化前后的计划。
|
-- 命令行演示入口:跑几条语句并打印结果与优化前后的计划。
|
||||||
|
|
||||||
users :: Table
|
users :: Table
|
||||||
users =
|
users =
|
||||||
@@ -37,7 +37,7 @@ orders =
|
|||||||
db :: Database
|
db :: Database
|
||||||
db = [("users", users), ("orders", orders)]
|
db = [("users", users), ("orders", orders)]
|
||||||
|
|
||||||
-- | 演示入口
|
-- | 跑演示语句,打印结果与优化前后的计划
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main = do
|
main = do
|
||||||
putStrLn "==================== Engine ====================="
|
putStrLn "==================== Engine ====================="
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
cabal-version: 2.4
|
||||||
|
|
||||||
|
name: chusql-core-engine
|
||||||
|
version: 1.0.0
|
||||||
|
synopsis: ChuSQL 执行引擎:SQL 词法与语法分析、表达式求值
|
||||||
|
description: ChuSQL 服务端执行引擎。负责 SQL 词法分析与 AST 构建、
|
||||||
|
WHERE 条件表达式求值、关系代数运算与内存表管理。
|
||||||
|
build-type: Simple
|
||||||
|
|
||||||
|
library
|
||||||
|
hs-source-dirs: src
|
||||||
|
exposed-modules: ChuSQL.Core.Engine.Algebra.Eval
|
||||||
|
ChuSQL.Core.Engine.Algebra.Expr
|
||||||
|
ChuSQL.Core.Engine.Algebra.Op
|
||||||
|
ChuSQL.Core.Engine.Algebra.Optimize
|
||||||
|
ChuSQL.Core.Engine.Algebra.Planner
|
||||||
|
ChuSQL.Core.Engine.Algebra.Sort
|
||||||
|
ChuSQL.Core.Engine
|
||||||
|
ChuSQL.Core.Engine.Semantic
|
||||||
|
ChuSQL.Core.Engine.Storage
|
||||||
|
ChuSQL.Core.Engine.Storage.FFI
|
||||||
|
ChuSQL.Core.Engine.Storage.IPC
|
||||||
|
ChuSQL.Core.Engine.Storage.Memory
|
||||||
|
ChuSQL.Core.Engine.Syntax.AST
|
||||||
|
ChuSQL.Core.Engine.Syntax.Parser
|
||||||
|
build-depends: base >= 4.14 && < 5
|
||||||
|
, aeson
|
||||||
|
, bytestring
|
||||||
|
, hashable
|
||||||
|
, chusql-core-model
|
||||||
|
, megaparsec
|
||||||
|
, parser-combinators
|
||||||
|
, scientific
|
||||||
|
, text
|
||||||
|
, unordered-containers
|
||||||
|
extra-libraries: chusql_core_storage
|
||||||
|
-- 运行期找 libchusql_core_storage.so:$ORIGIN 让二进制与动态库同目录即可
|
||||||
|
if os(linux)
|
||||||
|
ld-options: -Wl,-rpath,$ORIGIN
|
||||||
|
if os(osx)
|
||||||
|
ld-options: -Wl,-rpath,@loader_path
|
||||||
|
default-language: Haskell2010
|
||||||
|
ghc-options: -Wall
|
||||||
|
|
||||||
|
executable chusql-core-engine
|
||||||
|
main-is: Main.hs
|
||||||
|
hs-source-dirs: app
|
||||||
|
build-depends: base
|
||||||
|
, chusql-core-engine
|
||||||
|
, chusql-core-model
|
||||||
|
default-language: Haskell2010
|
||||||
|
ghc-options: -Wall
|
||||||
|
-- 运行期找 libchusql_core_storage.so:$ORIGIN 让二进制与动态库同目录即可
|
||||||
|
if os(linux)
|
||||||
|
ld-options: -Wl,-rpath,$ORIGIN
|
||||||
|
if os(osx)
|
||||||
|
ld-options: -Wl,-rpath,@loader_path
|
||||||
|
|
||||||
|
test-suite chusql-core-engine-test
|
||||||
|
type: exitcode-stdio-1.0
|
||||||
|
main-is: Spec.hs
|
||||||
|
hs-source-dirs: test
|
||||||
|
build-depends: base
|
||||||
|
, chusql-core-engine
|
||||||
|
, chusql-core-model
|
||||||
|
, directory
|
||||||
|
, filepath
|
||||||
|
, hspec
|
||||||
|
default-language: Haskell2010
|
||||||
|
ghc-options: -Wall
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
module ChuSQL.Engine (runStatement, runStatementM, rowsOf) where
|
module ChuSQL.Core.Engine (runStatement, runStatementM, rowsOf) where
|
||||||
|
|
||||||
import ChuSQL.Algebra.Eval (evalCondForRowM, evalExprM, evalRelOpM)
|
import ChuSQL.Core.Engine.Algebra.Eval (evalCondForRowM, evalExprM, evalRelOpM)
|
||||||
import ChuSQL.Algebra.Expr (evalCondForRow, evalExpr)
|
import ChuSQL.Core.Engine.Algebra.Expr (evalCondForRow, evalExpr)
|
||||||
import ChuSQL.Algebra.Optimize (optimize)
|
import ChuSQL.Core.Engine.Algebra.Optimize (optimize)
|
||||||
import ChuSQL.Algebra.Planner (translate)
|
import ChuSQL.Core.Engine.Algebra.Planner (translate)
|
||||||
import ChuSQL.Model
|
import ChuSQL.Core.Model
|
||||||
import ChuSQL.Semantic (prepare)
|
import ChuSQL.Core.Engine.Semantic (prepare)
|
||||||
import ChuSQL.Storage (MonadStorage (..))
|
import ChuSQL.Core.Engine.Storage (MonadStorage (..))
|
||||||
import ChuSQL.Storage.Memory (MemoryStorage (runMemoryStorage))
|
import ChuSQL.Core.Engine.Storage.Memory (MemoryStorage (runMemoryStorage))
|
||||||
import ChuSQL.Syntax.AST
|
import ChuSQL.Core.Engine.Syntax.AST
|
||||||
import ChuSQL.Syntax.Parser (parseExpression)
|
import ChuSQL.Core.Engine.Syntax.Parser (parseExpression)
|
||||||
import Data.Maybe (mapMaybe)
|
import Data.Maybe (mapMaybe)
|
||||||
|
|
||||||
-- 引擎入口:语义检查 + 分发执行,以及内存实现与泛型版本。
|
-- 引擎入口:检查语义、按语句分发执行,另有内存实现与泛型版本。
|
||||||
|
|
||||||
-- | 跑一条语句(内存实现)
|
-- | 跑一条语句(内存实现)
|
||||||
runStatement :: Database -> Statement -> Either String (Database, [Row])
|
runStatement :: Database -> Statement -> Either String (Database, [Row])
|
||||||
@@ -74,6 +74,7 @@ runStatementUncheckedM db (Delete tbl mWhere) = do
|
|||||||
result <- replaceAll tbl kept
|
result <- replaceAll tbl kept
|
||||||
pure (result >> Right [])
|
pure (result >> Right [])
|
||||||
where
|
where
|
||||||
|
-- | 取行里的整数 id,取不到就报错
|
||||||
rowId :: Row -> Either String Int
|
rowId :: Row -> Either String Int
|
||||||
rowId r = case lookup "id" r of
|
rowId r = case lookup "id" r of
|
||||||
Just (VInt k) -> Right k
|
Just (VInt k) -> Right k
|
||||||
@@ -137,7 +138,9 @@ existingRows tbl table
|
|||||||
| needsRows table = scan tbl
|
| needsRows table = scan tbl
|
||||||
| otherwise = pure (Right [])
|
| otherwise = pure (Right [])
|
||||||
where
|
where
|
||||||
|
-- | 这一表有没有要判约束的列
|
||||||
needsRows t = any (constraintColumn . snd) (tableCols t)
|
needsRows t = any (constraintColumn . snd) (tableCols t)
|
||||||
|
-- | 这一列带不带要判的约束
|
||||||
constraintColumn c = columnAutoIncrement c || columnUnique c || columnPrimaryKey c
|
constraintColumn c = columnAutoIncrement c || columnUnique c || columnPrimaryKey c
|
||||||
|
|
||||||
-- | 插一行:补齐缺列、给自增值、收类型、查约束
|
-- | 插一行:补齐缺列、给自增值、收类型、查约束
|
||||||
@@ -150,6 +153,7 @@ buildRows table current cols rows = do
|
|||||||
let auto = autoColumn table
|
let auto = autoColumn table
|
||||||
go auto (autoStart auto current) values []
|
go auto (autoStart auto current) values []
|
||||||
where
|
where
|
||||||
|
-- | 逐行构造,最后统一查一遍约束
|
||||||
go _ _ [] acc = do
|
go _ _ [] acc = do
|
||||||
let built = reverse acc
|
let built = reverse acc
|
||||||
mapM_ (runChecks table) built
|
mapM_ (runChecks table) built
|
||||||
@@ -165,11 +169,13 @@ buildRow :: Table -> Maybe (String, Column) -> [String] -> Int -> [Value] -> Eit
|
|||||||
buildRow table auto cols counter vals = go (tableCols table) counter []
|
buildRow table auto cols counter vals = go (tableCols table) counter []
|
||||||
where
|
where
|
||||||
provided = zip cols vals
|
provided = zip cols vals
|
||||||
|
-- | 按列定义逐列补值
|
||||||
go [] c acc = Right (reverse acc, c)
|
go [] c acc = Right (reverse acc, c)
|
||||||
go ((name, col) : rest) c acc = do
|
go ((name, col) : rest) c acc = do
|
||||||
(v, c') <- pick name col c
|
(v, c') <- pick name col c
|
||||||
v' <- coerceValue (columnType col) v
|
v' <- coerceValue (columnType col) v
|
||||||
go rest c' ((name, v') : acc)
|
go rest c' ((name, v') : acc)
|
||||||
|
-- | 决定这一列取什么值
|
||||||
pick name col c
|
pick name col c
|
||||||
| Just (autoName, _) <- auto
|
| Just (autoName, _) <- auto
|
||||||
, name == autoName
|
, name == autoName
|
||||||
@@ -178,11 +184,13 @@ buildRow table auto cols counter vals = go (tableCols table) counter []
|
|||||||
| Just d <- columnDefault col = Right (d, c)
|
| Just d <- columnDefault col = Right (d, c)
|
||||||
| columnNullable col = Right (VNull, c)
|
| columnNullable col = Right (VNull, c)
|
||||||
| otherwise = Left ("INSERT: column " ++ name ++ " is NOT NULL and has no default")
|
| otherwise = Left ("INSERT: column " ++ name ++ " is NOT NULL and has no default")
|
||||||
|
-- | 自增值跟着已有的值往前推
|
||||||
bump name _ c v
|
bump name _ c v
|
||||||
| Just (autoName, _) <- auto
|
| Just (autoName, _) <- auto
|
||||||
, name == autoName = max (c + 1) (valueInt v + 1)
|
, name == autoName = max (c + 1) (valueInt v + 1)
|
||||||
| otherwise = c
|
| otherwise = c
|
||||||
|
|
||||||
|
-- | 取自增值,不是整数就当 0
|
||||||
valueInt :: Value -> Int
|
valueInt :: Value -> Int
|
||||||
valueInt (VInt n) = n
|
valueInt (VInt n) = n
|
||||||
valueInt _ = 0
|
valueInt _ = 0
|
||||||
@@ -204,6 +212,7 @@ autoStart (Just (name, _)) rows = case [n | r <- rows, Just (VInt n) <- [lookup
|
|||||||
runChecks :: Table -> Row -> Either String ()
|
runChecks :: Table -> Row -> Either String ()
|
||||||
runChecks table row = mapM_ check (tableCols table)
|
runChecks table row = mapM_ check (tableCols table)
|
||||||
where
|
where
|
||||||
|
-- | 查这一列的 CHECK
|
||||||
check (name, col) = case columnCheck col of
|
check (name, col) = case columnCheck col of
|
||||||
Nothing -> Right ()
|
Nothing -> Right ()
|
||||||
Just text -> case parseExpression text of
|
Just text -> case parseExpression text of
|
||||||
@@ -213,6 +222,7 @@ runChecks table row = mapM_ check (tableCols table)
|
|||||||
Right True -> Right ()
|
Right True -> Right ()
|
||||||
Right False -> Left ("CHECK on " ++ name ++ " failed")
|
Right False -> Left ("CHECK on " ++ name ++ " failed")
|
||||||
|
|
||||||
|
-- | 只取第一行(报错提示用)
|
||||||
firstLine :: String -> String
|
firstLine :: String -> String
|
||||||
firstLine = takeWhile (/= '\n')
|
firstLine = takeWhile (/= '\n')
|
||||||
|
|
||||||
@@ -220,6 +230,7 @@ firstLine = takeWhile (/= '\n')
|
|||||||
enforceNotNull :: [(String, Column)] -> [Row] -> Either String ()
|
enforceNotNull :: [(String, Column)] -> [Row] -> Either String ()
|
||||||
enforceNotNull cols rows = mapM_ check cols
|
enforceNotNull cols rows = mapM_ check cols
|
||||||
where
|
where
|
||||||
|
-- | 查这一列的 NOT NULL
|
||||||
check (name, col)
|
check (name, col)
|
||||||
| columnNullable col = Right ()
|
| columnNullable col = Right ()
|
||||||
| any (\r -> lookup name r == Just VNull) rows = Left ("NOT NULL: column " ++ name ++ " cannot be null")
|
| any (\r -> lookup name r == Just VNull) rows = Left ("NOT NULL: column " ++ name ++ " cannot be null")
|
||||||
@@ -230,8 +241,11 @@ enforceUnique :: [(String, Column)] -> [Row] -> Either String ()
|
|||||||
enforceUnique cols rows = mapM_ check targets
|
enforceUnique cols rows = mapM_ check targets
|
||||||
where
|
where
|
||||||
targets = [(n, c) | (n, c) <- cols, columnUnique c || columnPrimaryKey c]
|
targets = [(n, c) | (n, c) <- cols, columnUnique c || columnPrimaryKey c]
|
||||||
|
-- | 查这一列有没有重复值
|
||||||
check (name, _) = go name [] (mapMaybe (lookup name) rows)
|
check (name, _) = go name [] (mapMaybe (lookup name) rows)
|
||||||
|
-- | 逐个值比过去
|
||||||
go _ _ [] = Right ()
|
go _ _ [] = Right ()
|
||||||
|
-- | NULL 不算冲突,跳过
|
||||||
go name seen (VNull : rest) = go name seen rest
|
go name seen (VNull : rest) = go name seen rest
|
||||||
go name seen (v : rest)
|
go name seen (v : rest)
|
||||||
| v `elem` seen = Left ("UNIQUE: duplicate value in column " ++ name)
|
| v `elem` seen = Left ("UNIQUE: duplicate value in column " ++ name)
|
||||||
@@ -271,6 +285,7 @@ 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 :: (MonadStorage m) => Database -> Maybe Expr -> [Row] -> m (Either String ([Row], [Row]))
|
||||||
splitByConditionM db cond = go [] []
|
splitByConditionM db cond = go [] []
|
||||||
where
|
where
|
||||||
|
-- | 逐行分流
|
||||||
go doomed kept [] = pure (Right (reverse doomed, reverse kept))
|
go doomed kept [] = pure (Right (reverse doomed, reverse kept))
|
||||||
go doomed kept (r : rs) = do
|
go doomed kept (r : rs) = do
|
||||||
hit <- case cond of
|
hit <- case cond of
|
||||||
@@ -299,6 +314,7 @@ alterTable db tbl change = case lookupTable db tbl of
|
|||||||
result <- replaceSchema tbl cols newRows
|
result <- replaceSchema tbl cols newRows
|
||||||
pure (result >> Right [])
|
pure (result >> Right [])
|
||||||
where
|
where
|
||||||
|
-- | 改完再统一查一遍约束
|
||||||
checkAltered (cols, rows) = do
|
checkAltered (cols, rows) = do
|
||||||
let table = Table tbl cols rows
|
let table = Table tbl cols rows
|
||||||
mapM_ (runChecks table) rows
|
mapM_ (runChecks table) rows
|
||||||
@@ -306,7 +322,7 @@ alterTable db tbl change = case lookupTable db tbl of
|
|||||||
enforceUnique cols rows
|
enforceUnique cols rows
|
||||||
Right (cols, rows)
|
Right (cols, rows)
|
||||||
|
|
||||||
-- | ADD COLUMN:老行补默认值或 NULL,自增列从 1 开始
|
-- | ADD COLUMN:老行补默认值或 NULL
|
||||||
addColumnTo :: (String, Column) -> [(String, Column)] -> [Row] -> Either String ([(String, Column)], [Row])
|
addColumnTo :: (String, Column) -> [(String, Column)] -> [Row] -> Either String ([(String, Column)], [Row])
|
||||||
addColumnTo (name, col) cols rows
|
addColumnTo (name, col) cols rows
|
||||||
| name `elem` map fst cols = Left ("ADD COLUMN: column already exists: " ++ name)
|
| name `elem` map fst cols = Left ("ADD COLUMN: column already exists: " ++ name)
|
||||||
@@ -338,6 +354,7 @@ retypeColumn col ty cols rows
|
|||||||
rows' <- mapM convert rows
|
rows' <- mapM convert rows
|
||||||
Right (map (\entry@(n, c) -> if n == col then (n, c{columnType = ty}) else entry) cols, rows')
|
Right (map (\entry@(n, c) -> if n == col then (n, c{columnType = ty}) else entry) cols, rows')
|
||||||
where
|
where
|
||||||
|
-- | 把这一列的老值收进新类型
|
||||||
convert row = case lookup col row of
|
convert row = case lookup col row of
|
||||||
Nothing -> Right row
|
Nothing -> Right row
|
||||||
Just v -> do
|
Just v -> do
|
||||||
+34
-20
@@ -1,21 +1,20 @@
|
|||||||
module ChuSQL.Algebra.Eval (evalRelOp, evalRelOpM, evalExprM, evalCondForRowM) where
|
module ChuSQL.Core.Engine.Algebra.Eval (evalRelOp, evalRelOpM, evalExprM, evalCondForRowM) where
|
||||||
|
|
||||||
import ChuSQL.Algebra.Expr (evalCondForRow, evalExpr)
|
import ChuSQL.Core.Engine.Algebra.Expr (evalCondForRow, evalExpr)
|
||||||
import ChuSQL.Algebra.Optimize (optimize, relOpCols)
|
import ChuSQL.Core.Engine.Algebra.Optimize (optimize, relOpCols)
|
||||||
import ChuSQL.Algebra.Op
|
import ChuSQL.Core.Engine.Algebra.Op
|
||||||
import ChuSQL.Algebra.Planner (translate)
|
import ChuSQL.Core.Engine.Algebra.Planner (translate)
|
||||||
import ChuSQL.Algebra.Sort (compareValue, sortRows)
|
import ChuSQL.Core.Engine.Algebra.Sort (compareValue, sortRows)
|
||||||
import ChuSQL.Model
|
import ChuSQL.Core.Model
|
||||||
import ChuSQL.Storage (IndexResult (..), MonadStorage (..))
|
import ChuSQL.Core.Engine.Storage (IndexResult (..), MonadStorage (..))
|
||||||
import ChuSQL.Storage.Memory (runMemoryStorage)
|
import ChuSQL.Core.Engine.Storage.Memory (runMemoryStorage)
|
||||||
import ChuSQL.Syntax.AST (Expr (..), JoinKind (..), Subquery (..))
|
import ChuSQL.Core.Engine.Syntax.AST (Expr (..), JoinKind (..), Subquery (..))
|
||||||
import Control.Monad (filterM, foldM)
|
import Control.Monad (filterM, foldM)
|
||||||
import qualified Data.HashMap.Strict as HM
|
import qualified Data.HashMap.Strict as HM
|
||||||
|
|
||||||
-- 执行:按算子树算出结果行(Scan 阶段会加别名前缀)。
|
-- 执行:按算子树算出结果行,外层行供相关子查询引用。
|
||||||
-- 外层行 outer 给相关子查询用:子查询里的列先在本层行里找,找不到再落到外层行。
|
|
||||||
|
|
||||||
-- | 只保留清单里的列,顺序按清单(SELECT 的列序就是结果的列序)
|
-- | 只保留清单里的列,顺序照清单
|
||||||
project :: [String] -> Row -> Row
|
project :: [String] -> Row -> Row
|
||||||
project ["*"] row = row
|
project ["*"] row = row
|
||||||
project cs row = [(c, v) | c <- cs, Just v <- [lookup c row]]
|
project cs row = [(c, v) | c <- cs, Just v <- [lookup c row]]
|
||||||
@@ -24,6 +23,7 @@ project cs row = [(c, v) | c <- cs, Just v <- [lookup c row]]
|
|||||||
addPrefix :: Maybe String -> Row -> Row
|
addPrefix :: Maybe String -> Row -> Row
|
||||||
addPrefix mAlias = map (\(k, v) -> (qualify mAlias k, v))
|
addPrefix mAlias = map (\(k, v) -> (qualify mAlias k, v))
|
||||||
|
|
||||||
|
-- | 给一批行加别名前缀
|
||||||
prefixRows :: Maybe String -> [Row] -> [Row]
|
prefixRows :: Maybe String -> [Row] -> [Row]
|
||||||
prefixRows mAlias = map (addPrefix mAlias)
|
prefixRows mAlias = map (addPrefix mAlias)
|
||||||
|
|
||||||
@@ -48,6 +48,7 @@ rangeCondition mAlias col lo hi = case (lo, hi) of
|
|||||||
(Nothing, Nothing) -> LitBool True
|
(Nothing, Nothing) -> LitBool True
|
||||||
where
|
where
|
||||||
c = Col (qualify mAlias col)
|
c = Col (qualify mAlias col)
|
||||||
|
-- | 一个端点的比较,闭端再加上等号
|
||||||
bound rel (v, inclusive)
|
bound rel (v, inclusive)
|
||||||
| inclusive = Or (rel c (valueLiteral v)) (Eq c (valueLiteral v))
|
| inclusive = Or (rel c (valueLiteral v)) (Eq c (valueLiteral v))
|
||||||
| otherwise = rel c (valueLiteral v)
|
| otherwise = rel c (valueLiteral v)
|
||||||
@@ -57,8 +58,7 @@ rowsCols :: [Row] -> [String]
|
|||||||
rowsCols (r : _) = map fst r
|
rowsCols (r : _) = map fst r
|
||||||
rowsCols [] = []
|
rowsCols [] = []
|
||||||
|
|
||||||
-- | 参与等值匹配的键:NULL 不参与匹配,整数值的浮点归一到整数
|
-- | 参与等值匹配的键:NULL 不参与,整数值的浮点归一
|
||||||
-- (嵌套循环走 compareValues,哈希要对齐它)
|
|
||||||
joinKey :: Value -> Maybe Value
|
joinKey :: Value -> Maybe Value
|
||||||
joinKey VNull = Nothing
|
joinKey VNull = Nothing
|
||||||
joinKey (VFloat d)
|
joinKey (VFloat d)
|
||||||
@@ -70,9 +70,11 @@ joinKey (VFloat d)
|
|||||||
rounded = round d :: Integer
|
rounded = round d :: Integer
|
||||||
joinKey v = Just v
|
joinKey v = Just v
|
||||||
|
|
||||||
|
-- | 取一行的连接键
|
||||||
joinKeyOf :: String -> Row -> Maybe Value
|
joinKeyOf :: String -> Row -> Maybe Value
|
||||||
joinKeyOf c row = lookup c row >>= joinKey
|
joinKeyOf c row = lookup c row >>= joinKey
|
||||||
|
|
||||||
|
-- | 等值连接的两侧列名
|
||||||
data EquiKeys = EquiKeys
|
data EquiKeys = EquiKeys
|
||||||
{ ekLeft :: String
|
{ ekLeft :: String
|
||||||
, ekRight :: String
|
, ekRight :: String
|
||||||
@@ -85,7 +87,7 @@ equiKeys lcols rcols (Eq (Col a) (Col b))
|
|||||||
| a /= b, b `elem` lcols, a `elem` rcols = Just (EquiKeys b a)
|
| a /= b, b `elem` lcols, a `elem` rcols = Just (EquiKeys b a)
|
||||||
equiKeys _ _ _ = Nothing
|
equiKeys _ _ _ = Nothing
|
||||||
|
|
||||||
-- | 连接:能认出等值键就走哈希,否则退回嵌套循环;左连接没配上的左行补 NULL
|
-- | 连接两批行:等值键走哈希,否则嵌套循环
|
||||||
joinRowsIn ::
|
joinRowsIn ::
|
||||||
(MonadStorage m) =>
|
(MonadStorage m) =>
|
||||||
Database ->
|
Database ->
|
||||||
@@ -104,7 +106,7 @@ joinRowsIn db outer kind rcols cond lrows rrows =
|
|||||||
-- 右表为空时行上没有列名,用算子推导出来的列名补 NULL
|
-- 右表为空时行上没有列名,用算子推导出来的列名补 NULL
|
||||||
ncols = if null rrows then rcols else rowsCols rrows
|
ncols = if null rrows then rcols else rowsCols rrows
|
||||||
|
|
||||||
-- | 左连接里配不上的左行补右表 NULL;内连接里没有匹配就是没行
|
-- | 左连接配不上的左行补 NULL,内连接丢弃
|
||||||
unmatched :: JoinKind -> Row -> [String] -> [Row]
|
unmatched :: JoinKind -> Row -> [String] -> [Row]
|
||||||
unmatched InnerJoin _ _ = []
|
unmatched InnerJoin _ _ = []
|
||||||
unmatched LeftJoin l cols = [l ++ [(c, VNull) | c <- cols]]
|
unmatched LeftJoin l cols = [l ++ [(c, VNull) | c <- cols]]
|
||||||
@@ -114,6 +116,7 @@ hashJoin :: JoinKind -> EquiKeys -> [String] -> [Row] -> [Row] -> [Row]
|
|||||||
hashJoin kind ks ncols lrows rrows = concatMap probe lrows
|
hashJoin kind ks ncols lrows rrows = concatMap probe lrows
|
||||||
where
|
where
|
||||||
buckets = HM.fromListWith (flip (++)) [(v, [r]) | r <- rrows, Just v <- [joinKeyOf (ekRight ks) r]]
|
buckets = HM.fromListWith (flip (++)) [(v, [r]) | r <- rrows, Just v <- [joinKeyOf (ekRight ks) r]]
|
||||||
|
-- | 拿左行的键去桶里找
|
||||||
probe l =
|
probe l =
|
||||||
let miss = case kind of
|
let miss = case kind of
|
||||||
InnerJoin -> []
|
InnerJoin -> []
|
||||||
@@ -138,6 +141,7 @@ filterPairsM ::
|
|||||||
m (Either String [Row])
|
m (Either String [Row])
|
||||||
filterPairsM db outer kind ncols cond lrows rrows = go lrows []
|
filterPairsM db outer kind ncols cond lrows rrows = go lrows []
|
||||||
where
|
where
|
||||||
|
-- | 逐左行配对,左连接没配上就补 NULL
|
||||||
go [] acc = pure (Right (reverse acc))
|
go [] acc = pure (Right (reverse acc))
|
||||||
go (l : ls) acc = do
|
go (l : ls) acc = do
|
||||||
hits <- keep l rrows []
|
hits <- keep l rrows []
|
||||||
@@ -148,6 +152,7 @@ filterPairsM db outer kind ncols cond lrows rrows = go lrows []
|
|||||||
(LeftJoin, []) -> [l ++ nullRow]
|
(LeftJoin, []) -> [l ++ nullRow]
|
||||||
_ -> hs
|
_ -> hs
|
||||||
go ls (reverse hs' ++ acc)
|
go ls (reverse hs' ++ acc)
|
||||||
|
-- | 拿一行右行去比条件
|
||||||
keep _ [] acc = pure (Right (reverse acc))
|
keep _ [] acc = pure (Right (reverse acc))
|
||||||
keep l (r : rs) acc = do
|
keep l (r : rs) acc = do
|
||||||
ok <- evalCondForRowM db cond (l ++ r ++ outer)
|
ok <- evalCondForRowM db cond (l ++ r ++ outer)
|
||||||
@@ -157,7 +162,7 @@ filterPairsM db outer kind ncols cond lrows rrows = go lrows []
|
|||||||
Right False -> keep l rs acc
|
Right False -> keep l rs acc
|
||||||
nullRow = [(c, VNull) | c <- ncols]
|
nullRow = [(c, VNull) | c <- ncols]
|
||||||
|
|
||||||
-- | 索引连接:右表是裸 Scan 时逐左行点查,返回 Nothing 表示该走通用路径
|
-- | 索引连接:右表是裸 Scan 时逐左行点查
|
||||||
probeIndexJoin ::
|
probeIndexJoin ::
|
||||||
(MonadStorage m) =>
|
(MonadStorage m) =>
|
||||||
Database ->
|
Database ->
|
||||||
@@ -183,8 +188,7 @@ probeIndexJoin db outer kind mAlias t ks rcols cond l = case lookup (ekLeft ks)
|
|||||||
pairs <- joinRowsIn db outer kind rcols cond [l] (prefixRows mAlias rows)
|
pairs <- joinRowsIn db outer kind rcols cond [l] (prefixRows mAlias rows)
|
||||||
pure (fmap Just pairs)
|
pure (fmap Just pairs)
|
||||||
|
|
||||||
-- | 索引按值的相等匹配,而数值比较会把 2 和 2.0 看成相等,
|
-- | 列与键都不是浮点时才敢用索引
|
||||||
-- 所以只有非浮点列 + 非浮点键才敢用索引,保证优化不改变结果语义
|
|
||||||
indexSafe :: Database -> String -> String -> Value -> Bool
|
indexSafe :: Database -> String -> String -> Value -> Bool
|
||||||
indexSafe db t col k = case lookupTable db t of
|
indexSafe db t col k = case lookupTable db t of
|
||||||
Left _ -> False
|
Left _ -> False
|
||||||
@@ -192,12 +196,14 @@ indexSafe db t col k = case lookupTable db t of
|
|||||||
Nothing -> False
|
Nothing -> False
|
||||||
Just c -> not (floatType (columnType c)) && not (floatValue k)
|
Just c -> not (floatType (columnType c)) && not (floatValue k)
|
||||||
|
|
||||||
|
-- | 是不是浮点列类型
|
||||||
floatType :: ColumnType -> Bool
|
floatType :: ColumnType -> Bool
|
||||||
floatType CFloat = True
|
floatType CFloat = True
|
||||||
floatType CDouble = True
|
floatType CDouble = True
|
||||||
floatType (CDecimal _ _) = True
|
floatType (CDecimal _ _) = True
|
||||||
floatType _ = False
|
floatType _ = False
|
||||||
|
|
||||||
|
-- | 是不是浮点值
|
||||||
floatValue :: Value -> Bool
|
floatValue :: Value -> Bool
|
||||||
floatValue (VFloat _) = True
|
floatValue (VFloat _) = True
|
||||||
floatValue _ = False
|
floatValue _ = False
|
||||||
@@ -206,12 +212,14 @@ floatValue _ = False
|
|||||||
groupKey :: [String] -> Row -> Either String [Value]
|
groupKey :: [String] -> Row -> Either String [Value]
|
||||||
groupKey keys row = mapM get keys
|
groupKey keys row = mapM get keys
|
||||||
where
|
where
|
||||||
|
-- | 取这一列的分组值
|
||||||
get c = maybe (Left ("unknown column: " ++ c)) Right (lookup c row)
|
get c = maybe (Left ("unknown column: " ++ c)) Right (lookup c row)
|
||||||
|
|
||||||
-- | 按分组键把行分组,保留分组首次出现的顺序
|
-- | 按分组键把行分组,保留分组首次出现的顺序
|
||||||
groupRows :: [String] -> [Row] -> Either String [([Value], [Row])]
|
groupRows :: [String] -> [Row] -> Either String [([Value], [Row])]
|
||||||
groupRows keys rows = go rows (HM.empty, [])
|
groupRows keys rows = go rows (HM.empty, [])
|
||||||
where
|
where
|
||||||
|
-- | 逐行塞进对应分组,并记住分组首次出现的顺序
|
||||||
go [] (acc, order) = Right [(k, reverse (HM.lookupDefault [] k acc)) | k <- reverse order]
|
go [] (acc, order) = Right [(k, reverse (HM.lookupDefault [] k acc)) | k <- reverse order]
|
||||||
go (r : rs) (acc, order) = do
|
go (r : rs) (acc, order) = do
|
||||||
k <- groupKey keys r
|
k <- groupKey keys r
|
||||||
@@ -269,7 +277,9 @@ evalAgg outer agg rows = case agg of
|
|||||||
MaxOf e -> valuesOf outer e rows >>= extremeOf higher . nonNull
|
MaxOf e -> valuesOf outer e rows >>= extremeOf higher . nonNull
|
||||||
_ -> Left "only COUNT, SUM, AVG, MIN and MAX can be aggregated"
|
_ -> Left "only COUNT, SUM, AVG, MIN and MAX can be aggregated"
|
||||||
where
|
where
|
||||||
|
-- | 取更小的那个
|
||||||
lower a b = if compareValue b a == LT then b else a
|
lower a b = if compareValue b a == LT then b else a
|
||||||
|
-- | 取更大的那个
|
||||||
higher a b = if compareValue b a == GT then b else a
|
higher a b = if compareValue b a == GT then b else a
|
||||||
|
|
||||||
-- | 一组的输出行:分组列 + 各聚合值
|
-- | 一组的输出行:分组列 + 各聚合值
|
||||||
@@ -289,6 +299,7 @@ evalAggregate outer keys aggs rows = do
|
|||||||
filterRowsM :: (Monad m) => (Row -> m (Either String Bool)) -> [Row] -> m (Either String [Row])
|
filterRowsM :: (Monad m) => (Row -> m (Either String Bool)) -> [Row] -> m (Either String [Row])
|
||||||
filterRowsM p = go []
|
filterRowsM p = go []
|
||||||
where
|
where
|
||||||
|
-- | 逐行留下通过条件的
|
||||||
go acc [] = pure (Right (reverse acc))
|
go acc [] = pure (Right (reverse acc))
|
||||||
go acc (r : rs) = do
|
go acc (r : rs) = do
|
||||||
keep <- p r
|
keep <- p r
|
||||||
@@ -301,6 +312,7 @@ filterRowsM p = go []
|
|||||||
computeM :: (MonadStorage m) => Database -> Row -> [(String, Expr)] -> Row -> m (Either String Row)
|
computeM :: (MonadStorage m) => Database -> Row -> [(String, Expr)] -> Row -> m (Either String Row)
|
||||||
computeM db outer items row = go items []
|
computeM db outer items row = go items []
|
||||||
where
|
where
|
||||||
|
-- | 逐项求值并攒成一行
|
||||||
go [] acc = pure (Right (reverse acc))
|
go [] acc = pure (Right (reverse acc))
|
||||||
go ((label, e) : rest) acc = do
|
go ((label, e) : rest) acc = do
|
||||||
v <- evalExprM db e (row ++ outer)
|
v <- evalExprM db e (row ++ outer)
|
||||||
@@ -375,6 +387,7 @@ substSubqueries db e env = case e of
|
|||||||
MaxOf a -> un MaxOf a
|
MaxOf a -> un MaxOf a
|
||||||
_ -> pure (Right e)
|
_ -> pure (Right e)
|
||||||
where
|
where
|
||||||
|
-- | 递归处理两个子表达式
|
||||||
bin ctor a b = do
|
bin ctor a b = do
|
||||||
ra <- substSubqueries db a env
|
ra <- substSubqueries db a env
|
||||||
case ra of
|
case ra of
|
||||||
@@ -382,6 +395,7 @@ substSubqueries db e env = case e of
|
|||||||
Right a' -> do
|
Right a' -> do
|
||||||
rb <- substSubqueries db b env
|
rb <- substSubqueries db b env
|
||||||
pure ((ctor a') <$> rb)
|
pure ((ctor a') <$> rb)
|
||||||
|
-- | 递归处理一个子表达式
|
||||||
un ctor a = do
|
un ctor a = do
|
||||||
ra <- substSubqueries db a env
|
ra <- substSubqueries db a env
|
||||||
pure (ctor <$> ra)
|
pure (ctor <$> ra)
|
||||||
+16
-4
@@ -1,7 +1,7 @@
|
|||||||
module ChuSQL.Algebra.Expr (evalExpr, evalCondForRow, colsInExpr, aggregatesIn, hasAggregate, bareColumns, hasSubquery, inValues, threeValuedNot) where
|
module ChuSQL.Core.Engine.Algebra.Expr (evalExpr, evalCondForRow, colsInExpr, aggregatesIn, hasAggregate, bareColumns, hasSubquery, inValues, threeValuedNot) where
|
||||||
|
|
||||||
import ChuSQL.Model
|
import ChuSQL.Core.Model
|
||||||
import ChuSQL.Syntax.AST
|
import ChuSQL.Core.Engine.Syntax.AST
|
||||||
import Control.Monad (join)
|
import Control.Monad (join)
|
||||||
|
|
||||||
-- 表达式求值:三值逻辑,NULL 参与运算结果仍是 NULL。
|
-- 表达式求值:三值逻辑,NULL 参与运算结果仍是 NULL。
|
||||||
@@ -54,7 +54,7 @@ evalExpr agg _ = Left ("aggregate functions are not allowed here: " ++ show agg)
|
|||||||
subqueryNeedsDatabase :: String
|
subqueryNeedsDatabase :: String
|
||||||
subqueryNeedsDatabase = "subquery needs a database to run"
|
subqueryNeedsDatabase = "subquery needs a database to run"
|
||||||
|
|
||||||
-- | IN 的三值逻辑:命中为真,没命中但碰到 NULL 就给 NULL,否则为假
|
-- | IN 三值逻辑:命中为真,碰 NULL 得 NULL
|
||||||
inValues :: Value -> [Value] -> Either String Value
|
inValues :: Value -> [Value] -> Either String Value
|
||||||
inValues VNull _ = Right VNull
|
inValues VNull _ = Right VNull
|
||||||
inValues v vs = do
|
inValues v vs = do
|
||||||
@@ -121,6 +121,7 @@ hasSubquery e = case e of
|
|||||||
MaxOf a -> hasSubquery a
|
MaxOf a -> hasSubquery a
|
||||||
_ -> False
|
_ -> False
|
||||||
where
|
where
|
||||||
|
-- | 两个子表达式里任意一个有
|
||||||
both a b = hasSubquery a || hasSubquery b
|
both a b = hasSubquery a || hasSubquery b
|
||||||
|
|
||||||
-- | 表达式里出现的聚合调用(按出现顺序)
|
-- | 表达式里出现的聚合调用(按出现顺序)
|
||||||
@@ -146,6 +147,7 @@ aggregatesIn e = case e of
|
|||||||
IsNotNull a -> aggregatesIn a
|
IsNotNull a -> aggregatesIn a
|
||||||
_ -> []
|
_ -> []
|
||||||
where
|
where
|
||||||
|
-- | 两个子表达式的聚合拼起来
|
||||||
both a b = aggregatesIn a ++ aggregatesIn b
|
both a b = aggregatesIn a ++ aggregatesIn b
|
||||||
|
|
||||||
-- | 表达式里有没有聚合调用
|
-- | 表达式里有没有聚合调用
|
||||||
@@ -174,6 +176,7 @@ bareColumns e = case e of
|
|||||||
ExistsSub sq _ -> subqueryRefs sq
|
ExistsSub sq _ -> subqueryRefs sq
|
||||||
_ -> []
|
_ -> []
|
||||||
where
|
where
|
||||||
|
-- | 两个子表达式的裸列拼起来
|
||||||
both a b = bareColumns a ++ bareColumns b
|
both a b = bareColumns a ++ bareColumns b
|
||||||
|
|
||||||
-- | 判断条件真假:只有 TRUE 通过,NULL 当作不通过
|
-- | 判断条件真假:只有 TRUE 通过,NULL 当作不通过
|
||||||
@@ -204,6 +207,7 @@ data Arith = Arith
|
|||||||
liftArith :: Arith -> Expr -> Expr -> Row -> Either String Value
|
liftArith :: Arith -> Expr -> Expr -> Row -> Either String Value
|
||||||
liftArith ops a b row = liftBinOp go a b row
|
liftArith ops a b row = liftBinOp go a b row
|
||||||
where
|
where
|
||||||
|
-- | NULL 出 NULL,整数配整数走整数算术
|
||||||
go VNull _ = Right VNull
|
go VNull _ = Right VNull
|
||||||
go _ VNull = Right VNull
|
go _ VNull = Right VNull
|
||||||
go (VInt x) (VInt y) = VInt <$> arithInt ops x y
|
go (VInt x) (VInt y) = VInt <$> arithInt ops x y
|
||||||
@@ -217,12 +221,15 @@ asDouble (VInt n) = Just (fromIntegral n)
|
|||||||
asDouble (VFloat d) = Just d
|
asDouble (VFloat d) = Just d
|
||||||
asDouble _ = Nothing
|
asDouble _ = Nothing
|
||||||
|
|
||||||
|
-- | 加法
|
||||||
addArith :: Arith
|
addArith :: Arith
|
||||||
addArith = Arith (\x y -> Right (x + y)) (\x y -> Right (x + y))
|
addArith = Arith (\x y -> Right (x + y)) (\x y -> Right (x + y))
|
||||||
|
|
||||||
|
-- | 减法
|
||||||
subArith :: Arith
|
subArith :: Arith
|
||||||
subArith = Arith (\x y -> Right (x - y)) (\x y -> Right (x - y))
|
subArith = Arith (\x y -> Right (x - y)) (\x y -> Right (x - y))
|
||||||
|
|
||||||
|
-- | 乘法
|
||||||
mulArith :: Arith
|
mulArith :: Arith
|
||||||
mulArith = Arith (\x y -> Right (x * y)) (\x y -> Right (x * y))
|
mulArith = Arith (\x y -> Right (x * y)) (\x y -> Right (x * y))
|
||||||
|
|
||||||
@@ -230,10 +237,12 @@ mulArith = Arith (\x y -> Right (x * y)) (\x y -> Right (x * y))
|
|||||||
divArith :: Arith
|
divArith :: Arith
|
||||||
divArith = Arith intDiv floatDiv
|
divArith = Arith intDiv floatDiv
|
||||||
where
|
where
|
||||||
|
-- | 整数除法:除零与溢出都报错
|
||||||
intDiv _ 0 = Left "division by zero"
|
intDiv _ 0 = Left "division by zero"
|
||||||
intDiv x y
|
intDiv x y
|
||||||
| x == minBound && y == -1 = Left "integer division overflow"
|
| x == minBound && y == -1 = Left "integer division overflow"
|
||||||
| otherwise = Right (x `quot` y)
|
| otherwise = Right (x `quot` y)
|
||||||
|
-- | 浮点除法:不能除以零
|
||||||
floatDiv _ 0 = Left "division by zero"
|
floatDiv _ 0 = Left "division by zero"
|
||||||
floatDiv x y = Right (x / y)
|
floatDiv x y = Right (x / y)
|
||||||
|
|
||||||
@@ -246,6 +255,7 @@ liftCompare ::
|
|||||||
Either String Value
|
Either String Value
|
||||||
liftCompare rel a b row = liftBinOp (compareValues rel) a b row
|
liftCompare rel a b row = liftBinOp (compareValues rel) a b row
|
||||||
|
|
||||||
|
-- | 比较两个值:NULL 给 NULL,其余按数、串、布尔比
|
||||||
compareValues :: (Ordering -> Bool) -> Value -> Value -> Either String Value
|
compareValues :: (Ordering -> Bool) -> Value -> Value -> Either String Value
|
||||||
compareValues rel x y
|
compareValues rel x y
|
||||||
| x == VNull || y == VNull = Right VNull
|
| x == VNull || y == VNull = Right VNull
|
||||||
@@ -263,6 +273,7 @@ liftBool ::
|
|||||||
Either String Value
|
Either String Value
|
||||||
liftBool f = liftBinOp f
|
liftBool f = liftBinOp f
|
||||||
|
|
||||||
|
-- | 逻辑与的三值真值表
|
||||||
andValues :: Value -> Value -> Either String Value
|
andValues :: Value -> Value -> Either String Value
|
||||||
andValues (VBool False) _ = Right (VBool False)
|
andValues (VBool False) _ = Right (VBool False)
|
||||||
andValues _ (VBool False) = Right (VBool False)
|
andValues _ (VBool False) = Right (VBool False)
|
||||||
@@ -272,6 +283,7 @@ andValues (VBool True) VNull = Right VNull
|
|||||||
andValues VNull VNull = Right VNull
|
andValues VNull VNull = Right VNull
|
||||||
andValues _ _ = Left "type error: expected two booleans"
|
andValues _ _ = Left "type error: expected two booleans"
|
||||||
|
|
||||||
|
-- | 逻辑或的三值真值表
|
||||||
orValues :: Value -> Value -> Either String Value
|
orValues :: Value -> Value -> Either String Value
|
||||||
orValues (VBool True) _ = Right (VBool True)
|
orValues (VBool True) _ = Right (VBool True)
|
||||||
orValues _ (VBool True) = Right (VBool True)
|
orValues _ (VBool True) = Right (VBool True)
|
||||||
+5
-4
@@ -1,9 +1,9 @@
|
|||||||
module ChuSQL.Algebra.Op (RelOp (..), renderPlan) where
|
module ChuSQL.Core.Engine.Algebra.Op (RelOp (..), renderPlan) where
|
||||||
|
|
||||||
import ChuSQL.Model (Value)
|
import ChuSQL.Core.Model (Value)
|
||||||
import ChuSQL.Syntax.AST (Expr, JoinKind (..), SortDir)
|
import ChuSQL.Core.Engine.Syntax.AST (Expr, JoinKind (..), SortDir)
|
||||||
|
|
||||||
-- 关系代数算子,以及把算子树渲染成文本。
|
-- 关系代数算子定义与算子树文本渲染。
|
||||||
|
|
||||||
data RelOp
|
data RelOp
|
||||||
= Scan (Maybe String) String (Maybe [String])
|
= Scan (Maybe String) String (Maybe [String])
|
||||||
@@ -28,6 +28,7 @@ kindLabel LeftJoin = "LeftJoin"
|
|||||||
renderPlan :: RelOp -> String
|
renderPlan :: RelOp -> String
|
||||||
renderPlan = go 0
|
renderPlan = go 0
|
||||||
where
|
where
|
||||||
|
-- | 按缩进渲染一个算子
|
||||||
go ind op = case op of
|
go ind op = case op of
|
||||||
Scan a t cols -> pad ++ "Scan " ++ show a ++ " " ++ show t ++ maybe "" ((" columns=" ++) . show) cols
|
Scan a t cols -> pad ++ "Scan " ++ show a ++ " " ++ show t ++ maybe "" ((" columns=" ++) . show) cols
|
||||||
Lookup _ t c k -> pad ++ "Lookup " ++ show t ++ "." ++ show c ++ " = " ++ show k
|
Lookup _ t c k -> pad ++ "Lookup " ++ show t ++ "." ++ show c ++ " = " ++ show k
|
||||||
+10
-8
@@ -1,12 +1,12 @@
|
|||||||
module ChuSQL.Algebra.Optimize (optimize, pushProject, relOpCols) where
|
module ChuSQL.Core.Engine.Algebra.Optimize (optimize, pushProject, relOpCols) where
|
||||||
|
|
||||||
import ChuSQL.Algebra.Expr (colsInExpr, evalExpr)
|
import ChuSQL.Core.Engine.Algebra.Expr (colsInExpr, evalExpr)
|
||||||
import ChuSQL.Algebra.Op (RelOp (..))
|
import ChuSQL.Core.Engine.Algebra.Op (RelOp (..))
|
||||||
import ChuSQL.Model
|
import ChuSQL.Core.Model
|
||||||
import ChuSQL.Syntax.AST (Expr (..), JoinKind (..))
|
import ChuSQL.Core.Engine.Syntax.AST (Expr (..), JoinKind (..))
|
||||||
import Data.List (nub)
|
import Data.List (nub)
|
||||||
|
|
||||||
-- 查询优化:谓词下推 + 投影下推 + 常量折叠,跑到不动点。
|
-- 查询优化:谓词下推、投影下推与常量折叠。
|
||||||
|
|
||||||
-- | 算子会产出哪些列
|
-- | 算子会产出哪些列
|
||||||
relOpCols :: Database -> RelOp -> [String]
|
relOpCols :: Database -> RelOp -> [String]
|
||||||
@@ -48,6 +48,7 @@ combineConj (e : es) = Just (foldl And e es)
|
|||||||
partitionPred :: [String] -> [Expr] -> ([Expr], [Expr])
|
partitionPred :: [String] -> [Expr] -> ([Expr], [Expr])
|
||||||
partitionPred have = foldr pick ([], [])
|
partitionPred have = foldr pick ([], [])
|
||||||
where
|
where
|
||||||
|
-- | 按列归属收到左边或右边
|
||||||
pick e (yes, no)
|
pick e (yes, no)
|
||||||
| colsInExpr e `isSubsetOf` have = (e : yes, no)
|
| colsInExpr e `isSubsetOf` have = (e : yes, no)
|
||||||
| otherwise = (yes, e : no)
|
| otherwise = (yes, e : no)
|
||||||
@@ -72,8 +73,7 @@ pushJoin db p l r c =
|
|||||||
Nothing -> joined
|
Nothing -> joined
|
||||||
Just p' -> Filter p' joined
|
Just p' -> Filter p' joined
|
||||||
|
|
||||||
-- | Filter 压在左连接上时,只把纯左表的谓词下推到左表;
|
-- | Filter 压在左连接上时只下推纯左表的谓词
|
||||||
-- 右表没配上会补 NULL,右表谓词不能提前算。
|
|
||||||
pushLeftJoin :: Database -> Expr -> RelOp -> RelOp -> Expr -> RelOp
|
pushLeftJoin :: Database -> Expr -> RelOp -> RelOp -> Expr -> RelOp
|
||||||
pushLeftJoin db p l r c =
|
pushLeftJoin db p l r c =
|
||||||
let (lPs, rest) = partitionPred (relOpCols db l) (splitConj p)
|
let (lPs, rest) = partitionPred (relOpCols db l) (splitConj p)
|
||||||
@@ -105,6 +105,7 @@ hasDivision e = case e of
|
|||||||
Or a b -> both a b
|
Or a b -> both a b
|
||||||
_ -> False
|
_ -> False
|
||||||
where
|
where
|
||||||
|
-- | 两个子表达式里任意一个有除法
|
||||||
both a b = hasDivision a || hasDivision b
|
both a b = hasDivision a || hasDivision b
|
||||||
|
|
||||||
-- | 检查子计划中可能报错的除法
|
-- | 检查子计划中可能报错的除法
|
||||||
@@ -216,6 +217,7 @@ rewriteNode db op = pushOne db op
|
|||||||
optimizeRelOp :: Database -> RelOp -> RelOp
|
optimizeRelOp :: Database -> RelOp -> RelOp
|
||||||
optimizeRelOp db = rewriteNode db . descend
|
optimizeRelOp db = rewriteNode db . descend
|
||||||
where
|
where
|
||||||
|
-- | 先递归处理子算子
|
||||||
descend (Filter p x) = Filter p (optimizeRelOp db x)
|
descend (Filter p x) = Filter p (optimizeRelOp db x)
|
||||||
descend (Project cols x) = Project cols (optimizeRelOp db x)
|
descend (Project cols x) = Project cols (optimizeRelOp db x)
|
||||||
descend (Compute items x) = Compute items (optimizeRelOp db x)
|
descend (Compute items x) = Compute items (optimizeRelOp db x)
|
||||||
+5
-4
@@ -1,8 +1,8 @@
|
|||||||
module ChuSQL.Algebra.Planner (translate) where
|
module ChuSQL.Core.Engine.Algebra.Planner (translate) where
|
||||||
|
|
||||||
import ChuSQL.Algebra.Expr (aggregatesIn)
|
import ChuSQL.Core.Engine.Algebra.Expr (aggregatesIn)
|
||||||
import ChuSQL.Algebra.Op
|
import ChuSQL.Core.Engine.Algebra.Op
|
||||||
import ChuSQL.Syntax.AST
|
import ChuSQL.Core.Engine.Syntax.AST
|
||||||
|
|
||||||
-- 计划生成:把语句翻译成算子树。
|
-- 计划生成:把语句翻译成算子树。
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ source fromC mWhere = case mWhere of
|
|||||||
nameAggs :: [(String, Expr)] -> [(String, Expr)]
|
nameAggs :: [(String, Expr)] -> [(String, Expr)]
|
||||||
nameAggs items = go (1 :: Int) [] (concatMap (aggregatesIn . snd) items)
|
nameAggs items = go (1 :: Int) [] (concatMap (aggregatesIn . snd) items)
|
||||||
where
|
where
|
||||||
|
-- | 逐个聚合起名,重复的不再起名
|
||||||
go :: Int -> [(String, Expr)] -> [Expr] -> [(String, Expr)]
|
go :: Int -> [(String, Expr)] -> [Expr] -> [(String, Expr)]
|
||||||
go _ acc [] = reverse acc
|
go _ acc [] = reverse acc
|
||||||
go n acc (e : es)
|
go n acc (e : es)
|
||||||
+7
-5
@@ -1,10 +1,10 @@
|
|||||||
module ChuSQL.Algebra.Sort (sortRows, compareValue) where
|
module ChuSQL.Core.Engine.Algebra.Sort (sortRows, compareValue) where
|
||||||
|
|
||||||
import ChuSQL.Model
|
import ChuSQL.Core.Model
|
||||||
import ChuSQL.Syntax.AST
|
import ChuSQL.Core.Engine.Syntax.AST
|
||||||
import Data.List (sortBy)
|
import Data.List (sortBy)
|
||||||
|
|
||||||
-- 排序:按多列排序,每列可升可降。
|
-- 排序:按多列规格排出结果行,每列可升可降。
|
||||||
|
|
||||||
-- | 反转比较结果
|
-- | 反转比较结果
|
||||||
flipOrdering :: Ordering -> Ordering
|
flipOrdering :: Ordering -> Ordering
|
||||||
@@ -41,10 +41,12 @@ compareKeys ((_, dir) : rest) (a : as) (b : bs) =
|
|||||||
_ -> compareKeys rest as bs
|
_ -> compareKeys rest as bs
|
||||||
compareKeys _ _ _ = EQ
|
compareKeys _ _ _ = EQ
|
||||||
|
|
||||||
-- | 排序若干行(先取键再排,避免每次比较都查列)
|
-- | 按排序规格排序若干行
|
||||||
sortRows :: [(String, SortDir)] -> [Row] -> [Row]
|
sortRows :: [(String, SortDir)] -> [Row] -> [Row]
|
||||||
sortRows [] rows = rows
|
sortRows [] rows = rows
|
||||||
sortRows spec rows = map snd (sortBy cmp (map decorate rows))
|
sortRows spec rows = map snd (sortBy cmp (map decorate rows))
|
||||||
where
|
where
|
||||||
|
-- | 取键并带上原行
|
||||||
decorate row = (sortKey spec row, row)
|
decorate row = (sortKey spec row, row)
|
||||||
|
-- | 比较两个带键的行
|
||||||
cmp (k1, _) (k2, _) = compareKeys spec k1 k2
|
cmp (k1, _) (k2, _) = compareKeys spec k1 k2
|
||||||
+35
-18
@@ -1,9 +1,9 @@
|
|||||||
module ChuSQL.Semantic (check, prepare) where
|
module ChuSQL.Core.Engine.Semantic (check, prepare) where
|
||||||
|
|
||||||
import ChuSQL.Algebra.Expr (bareColumns, colsInExpr, hasAggregate)
|
import ChuSQL.Core.Engine.Algebra.Expr (bareColumns, colsInExpr, hasAggregate)
|
||||||
import ChuSQL.Model
|
import ChuSQL.Core.Model
|
||||||
import ChuSQL.Syntax.AST
|
import ChuSQL.Core.Engine.Syntax.AST
|
||||||
import ChuSQL.Syntax.Parser (parseExpression)
|
import ChuSQL.Core.Engine.Syntax.Parser (parseExpression)
|
||||||
import Data.Char (isAlphaNum, isAscii, toLower)
|
import Data.Char (isAlphaNum, isAscii, toLower)
|
||||||
import Data.List (intercalate, isInfixOf, isPrefixOf, nub)
|
import Data.List (intercalate, isInfixOf, isPrefixOf, nub)
|
||||||
import Data.Maybe (fromMaybe)
|
import Data.Maybe (fromMaybe)
|
||||||
@@ -22,8 +22,7 @@ prefixColumns mAlias cols = [(prefix ++ c, ty) | (c, ty) <- cols]
|
|||||||
where
|
where
|
||||||
prefix = maybe "" (++ ".") mAlias
|
prefix = maybe "" (++ ".") mAlias
|
||||||
|
|
||||||
-- | 检查期的作用域:本层优先,外层兜底。
|
-- | 检查期的作用域:本层优先,外层兜底
|
||||||
-- 不能把两层平铺成一个列表:同名裸列会出现两次,被误报成 ambiguous。
|
|
||||||
data Scope = Scope
|
data Scope = Scope
|
||||||
{ scopeInner :: [(String, Column)]
|
{ scopeInner :: [(String, Column)]
|
||||||
, scopeOuter :: [(String, Column)]
|
, scopeOuter :: [(String, Column)]
|
||||||
@@ -39,7 +38,7 @@ scopeAt place scope c
|
|||||||
| "ambiguous column:" `isInfixOf` err -> Left err
|
| "ambiguous column:" `isInfixOf` err -> Left err
|
||||||
| otherwise -> columnAt place (scopeOuter scope) c
|
| otherwise -> columnAt place (scopeOuter scope) c
|
||||||
|
|
||||||
-- | 收集 FROM 能提供的列;outer 是外层作用域,给 ON 里的相关子查询用
|
-- | 收集 FROM 能提供的列,含外层作用域
|
||||||
checkFrom :: Database -> [(String, Column)] -> FromClause -> Either String [(String, Column)]
|
checkFrom :: Database -> [(String, Column)] -> FromClause -> Either String [(String, Column)]
|
||||||
checkFrom _ _ FromUnit = Right []
|
checkFrom _ _ FromUnit = Right []
|
||||||
checkFrom db _ (FromTable mAlias tbl) = do
|
checkFrom db _ (FromTable mAlias tbl) = do
|
||||||
@@ -52,11 +51,11 @@ checkFrom db outer (FromJoin _ left mAlias tbl cond) = do
|
|||||||
checkBool db "ON" (Scope env outer) cond
|
checkBool db "ON" (Scope env outer) cond
|
||||||
Right env
|
Right env
|
||||||
|
|
||||||
-- | 本层作用域优先:外层已有的同名键不再重复堆进来(否则同一个键被数两次,误报 ambiguous)
|
-- | 合并两层列,本层优先且不重复
|
||||||
scopedEnv :: [(String, Column)] -> [(String, Column)] -> [(String, Column)]
|
scopedEnv :: [(String, Column)] -> [(String, Column)] -> [(String, Column)]
|
||||||
scopedEnv env outer = env ++ [o | o <- outer, o `notElem` env]
|
scopedEnv env outer = env ++ [o | o <- outer, o `notElem` env]
|
||||||
|
|
||||||
-- | 作用域摊平成一层(只用于继续往外传,查找仍走 scopeAt 的本层优先)
|
-- | 把作用域摊平成一层的列清单
|
||||||
scopeFlat :: Scope -> [(String, Column)]
|
scopeFlat :: Scope -> [(String, Column)]
|
||||||
scopeFlat scope = scopedEnv (scopeInner scope) (scopeOuter scope)
|
scopeFlat scope = scopedEnv (scopeInner scope) (scopeOuter scope)
|
||||||
|
|
||||||
@@ -73,7 +72,7 @@ missingColumn place c env =
|
|||||||
checkColumns :: String -> Scope -> [String] -> Either String ()
|
checkColumns :: String -> Scope -> [String] -> Either String ()
|
||||||
checkColumns place env = mapM_ (\c -> if c == allColumns then Right () else () <$ scopeAt place env c)
|
checkColumns place env = mapM_ (\c -> if c == allColumns then Right () else () <$ scopeAt place env c)
|
||||||
|
|
||||||
-- | 保留子句名与可用列提示
|
-- | 在一层里查一列,报错时带上子句名与可用列
|
||||||
columnAt :: String -> [(String, Column)] -> String -> Either String (String, Column)
|
columnAt :: String -> [(String, Column)] -> String -> Either String (String, Column)
|
||||||
columnAt place env c = case resolveColumn c env of
|
columnAt place env c = case resolveColumn c env of
|
||||||
Right entry -> Right entry
|
Right entry -> Right entry
|
||||||
@@ -94,6 +93,7 @@ checkGrouping env keys items
|
|||||||
checkColumns "GROUP BY" env keys
|
checkColumns "GROUP BY" env keys
|
||||||
mapM_ okCol (nub (concatMap (bareColumns . snd) items))
|
mapM_ okCol (nub (concatMap (bareColumns . snd) items))
|
||||||
where
|
where
|
||||||
|
-- | 这一列能不能出现在分组查询里
|
||||||
okCol c
|
okCol c
|
||||||
| c == allColumns = Left "SELECT: * cannot be selected together with GROUP BY"
|
| c == allColumns = Left "SELECT: * cannot be selected together with GROUP BY"
|
||||||
| c `elem` keys = Right ()
|
| c `elem` keys = Right ()
|
||||||
@@ -173,7 +173,7 @@ itemType place want got
|
|||||||
| compatible want got = Right ()
|
| compatible want got = Right ()
|
||||||
| otherwise = Left (place ++ ": IN list item has type " ++ renderType got ++ ", expected " ++ renderType want)
|
| otherwise = Left (place ++ ": IN list item has type " ++ renderType got ++ ", expected " ++ renderType want)
|
||||||
|
|
||||||
-- | 子查询的输出列与类型(外层作用域 outerScope 可做相关引用,内层整条查一遍)
|
-- | 推导子查询的输出列与类型
|
||||||
inferSubqueryOutput :: Database -> Scope -> Subquery -> Either String [(String, InferredType)]
|
inferSubqueryOutput :: Database -> Scope -> Subquery -> Either String [(String, InferredType)]
|
||||||
inferSubqueryOutput db outerScope sq = case subqueryStatement sq of
|
inferSubqueryOutput db outerScope sq = case subqueryStatement sq of
|
||||||
q@Select { selectCols = cols } -> do
|
q@Select { selectCols = cols } -> do
|
||||||
@@ -187,6 +187,7 @@ inferSubqueryOutput db outerScope sq = case subqueryStatement sq of
|
|||||||
_ -> Left "subquery must be a SELECT"
|
_ -> Left "subquery must be a SELECT"
|
||||||
where
|
where
|
||||||
enclosing = scopeFlat outerScope
|
enclosing = scopeFlat outerScope
|
||||||
|
-- | 查这一列的推导类型
|
||||||
columnTypeOf env c = do
|
columnTypeOf env c = do
|
||||||
(_, col) <- scopeAt "SELECT" (Scope env enclosing) c
|
(_, col) <- scopeAt "SELECT" (Scope env enclosing) c
|
||||||
Right (c, InferType (columnType col))
|
Right (c, InferType (columnType col))
|
||||||
@@ -222,6 +223,7 @@ arithmetic db place env a b = do
|
|||||||
mapM_ (numericOperand place (InferType want) (InferType want)) [ta, tb]
|
mapM_ (numericOperand place (InferType want) (InferType want)) [ta, tb]
|
||||||
Right (arithResult ta tb)
|
Right (arithResult ta tb)
|
||||||
where
|
where
|
||||||
|
-- | 是不是浮点类型
|
||||||
isFloatSide (InferType x) = numericType x && not (integerType x)
|
isFloatSide (InferType x) = numericType x && not (integerType x)
|
||||||
isFloatSide _ = False
|
isFloatSide _ = False
|
||||||
|
|
||||||
@@ -260,6 +262,7 @@ boolean db place env a b = do
|
|||||||
mapM_ (boolOperand place ta) [ta, tb]
|
mapM_ (boolOperand place ta) [ta, tb]
|
||||||
Right (InferType CBool)
|
Right (InferType CBool)
|
||||||
where
|
where
|
||||||
|
-- | 挨个查操作数是不是布尔
|
||||||
boolOperand _ _ InferNull = Right ()
|
boolOperand _ _ InferNull = Right ()
|
||||||
boolOperand _ _ (InferType CBool) = Right ()
|
boolOperand _ _ (InferType CBool) = Right ()
|
||||||
boolOperand p other t = Left (p ++ ": operator needs TBool on both sides, got " ++ renderType other ++ " and " ++ renderType t)
|
boolOperand p other t = Left (p ++ ": operator needs TBool on both sides, got " ++ renderType other ++ " and " ++ renderType t)
|
||||||
@@ -453,7 +456,7 @@ checkInsertTargets t cols = mapM_ checkTarget (tableCols t)
|
|||||||
| Just _ <- columnDefault c = Right ()
|
| Just _ <- columnDefault c = Right ()
|
||||||
| otherwise = Left ("INSERT: column " ++ name ++ " is NOT NULL and has no default")
|
| otherwise = Left ("INSERT: column " ++ name ++ " is NOT NULL and has no default")
|
||||||
|
|
||||||
-- | 一列定义是否合法;names 是校验 CHECK 列引用时可用的全部列名
|
-- | 检查一列定义是否合法(names 给 CHECK 用)
|
||||||
checkColumnDefs :: [String] -> [(String, Column)] -> Either String ()
|
checkColumnDefs :: [String] -> [(String, Column)] -> Either String ()
|
||||||
checkColumnDefs names cols = do
|
checkColumnDefs names cols = do
|
||||||
mapM_ (\(_, c) -> mapM_ (checkDefault c) (columnDefault c)) cols
|
mapM_ (\(_, c) -> mapM_ (checkDefault c) (columnDefault c)) cols
|
||||||
@@ -480,11 +483,13 @@ checkDefault c v
|
|||||||
checkChecks :: [String] -> [(String, Column)] -> Either String ()
|
checkChecks :: [String] -> [(String, Column)] -> Either String ()
|
||||||
checkChecks names = mapM_ checkOne
|
checkChecks names = mapM_ checkOne
|
||||||
where
|
where
|
||||||
|
-- | 查这一列的 CHECK
|
||||||
checkOne (_, c) = case columnCheck c of
|
checkOne (_, c) = case columnCheck c of
|
||||||
Nothing -> Right ()
|
Nothing -> Right ()
|
||||||
Just text -> case parseExpression text of
|
Just text -> case parseExpression text of
|
||||||
Left err -> Left ("CHECK: " ++ firstLine err)
|
Left err -> Left ("CHECK: " ++ firstLine err)
|
||||||
Right e -> mapM_ (known e) (colsInExpr e)
|
Right e -> mapM_ (known e) (colsInExpr e)
|
||||||
|
-- | 这个列名在不在定义里
|
||||||
known _ col
|
known _ col
|
||||||
| col `elem` names = Right ()
|
| col `elem` names = Right ()
|
||||||
| otherwise = Left ("CHECK: unknown column: " ++ col)
|
| otherwise = Left ("CHECK: unknown column: " ++ col)
|
||||||
@@ -494,6 +499,7 @@ checkChecks names = mapM_ checkOne
|
|||||||
accountNameLimit :: Int
|
accountNameLimit :: Int
|
||||||
accountNameLimit = 64
|
accountNameLimit = 64
|
||||||
|
|
||||||
|
-- | 检查库名是否合法
|
||||||
checkDatabaseName :: String -> Either String ()
|
checkDatabaseName :: String -> Either String ()
|
||||||
checkDatabaseName name
|
checkDatabaseName name
|
||||||
| null name || length name > 64 || not (all (\c -> isAscii c && (isAlphaNum c || c == '_')) name) = Left "invalid database name"
|
| null name || length name > 64 || not (all (\c -> isAscii c && (isAlphaNum c || c == '_')) name) = Left "invalid database name"
|
||||||
@@ -511,6 +517,7 @@ checkUserName name
|
|||||||
| not (all allowed name) = Left "USER: account name may only contain ASCII letters, digits, dot, dash or underscore"
|
| not (all allowed name) = Left "USER: account name may only contain ASCII letters, digits, dot, dash or underscore"
|
||||||
| otherwise = Right ()
|
| otherwise = Right ()
|
||||||
where
|
where
|
||||||
|
-- | 账号名字符规则
|
||||||
allowed c = isAscii c && (isAlphaNum c || c `elem` ("_.-" :: String))
|
allowed c = isAscii c && (isAlphaNum c || c `elem` ("_.-" :: String))
|
||||||
|
|
||||||
-- | 建号 / 改口令:名字合法且口令不空
|
-- | 建号 / 改口令:名字合法且口令不空
|
||||||
@@ -524,7 +531,7 @@ checkAccount name password = do
|
|||||||
then Left "USER: password is too long"
|
then Left "USER: password is too long"
|
||||||
else Right ()
|
else Right ()
|
||||||
|
|
||||||
-- | 角色名:字符规则同账号名,另外不许拿权限关键字当名字(否则 GRANT 读不出来)
|
-- | 检查角色名是否合法(不许用权限关键字)
|
||||||
checkRoleName :: String -> Either String ()
|
checkRoleName :: String -> Either String ()
|
||||||
checkRoleName name
|
checkRoleName name
|
||||||
| null name = Left "ROLE: empty role name"
|
| null name = Left "ROLE: empty role name"
|
||||||
@@ -533,6 +540,7 @@ checkRoleName name
|
|||||||
| map toLower name `elem` privilegeNames = Left "ROLE: role name must not be a privilege keyword"
|
| map toLower name `elem` privilegeNames = Left "ROLE: role name must not be a privilege keyword"
|
||||||
| otherwise = Right ()
|
| otherwise = Right ()
|
||||||
where
|
where
|
||||||
|
-- | 角色名字符规则
|
||||||
allowed c = isAscii c && (isAlphaNum c || c `elem` ("_.-" :: String))
|
allowed c = isAscii c && (isAlphaNum c || c `elem` ("_.-" :: String))
|
||||||
|
|
||||||
-- | 权限关键字(小写,只用于比较)
|
-- | 权限关键字(小写,只用于比较)
|
||||||
@@ -554,8 +562,10 @@ checkGrantObject obj
|
|||||||
| not (all allowed obj) = Left "GRANT: object name may only contain ASCII letters, digits, dot, dash or underscore"
|
| not (all allowed obj) = Left "GRANT: object name may only contain ASCII letters, digits, dot, dash or underscore"
|
||||||
| otherwise = Right ()
|
| otherwise = Right ()
|
||||||
where
|
where
|
||||||
|
-- | 授权对象字符规则
|
||||||
allowed c = isAscii c && (isAlphaNum c || c `elem` ("_.-" :: String))
|
allowed c = isAscii c && (isAlphaNum c || c `elem` ("_.-" :: String))
|
||||||
|
|
||||||
|
-- | 内置 id 列名
|
||||||
idColumn :: String
|
idColumn :: String
|
||||||
idColumn = "id"
|
idColumn = "id"
|
||||||
-- | 把列引用统一为执行期名字(子查询一起解析)
|
-- | 把列引用统一为执行期名字(子查询一起解析)
|
||||||
@@ -592,10 +602,10 @@ resolveExpr db env outer f e = case e of
|
|||||||
ExistsSub sq negated -> ExistsSub <$> resolveSubquery db (env ++ outer) sq <*> pure negated
|
ExistsSub sq negated -> ExistsSub <$> resolveSubquery db (env ++ outer) sq <*> pure negated
|
||||||
_ -> Right e
|
_ -> Right e
|
||||||
where
|
where
|
||||||
|
-- | 递归处理两个子表达式
|
||||||
binary ctor a b = ctor <$> resolveExpr db env outer f a <*> resolveExpr db env outer f b
|
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 :: Database -> [(String, Column)] -> Subquery -> Either String Subquery
|
||||||
resolveSubquery db outer sq = do
|
resolveSubquery db outer sq = do
|
||||||
stmt <- resolveSubqueryStatement db outer (subqueryStatement sq)
|
stmt <- resolveSubqueryStatement db outer (subqueryStatement sq)
|
||||||
@@ -608,11 +618,12 @@ outerRefsOf db stmt = nub [c | c <- statementColumns stmt, not (inInner c)]
|
|||||||
inner = case checkFrom db [] (statementFrom stmt) of
|
inner = case checkFrom db [] (statementFrom stmt) of
|
||||||
Right env -> env
|
Right env -> env
|
||||||
Left _ -> []
|
Left _ -> []
|
||||||
|
-- | 这一列能不能在内层解析到
|
||||||
inInner c = case resolveColumn c inner of
|
inInner c = case resolveColumn c inner of
|
||||||
Right _ -> True
|
Right _ -> True
|
||||||
Left _ -> False
|
Left _ -> False
|
||||||
|
|
||||||
-- | 一条语句里出现的所有列引用(含 JOIN 的 ON 和嵌套子查询的外层引用)
|
-- | 一条语句里出现的所有列引用
|
||||||
statementColumns :: Statement -> [String]
|
statementColumns :: Statement -> [String]
|
||||||
statementColumns stmt = case stmt of
|
statementColumns stmt = case stmt of
|
||||||
Select cols fromC mWhere groupBy orderBy _ ->
|
Select cols fromC mWhere groupBy orderBy _ ->
|
||||||
@@ -621,15 +632,18 @@ statementColumns stmt = case stmt of
|
|||||||
concatMap (colsInExpr . snd) items ++ fromColumns fromC ++ concatMap colsInExpr mWhere ++ groupBy ++ map fst orderBy
|
concatMap (colsInExpr . snd) items ++ fromColumns fromC ++ concatMap colsInExpr mWhere ++ groupBy ++ map fst orderBy
|
||||||
_ -> []
|
_ -> []
|
||||||
where
|
where
|
||||||
|
-- | 取 FROM 里出现的列
|
||||||
fromColumns FromUnit = []
|
fromColumns FromUnit = []
|
||||||
fromColumns (FromTable _ _) = []
|
fromColumns (FromTable _ _) = []
|
||||||
fromColumns (FromJoin _ l _ _ c) = fromColumns l ++ colsInExpr c
|
fromColumns (FromJoin _ l _ _ c) = fromColumns l ++ colsInExpr c
|
||||||
|
|
||||||
|
-- | 取语句的 FROM 子句,没有就是 FromUnit
|
||||||
statementFrom :: Statement -> FromClause
|
statementFrom :: Statement -> FromClause
|
||||||
statementFrom Select { selectFrom = fromC } = fromC
|
statementFrom Select { selectFrom = fromC } = fromC
|
||||||
statementFrom SelectExpr { selectFrom = fromC } = fromC
|
statementFrom SelectExpr { selectFrom = fromC } = fromC
|
||||||
statementFrom _ = FromUnit
|
statementFrom _ = FromUnit
|
||||||
|
|
||||||
|
-- | 解析子查询里的 SELECT
|
||||||
resolveSubqueryStatement :: Database -> [(String, Column)] -> Statement -> Either String Statement
|
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@Select{} = resolveQuery db outer q (map (\c -> (c, Col c)) (selectCols q))
|
||||||
resolveSubqueryStatement db outer q@SelectExpr{} = resolveQuery db outer q (selectItems q)
|
resolveSubqueryStatement db outer q@SelectExpr{} = resolveQuery db outer q (selectItems q)
|
||||||
@@ -672,14 +686,17 @@ resolveSelect db q@Select{} = resolveQuery db [] q (map (\c -> (c, Col c)) (sele
|
|||||||
resolveSelect db q@SelectExpr{} = resolveQuery db [] q (selectItems q)
|
resolveSelect db q@SelectExpr{} = resolveQuery db [] q (selectItems q)
|
||||||
resolveSelect _ q = Right q
|
resolveSelect _ q = Right q
|
||||||
|
|
||||||
-- | 解析投影、条件、排序与连接引用;outer 是相关子查询能看见的外层列
|
-- | 解析投影、条件、排序与连接引用
|
||||||
resolveQuery :: Database -> [(String, Column)] -> Statement -> [(String, Expr)] -> Either String Statement
|
resolveQuery :: Database -> [(String, Column)] -> Statement -> [(String, Expr)] -> Either String Statement
|
||||||
resolveQuery db outer q items = do
|
resolveQuery db outer q items = do
|
||||||
env <- checkFrom db outer (selectFrom q)
|
env <- checkFrom db outer (selectFrom q)
|
||||||
|
-- | 单表查询里把裸列名换成物理列名
|
||||||
let physical c = case selectFrom q of
|
let physical c = case selectFrom q of
|
||||||
FromTable Nothing tbl -> unqualify (Just tbl) c
|
FromTable Nothing tbl -> unqualify (Just tbl) c
|
||||||
_ -> c
|
_ -> c
|
||||||
|
-- | 查一列并换算成物理名
|
||||||
name place c = physical . fst <$> scopedColumnAt place env outer c
|
name place c = physical . fst <$> scopedColumnAt place env outer c
|
||||||
|
-- | 换算一个投影项
|
||||||
output (_, Col c) = do
|
output (_, Col c) = do
|
||||||
k <- name "SELECT" c
|
k <- name "SELECT" c
|
||||||
pure (k, Col k)
|
pure (k, Col k)
|
||||||
+11
-4
@@ -1,11 +1,11 @@
|
|||||||
module ChuSQL.Storage (
|
module ChuSQL.Core.Engine.Storage (
|
||||||
MonadStorage (..),
|
MonadStorage (..),
|
||||||
IndexResult (..),
|
IndexResult (..),
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import ChuSQL.Model (Column, Database, Row, Table (..), Value (..))
|
import ChuSQL.Core.Model (Column, Database, Row, Table (..), Value (..))
|
||||||
|
|
||||||
-- 存储层抽象:上层只认 domain 类型,不关心底层实现。
|
-- 存储层抽象:MonadStorage 类与 IndexResult。
|
||||||
|
|
||||||
data IndexResult
|
data IndexResult
|
||||||
= IndexRows [Row]
|
= IndexRows [Row]
|
||||||
@@ -13,12 +13,16 @@ data IndexResult
|
|||||||
deriving (Show, Eq)
|
deriving (Show, Eq)
|
||||||
|
|
||||||
class (Monad m) => MonadStorage m where
|
class (Monad m) => MonadStorage m where
|
||||||
|
-- \| 建一个库
|
||||||
createDatabase :: String -> m (Either String ())
|
createDatabase :: String -> m (Either String ())
|
||||||
createDatabase _ = pure (Left "database management is unavailable in this storage")
|
createDatabase _ = pure (Left "database management is unavailable in this storage")
|
||||||
|
-- \| 删一个库
|
||||||
dropDatabase :: String -> m (Either String ())
|
dropDatabase :: String -> m (Either String ())
|
||||||
dropDatabase _ = pure (Left "database management is unavailable in this storage")
|
dropDatabase _ = pure (Left "database management is unavailable in this storage")
|
||||||
|
-- \| 切当前库
|
||||||
useDatabase :: String -> m (Either String ())
|
useDatabase :: String -> m (Either String ())
|
||||||
useDatabase _ = pure (Left "database selection is unavailable in this storage")
|
useDatabase _ = pure (Left "database selection is unavailable in this storage")
|
||||||
|
-- \| 列出现有库
|
||||||
listDatabases :: m (Either String [String])
|
listDatabases :: m (Either String [String])
|
||||||
listDatabases = pure (Right [])
|
listDatabases = pure (Right [])
|
||||||
|
|
||||||
@@ -38,6 +42,7 @@ class (Monad m) => MonadStorage m where
|
|||||||
insertMany :: String -> [Row] -> m (Either String ())
|
insertMany :: String -> [Row] -> m (Either String ())
|
||||||
insertMany t = go
|
insertMany t = go
|
||||||
where
|
where
|
||||||
|
-- | 逐行插入,出错就停
|
||||||
go [] = pure (Right ())
|
go [] = pure (Right ())
|
||||||
go (r : rs) = do
|
go (r : rs) = do
|
||||||
x <- insert t r
|
x <- insert t r
|
||||||
@@ -53,6 +58,7 @@ class (Monad m) => MonadStorage m where
|
|||||||
Left e -> pure (Left e)
|
Left e -> pure (Left e)
|
||||||
Right rs -> replaceAll t [r | r <- rs, not (doomed r)]
|
Right rs -> replaceAll t [r | r <- rs, not (doomed r)]
|
||||||
where
|
where
|
||||||
|
-- | 这一行要不要删
|
||||||
doomed r = case lookup "id" r of
|
doomed r = case lookup "id" r of
|
||||||
Just (VInt k) -> k `elem` ks
|
Just (VInt k) -> k `elem` ks
|
||||||
_ -> False
|
_ -> False
|
||||||
@@ -79,7 +85,7 @@ class (Monad m) => MonadStorage m where
|
|||||||
-- \| 删除表
|
-- \| 删除表
|
||||||
dropTable :: String -> m (Either String ())
|
dropTable :: String -> m (Either String ())
|
||||||
|
|
||||||
-- \| 给某一列建索引(要求这一列整数取值唯一)
|
-- \| 给某一列建索引(id 列要求唯一)
|
||||||
createIndex :: String -> String -> m (Either String ())
|
createIndex :: String -> String -> m (Either String ())
|
||||||
createIndex _ _ = pure (Right ())
|
createIndex _ _ = pure (Right ())
|
||||||
|
|
||||||
@@ -100,4 +106,5 @@ class (Monad m) => MonadStorage m where
|
|||||||
schema :: m Database
|
schema :: m Database
|
||||||
schema = fmap (map withoutRows) snapshot
|
schema = fmap (map withoutRows) snapshot
|
||||||
where
|
where
|
||||||
|
-- | 表定义去掉所有行
|
||||||
withoutRows (name, t) = (name, t {tableRows = []})
|
withoutRows (name, t) = (name, t {tableRows = []})
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
{-# LANGUAGE ForeignFunctionInterface #-}
|
||||||
|
{-# LANGUAGE ImportQualifiedPost #-}
|
||||||
|
|
||||||
|
module ChuSQL.Core.Engine.Storage.FFI (
|
||||||
|
StorageHandle,
|
||||||
|
storageVersion,
|
||||||
|
openStorage,
|
||||||
|
closeStorage,
|
||||||
|
storageRequest,
|
||||||
|
) where
|
||||||
|
|
||||||
|
import Control.Exception (SomeException, try)
|
||||||
|
import Data.ByteString qualified as BS
|
||||||
|
import Data.Text qualified as T
|
||||||
|
import Data.Text.Encoding qualified as TE
|
||||||
|
import Data.Text.Encoding.Error (lenientDecode)
|
||||||
|
import Foreign
|
||||||
|
import Foreign.C
|
||||||
|
|
||||||
|
-- Rust 存储库 cdylib 的 C ABI 绑定(ffi.rs),收发单行 JSON。
|
||||||
|
|
||||||
|
-- | 存储实例句柄(Rust 侧的 *mut Storage)
|
||||||
|
newtype StorageHandle = StorageHandle (Ptr ())
|
||||||
|
|
||||||
|
foreign import ccall unsafe "chusql_storage_version"
|
||||||
|
cStorageVersion :: IO CString
|
||||||
|
|
||||||
|
foreign import ccall safe "chusql_storage_open"
|
||||||
|
cStorageOpen :: CString -> IO (Ptr ())
|
||||||
|
|
||||||
|
foreign import ccall safe "chusql_storage_close"
|
||||||
|
cStorageClose :: Ptr () -> IO ()
|
||||||
|
|
||||||
|
foreign import ccall safe "chusql_storage_request"
|
||||||
|
cStorageRequest :: Ptr () -> Ptr CChar -> CSize -> Ptr (Ptr CChar) -> Ptr CSize -> IO CInt
|
||||||
|
|
||||||
|
foreign import ccall unsafe "chusql_storage_free"
|
||||||
|
cStorageFree :: Ptr CChar -> CSize -> IO ()
|
||||||
|
|
||||||
|
foreign import ccall unsafe "chusql_storage_last_error"
|
||||||
|
cStorageLastError :: Ptr CSize -> IO (Ptr CChar)
|
||||||
|
|
||||||
|
-- | 库版本(Rust 侧的 CARGO_PKG_VERSION)
|
||||||
|
storageVersion :: IO String
|
||||||
|
storageVersion = cStorageVersion >>= peekCString
|
||||||
|
|
||||||
|
-- | 打开存储实例,失败给错误文本
|
||||||
|
openStorage :: Maybe FilePath -> IO (Either String StorageHandle)
|
||||||
|
openStorage path = do
|
||||||
|
result <- try (withCStringOrNull path cStorageOpen) :: IO (Either SomeException (Ptr ()))
|
||||||
|
case result of
|
||||||
|
Left e -> pure (Left ("storage library call failed: " ++ show e))
|
||||||
|
Right ptr
|
||||||
|
| ptr == nullPtr -> Left <$> lastError
|
||||||
|
| otherwise -> pure (Right (StorageHandle ptr))
|
||||||
|
where
|
||||||
|
-- | 没有路径就传空指针,否则转成 C 字符串
|
||||||
|
withCStringOrNull Nothing f = f nullPtr
|
||||||
|
withCStringOrNull (Just s) f = withCString s f
|
||||||
|
|
||||||
|
-- | 关掉存储实例
|
||||||
|
closeStorage :: StorageHandle -> IO ()
|
||||||
|
closeStorage (StorageHandle handle) = cStorageClose handle
|
||||||
|
|
||||||
|
-- | 一条 JSON 请求换一条 JSON 响应
|
||||||
|
storageRequest :: StorageHandle -> BS.ByteString -> IO (Either String BS.ByteString)
|
||||||
|
storageRequest (StorageHandle handle) payload =
|
||||||
|
BS.useAsCStringLen payload $ \(inPtr, inLen) ->
|
||||||
|
alloca $ \outPtrRef ->
|
||||||
|
alloca $ \outLenRef -> do
|
||||||
|
code <- cStorageRequest handle (castPtr inPtr) (fromIntegral inLen) outPtrRef outLenRef
|
||||||
|
if code /= 0
|
||||||
|
then Left <$> lastError
|
||||||
|
else do
|
||||||
|
outPtr <- peek outPtrRef
|
||||||
|
outLen <- peek outLenRef
|
||||||
|
bytes <- BS.packCStringLen (castPtr outPtr, fromIntegral outLen)
|
||||||
|
cStorageFree outPtr outLen
|
||||||
|
pure (Right bytes)
|
||||||
|
|
||||||
|
-- | 当前线程最近一次失败的文本
|
||||||
|
lastError :: IO String
|
||||||
|
lastError = alloca $ \lenRef -> do
|
||||||
|
ptr <- cStorageLastError lenRef
|
||||||
|
if ptr == nullPtr
|
||||||
|
then pure "storage: unknown error"
|
||||||
|
else do
|
||||||
|
len <- peek lenRef
|
||||||
|
raw <- BS.packCStringLen (ptr, fromIntegral len)
|
||||||
|
pure (T.unpack (TE.decodeUtf8With lenientDecode raw))
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
{-# LANGUAGE ImportQualifiedPost #-}
|
||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
|
module ChuSQL.Core.Engine.Storage.IPC (
|
||||||
|
IPCStorage (..),
|
||||||
|
Request (..),
|
||||||
|
Response (..),
|
||||||
|
Account (..),
|
||||||
|
SchemaColumn (..),
|
||||||
|
TableInfo (..),
|
||||||
|
StorageLink (..),
|
||||||
|
setStorageLink,
|
||||||
|
localStorageLink,
|
||||||
|
sendRequest,
|
||||||
|
sendRawRequest,
|
||||||
|
closeConnection,
|
||||||
|
setDatabaseName,
|
||||||
|
getDatabaseName,
|
||||||
|
doListTables,
|
||||||
|
doLookupByColumn,
|
||||||
|
doInsert,
|
||||||
|
doInsertMany,
|
||||||
|
doDeleteKeys,
|
||||||
|
doDescribeTable,
|
||||||
|
doDropTable,
|
||||||
|
doListCatalog,
|
||||||
|
) where
|
||||||
|
|
||||||
|
import ChuSQL.Core.Model
|
||||||
|
import ChuSQL.Core.Protocol (Account (..), Request (..), Response (..), SchemaColumn (..), TableInfo (..))
|
||||||
|
import ChuSQL.Core.Engine.Storage
|
||||||
|
import ChuSQL.Core.Engine.Storage.FFI (closeStorage, openStorage, storageRequest)
|
||||||
|
import Control.Concurrent.MVar (MVar, modifyMVar_, newMVar, readMVar)
|
||||||
|
import Data.Aeson (eitherDecodeStrict, encode)
|
||||||
|
import Data.ByteString qualified as BS
|
||||||
|
import Data.ByteString.Lazy qualified as BL
|
||||||
|
import Data.Maybe (fromMaybe)
|
||||||
|
import System.IO.Unsafe (unsafePerformIO)
|
||||||
|
|
||||||
|
-- IPC 存储实现:把 MonadStorage 的操作变成发给存储库的 JSON 请求。
|
||||||
|
|
||||||
|
newtype IPCStorage a = IPCStorage
|
||||||
|
{ runIPCStorage :: IO a
|
||||||
|
}
|
||||||
|
|
||||||
|
instance Functor IPCStorage where
|
||||||
|
fmap f (IPCStorage m) = IPCStorage (fmap f m)
|
||||||
|
|
||||||
|
instance Applicative IPCStorage where
|
||||||
|
pure = IPCStorage . pure
|
||||||
|
IPCStorage mf <*> IPCStorage ma = IPCStorage (mf <*> ma)
|
||||||
|
|
||||||
|
instance Monad IPCStorage where
|
||||||
|
IPCStorage m >>= k = IPCStorage $ do
|
||||||
|
a <- m
|
||||||
|
runIPCStorage (k a)
|
||||||
|
|
||||||
|
-- | 一条到存储库的链路:管请求与关闭
|
||||||
|
data StorageLink = StorageLink
|
||||||
|
{ slRequest :: BS.ByteString -> IO (Either String BS.ByteString)
|
||||||
|
, slClose :: IO ()
|
||||||
|
}
|
||||||
|
|
||||||
|
{-# NOINLINE linkRef #-}
|
||||||
|
-- | 进程全局的存储链路
|
||||||
|
linkRef :: MVar (Maybe StorageLink)
|
||||||
|
linkRef = unsafePerformIO (newMVar Nothing)
|
||||||
|
|
||||||
|
-- | 装入一条链路;一个进程只装一次
|
||||||
|
setStorageLink :: StorageLink -> IO ()
|
||||||
|
setStorageLink link = modifyMVar_ linkRef (const (pure (Just link)))
|
||||||
|
|
||||||
|
-- | 打开本地的存储动态库,得到一条链路
|
||||||
|
localStorageLink :: Maybe FilePath -> IO (Either String StorageLink)
|
||||||
|
localStorageLink path = fmap (fmap toLink) (openStorage path)
|
||||||
|
where
|
||||||
|
-- | 把句柄包成一条链路
|
||||||
|
toLink handle =
|
||||||
|
StorageLink
|
||||||
|
{ slRequest = storageRequest handle
|
||||||
|
, slClose = closeStorage handle
|
||||||
|
}
|
||||||
|
|
||||||
|
-- | 关掉当前链路(进程退出前调用);没有链路就什么都不做
|
||||||
|
closeConnection :: IO ()
|
||||||
|
closeConnection = modifyMVar_ linkRef $ \current -> do
|
||||||
|
case current of
|
||||||
|
Nothing -> pure ()
|
||||||
|
Just link -> slClose link
|
||||||
|
pure Nothing
|
||||||
|
|
||||||
|
-- | 发一条请求并解码应答;链路没装就报错,不崩
|
||||||
|
sendRequest :: Request -> IO Response
|
||||||
|
sendRequest req = do
|
||||||
|
current <- readMVar linkRef
|
||||||
|
case current of
|
||||||
|
Nothing -> pure (RespError "storage not configured")
|
||||||
|
Just link -> do
|
||||||
|
raw <- slRequest link (BL.toStrict (encode req))
|
||||||
|
case raw of
|
||||||
|
Left e -> pure (RespError ("storage error: " ++ e))
|
||||||
|
Right bytes -> case eitherDecodeStrict (BS.dropWhileEnd (== 13) bytes) of
|
||||||
|
Left e -> pure (RespError ("decode error: " ++ e))
|
||||||
|
Right resp -> pure resp
|
||||||
|
|
||||||
|
-- | 原样转发一条载荷给存储库
|
||||||
|
sendRawRequest :: BS.ByteString -> IO (Either String BS.ByteString)
|
||||||
|
sendRawRequest payload = do
|
||||||
|
current <- readMVar linkRef
|
||||||
|
case current of
|
||||||
|
Nothing -> pure (Left "storage not configured")
|
||||||
|
Just link -> slRequest link payload
|
||||||
|
|
||||||
|
{-# NOINLINE databaseRef #-}
|
||||||
|
-- | 进程全局的当前库名
|
||||||
|
databaseRef :: MVar (Maybe String)
|
||||||
|
databaseRef = unsafePerformIO (newMVar Nothing)
|
||||||
|
|
||||||
|
-- | 设定当前库名(会话层决定,本层只记住并发给存储)
|
||||||
|
setDatabaseName :: Maybe String -> IO ()
|
||||||
|
setDatabaseName name = modifyMVar_ databaseRef (const (pure name))
|
||||||
|
|
||||||
|
-- | 当前库名
|
||||||
|
getDatabaseName :: IO (Maybe String)
|
||||||
|
getDatabaseName = readMVar databaseRef
|
||||||
|
|
||||||
|
|
||||||
|
-- | 给语句类请求盖上当前库名
|
||||||
|
stampDatabase :: Maybe String -> Request -> Request
|
||||||
|
stampDatabase Nothing req = req
|
||||||
|
stampDatabase (Just db) req
|
||||||
|
| globalRequest req = req
|
||||||
|
| otherwise = ReqInDatabase db req
|
||||||
|
|
||||||
|
-- | 是不是不该带库名的全局请求
|
||||||
|
globalRequest :: Request -> Bool
|
||||||
|
globalRequest ReqDatabase{} = True
|
||||||
|
globalRequest ReqAllCatalog = True
|
||||||
|
globalRequest ReqAccountsList = True
|
||||||
|
globalRequest ReqAccountCreate{} = True
|
||||||
|
globalRequest ReqAccountReset{} = True
|
||||||
|
globalRequest ReqAccountLogin{} = True
|
||||||
|
globalRequest ReqAccountDrop{} = True
|
||||||
|
globalRequest ReqPing = True
|
||||||
|
globalRequest _ = False
|
||||||
|
|
||||||
|
-- | 发请求并把响应翻成 Either
|
||||||
|
ask :: String -> Request -> (Response -> Maybe a) -> IO (Either String a)
|
||||||
|
ask what req recognize = do
|
||||||
|
current <- getDatabaseName
|
||||||
|
resp <- sendRequest (stampDatabase current req)
|
||||||
|
pure $ case resp of
|
||||||
|
RespError e -> Left e
|
||||||
|
other -> case recognize other of
|
||||||
|
Just a -> Right a
|
||||||
|
Nothing -> Left ("unexpected response to " ++ what)
|
||||||
|
|
||||||
|
-- | 只认"成了"(多数写操作都这样)
|
||||||
|
okOnly :: Response -> Maybe ()
|
||||||
|
okOnly RespOk = Just ()
|
||||||
|
okOnly _ = Nothing
|
||||||
|
|
||||||
|
-- | 发 Scan
|
||||||
|
doScan :: String -> IO (Either String [Row])
|
||||||
|
doScan t = ask "scan" (ReqScan t) $ \resp -> case resp of
|
||||||
|
RespRows rows -> Just rows
|
||||||
|
_ -> Nothing
|
||||||
|
|
||||||
|
-- | 发 Insert(索引由存储层维护)
|
||||||
|
doInsert :: String -> Row -> IO (Either String ())
|
||||||
|
doInsert t r = ask "insert" (ReqInsert t r) okOnly
|
||||||
|
|
||||||
|
-- | 发 InsertBatch:一批行一次请求
|
||||||
|
doInsertMany :: String -> [Row] -> IO (Either String ())
|
||||||
|
doInsertMany t rs = ask "insert_batch" (ReqInsertBatch t rs) okOnly
|
||||||
|
|
||||||
|
-- | 发 DeleteKeys
|
||||||
|
doDeleteKeys :: String -> [Int] -> IO (Either String ())
|
||||||
|
doDeleteKeys t ks = ask "delete_keys" (ReqDeleteKeys t ks) okOnly
|
||||||
|
|
||||||
|
-- | 发 ReplaceAll
|
||||||
|
doReplaceAll :: String -> [Row] -> IO (Either String ())
|
||||||
|
doReplaceAll t rs = ask "replace_all" (ReqReplaceAll t rs) okOnly
|
||||||
|
|
||||||
|
-- | 发 ListTables
|
||||||
|
doListTables :: IO (Either String [String])
|
||||||
|
doListTables = ask "list_tables" ReqListTables $ \resp -> case resp of
|
||||||
|
RespTables ts -> Just ts
|
||||||
|
_ -> Nothing
|
||||||
|
|
||||||
|
-- | 一次拿全库 schema
|
||||||
|
doListCatalog :: IO (Either String [TableInfo])
|
||||||
|
doListCatalog = ask "list_catalog" ReqListCatalog $ \resp -> case resp of
|
||||||
|
RespCatalog xs -> Just xs
|
||||||
|
_ -> Nothing
|
||||||
|
|
||||||
|
-- | 按某一列的索引取一行(键可以是整数或字符串)
|
||||||
|
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 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
|
||||||
|
RespSchema info -> Just info
|
||||||
|
_ -> Nothing
|
||||||
|
|
||||||
|
-- | 给某一列建索引
|
||||||
|
doCreateIndex :: String -> String -> IO (Either String ())
|
||||||
|
doCreateIndex t c = ask "create_index" (ReqCreateIndex t c) okOnly
|
||||||
|
|
||||||
|
-- | 去掉某一列的索引
|
||||||
|
doDropIndex :: String -> String -> IO (Either String ())
|
||||||
|
doDropIndex t c = ask "drop_index" (ReqDropIndex t c) okOnly
|
||||||
|
|
||||||
|
-- | 删一列(列定义、这一列上的索引、每行里的那一格一起没)
|
||||||
|
doDropColumn :: String -> String -> IO (Either String ())
|
||||||
|
doDropColumn t c = ask "drop_column" (ReqDropColumn t c) okOnly
|
||||||
|
|
||||||
|
-- | 发 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
|
||||||
|
|
||||||
|
-- | 发 DropTable
|
||||||
|
doDropTable :: String -> IO (Either String ())
|
||||||
|
doDropTable t = ask "drop_table" (ReqDropTable t) okOnly
|
||||||
|
|
||||||
|
-- | 线上 schema 转本地列
|
||||||
|
schemaToColumns :: [SchemaColumn] -> [(String, Column)]
|
||||||
|
schemaToColumns = map toColumn
|
||||||
|
|
||||||
|
-- | 线上 schema 转本地 column
|
||||||
|
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)
|
||||||
|
|
||||||
|
-- \| 一批行一次请求:N 行只落一次盘
|
||||||
|
insertMany t rs = IPCStorage (doInsertMany t rs)
|
||||||
|
|
||||||
|
-- \| 按 id 批量删行
|
||||||
|
deleteKeys t ks = IPCStorage (doDeleteKeys t ks)
|
||||||
|
|
||||||
|
-- \| 发 ReplaceAll
|
||||||
|
replaceAll t rs = IPCStorage (doReplaceAll t rs)
|
||||||
|
|
||||||
|
-- \| 按某一列的索引取一行(没有索引就回 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))
|
||||||
|
|
||||||
|
-- \| ALTER:整表换列定义与全部行
|
||||||
|
replaceSchema name cols rows = IPCStorage (doReplaceSchema name (map toWire cols) rows)
|
||||||
|
|
||||||
|
-- \| 发 DropTable
|
||||||
|
dropTable name = IPCStorage (doDropTable name)
|
||||||
|
|
||||||
|
-- \| 建索引
|
||||||
|
createIndex t c = IPCStorage (doCreateIndex t c)
|
||||||
|
|
||||||
|
-- \| 删索引
|
||||||
|
dropIndex t c = IPCStorage (doDropIndex t c)
|
||||||
|
|
||||||
|
-- \| 删一列
|
||||||
|
dropColumn t c = IPCStorage (doDropColumn t c)
|
||||||
|
|
||||||
|
-- \| 列表 + 逐表扫描拼库
|
||||||
|
snapshot = IPCStorage $ do
|
||||||
|
result <- doListCatalog
|
||||||
|
case result of
|
||||||
|
Left _ -> pure []
|
||||||
|
Right xs -> mapM loadEntry xs
|
||||||
|
where
|
||||||
|
loadEntry info = do
|
||||||
|
rows <- doScan (tiTable info)
|
||||||
|
pure
|
||||||
|
( tiTable info
|
||||||
|
, Table (tiTable info) (schemaToColumns (tiColumns info)) (either (const []) id rows)
|
||||||
|
)
|
||||||
|
|
||||||
|
-- \| 只问数据字典要结构,设了当前库只留它的表
|
||||||
|
schema = IPCStorage $ do
|
||||||
|
current <- getDatabaseName
|
||||||
|
result <- ask "all_catalogs" ReqAllCatalog $ \resp -> case resp of
|
||||||
|
RespCatalog infos -> Just infos
|
||||||
|
_ -> Nothing
|
||||||
|
pure $ case result of
|
||||||
|
Left _ -> []
|
||||||
|
Right xs -> concatMap (visibleTables current) xs
|
||||||
|
|
||||||
|
-- | 没设库名就照单全收;设了库名只收这个库的表,键名去掉库名前缀
|
||||||
|
visibleTables :: Maybe String -> TableInfo -> [(String, Table)]
|
||||||
|
visibleTables current info
|
||||||
|
| Just db <- current =
|
||||||
|
if unqualify (Just db) (tiTable info) /= tiTable info
|
||||||
|
then [bare named]
|
||||||
|
else []
|
||||||
|
| otherwise = [bare info]
|
||||||
|
where
|
||||||
|
named = info {tiTable = unqualify current (tiTable info)}
|
||||||
|
-- | 组装成不带行的表
|
||||||
|
bare i = (tiTable i, Table (tiTable i) (schemaToColumns (tiColumns i)) [])
|
||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
module ChuSQL.Storage.Memory (MemoryStorage (runMemoryStorage)) where
|
module ChuSQL.Core.Engine.Storage.Memory (MemoryStorage (runMemoryStorage)) where
|
||||||
|
|
||||||
import ChuSQL.Model
|
import ChuSQL.Core.Model
|
||||||
import ChuSQL.Storage
|
import ChuSQL.Core.Engine.Storage
|
||||||
|
|
||||||
-- 内存实现:Database 上的状态,错误通道是 Either String。
|
-- 内存实现:Database 上的状态,错误通道是 Either String。
|
||||||
|
|
||||||
+8
-5
@@ -1,6 +1,6 @@
|
|||||||
module ChuSQL.Syntax.AST (Statement (..), FromClause (..), JoinKind (..), Expr (..), Subquery (..), SortDir (..), makeSelect) where
|
module ChuSQL.Core.Engine.Syntax.AST (Statement (..), FromClause (..), JoinKind (..), Expr (..), Subquery (..), SortDir (..), makeSelect) where
|
||||||
|
|
||||||
import ChuSQL.Model (Column (..), ColumnType, Value (..))
|
import ChuSQL.Core.Model (Column (..), ColumnType, Value (..))
|
||||||
|
|
||||||
-- 语法树:语句、数据来源、条件表达式、排序方向。
|
-- 语法树:语句、数据来源、条件表达式、排序方向。
|
||||||
|
|
||||||
@@ -41,11 +41,11 @@ data Statement
|
|||||||
| CreateRole String
|
| CreateRole String
|
||||||
-- | 删角色:连同它的授权与成员一起删
|
-- | 删角色:连同它的授权与成员一起删
|
||||||
| DropRole String
|
| DropRole String
|
||||||
-- | GRANT <权限>[, ...] ON <表|*> TO <角色>
|
-- | GRANT 权限 ON 表 TO 角色
|
||||||
| GrantPrivileges [String] String String
|
| GrantPrivileges [String] String String
|
||||||
-- | REVOKE <权限>[, ...] ON <表|*> FROM <角色>
|
-- | REVOKE 权限 ON 表 FROM 角色
|
||||||
| RevokePrivileges [String] String String
|
| RevokePrivileges [String] String String
|
||||||
-- | GRANT <角色> TO <用户>[, ...]:给用户加角色
|
-- | GRANT 角色 TO 用户:给用户加角色
|
||||||
| GrantRole String [String]
|
| GrantRole String [String]
|
||||||
-- | REVOKE <角色> FROM <用户>[, ...]
|
-- | REVOKE <角色> FROM <用户>[, ...]
|
||||||
| RevokeRole String [String]
|
| RevokeRole String [String]
|
||||||
@@ -61,6 +61,7 @@ data JoinKind
|
|||||||
| LeftJoin
|
| LeftJoin
|
||||||
deriving (Show, Eq)
|
deriving (Show, Eq)
|
||||||
|
|
||||||
|
-- | 数据来源:单表、带连接的来源,或空
|
||||||
data FromClause
|
data FromClause
|
||||||
= FromTable (Maybe String) String
|
= FromTable (Maybe String) String
|
||||||
| FromJoin JoinKind FromClause (Maybe String) String Expr
|
| FromJoin JoinKind FromClause (Maybe String) String Expr
|
||||||
@@ -74,6 +75,7 @@ data Subquery = Subquery
|
|||||||
}
|
}
|
||||||
deriving (Show, Eq)
|
deriving (Show, Eq)
|
||||||
|
|
||||||
|
-- | 条件表达式:列、字面量、算术、比较、逻辑、聚合与子查询
|
||||||
data Expr
|
data Expr
|
||||||
= Col String
|
= Col String
|
||||||
| LitNull
|
| LitNull
|
||||||
@@ -108,6 +110,7 @@ data Expr
|
|||||||
| ExistsSub Subquery Bool
|
| ExistsSub Subquery Bool
|
||||||
deriving (Show, Eq)
|
deriving (Show, Eq)
|
||||||
|
|
||||||
|
-- | 排序方向:升序或降序
|
||||||
data SortDir
|
data SortDir
|
||||||
= Asc
|
= Asc
|
||||||
| Desc
|
| Desc
|
||||||
+16
-11
@@ -1,9 +1,9 @@
|
|||||||
{-# LANGUAGE ImportQualifiedPost #-}
|
{-# LANGUAGE ImportQualifiedPost #-}
|
||||||
|
|
||||||
module ChuSQL.Syntax.Parser (parseStatement, parseExpression) where
|
module ChuSQL.Core.Engine.Syntax.Parser (parseStatement, parseExpression) where
|
||||||
|
|
||||||
import ChuSQL.Model (Column (..), ColumnType (..), Value (..), plainColumn)
|
import ChuSQL.Core.Model (Column (..), ColumnType (..), Value (..), plainColumn)
|
||||||
import ChuSQL.Syntax.AST
|
import ChuSQL.Core.Engine.Syntax.AST
|
||||||
import Control.Monad (void)
|
import Control.Monad (void)
|
||||||
import Control.Monad.Combinators.Expr (Operator (..), makeExprParser)
|
import Control.Monad.Combinators.Expr (Operator (..), makeExprParser)
|
||||||
import Data.Char (isAlpha, isAlphaNum, isHexDigit, isSpace, toLower, toUpper)
|
import Data.Char (isAlpha, isAlphaNum, isHexDigit, isSpace, toLower, toUpper)
|
||||||
@@ -43,7 +43,7 @@ identifier = lexeme $ do
|
|||||||
rest <- many (satisfy isIdentChar)
|
rest <- many (satisfy isIdentChar)
|
||||||
return (first : rest)
|
return (first : rest)
|
||||||
|
|
||||||
-- | 账号名:裸名字或单引号字符串(账号名允许点与短横线,字符串形式才写得出来)
|
-- | 账号名:裸名字或单引号字符串
|
||||||
userName :: Parser String
|
userName :: Parser String
|
||||||
userName = identifier <|> stringLit
|
userName = identifier <|> stringLit
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ existsPredicate = do
|
|||||||
scalarSubquery :: Parser Expr
|
scalarSubquery :: Parser Expr
|
||||||
scalarSubquery = ScalarSub <$> between (symbol "(") (symbol ")") subquerySelect
|
scalarSubquery = ScalarSub <$> between (symbol "(") (symbol ")") subquerySelect
|
||||||
|
|
||||||
-- | [NOT] IN (SELECT ...) 或 [NOT] IN (值清单)
|
-- | 读 [NOT] IN:子查询或值清单
|
||||||
inPredicate :: Parser (Expr -> Expr)
|
inPredicate :: Parser (Expr -> Expr)
|
||||||
inPredicate = do
|
inPredicate = do
|
||||||
negated <- (True <$ try (keyword "not" *> keyword "in")) <|> (False <$ keyword "in")
|
negated <- (True <$ try (keyword "not" *> keyword "in")) <|> (False <$ keyword "in")
|
||||||
@@ -172,7 +172,7 @@ inPredicate = do
|
|||||||
subquerySelect :: Parser Subquery
|
subquerySelect :: Parser Subquery
|
||||||
subquerySelect = Subquery <$> selectStatement <*> pure []
|
subquerySelect = Subquery <$> selectStatement <*> pure []
|
||||||
|
|
||||||
-- | 聚合调用:COUNT(*) / COUNT(x) / SUM(x) / AVG(x) / MIN(x) / MAX(x)
|
-- | 聚合调用:COUNT/SUM/AVG/MIN/MAX
|
||||||
aggregateCall :: Parser Expr
|
aggregateCall :: Parser Expr
|
||||||
aggregateCall =
|
aggregateCall =
|
||||||
choice
|
choice
|
||||||
@@ -184,6 +184,7 @@ aggregateCall =
|
|||||||
, MaxOf <$> aggArg "max"
|
, MaxOf <$> aggArg "max"
|
||||||
]
|
]
|
||||||
where
|
where
|
||||||
|
-- | 读聚合函数的参数
|
||||||
aggArg name = try (keyword name *> between (symbol "(") (symbol ")") expr)
|
aggArg name = try (keyword name *> between (symbol "(") (symbol ")") expr)
|
||||||
|
|
||||||
-- | 只认字面量,给 DEFAULT 用
|
-- | 只认字面量,给 DEFAULT 用
|
||||||
@@ -199,6 +200,7 @@ literalValue =
|
|||||||
, VStr <$> stringLit
|
, VStr <$> stringLit
|
||||||
]
|
]
|
||||||
where
|
where
|
||||||
|
-- | 负浮点字面量
|
||||||
negateLit d = VFloat (negate d)
|
negateLit d = VFloat (negate d)
|
||||||
|
|
||||||
-- | 一个排序列
|
-- | 一个排序列
|
||||||
@@ -230,6 +232,7 @@ assignment = do
|
|||||||
e <- expr
|
e <- expr
|
||||||
return (col, e)
|
return (col, e)
|
||||||
|
|
||||||
|
-- | 保留字清单,别名不许用
|
||||||
reservedWords :: [String]
|
reservedWords :: [String]
|
||||||
reservedWords =
|
reservedWords =
|
||||||
[ "select"
|
[ "select"
|
||||||
@@ -367,7 +370,7 @@ dropIndexStatement = do
|
|||||||
col <- between (symbol "(") (symbol ")") identifier
|
col <- between (symbol "(") (symbol ")") identifier
|
||||||
return (DropIndex tbl col)
|
return (DropIndex tbl col)
|
||||||
|
|
||||||
-- | 读 ALTER TABLE:加列、删列、改名、改类型、改默认值、改可空
|
-- | 读 ALTER TABLE 的加列、删列、改名与改属性
|
||||||
alterTableStatement :: Parser Statement
|
alterTableStatement :: Parser Statement
|
||||||
alterTableStatement = do
|
alterTableStatement = do
|
||||||
keyword "alter"
|
keyword "alter"
|
||||||
@@ -402,7 +405,7 @@ alterTableStatement = do
|
|||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
-- | 读 CREATE USER(口令是明文,交给账号服务加盐派生)
|
-- | 读 CREATE USER,口令是明文
|
||||||
createUserStatement :: Parser Statement
|
createUserStatement :: Parser Statement
|
||||||
createUserStatement = do
|
createUserStatement = do
|
||||||
keyword "create"
|
keyword "create"
|
||||||
@@ -418,7 +421,7 @@ identifiedBy = do
|
|||||||
keyword "by"
|
keyword "by"
|
||||||
stringLit
|
stringLit
|
||||||
|
|
||||||
-- | 读 ALTER USER ... IDENTIFIED BY(改一个普通账号的口令)
|
-- | 读 ALTER USER 改口令
|
||||||
alterUserStatement :: Parser Statement
|
alterUserStatement :: Parser Statement
|
||||||
alterUserStatement = do
|
alterUserStatement = do
|
||||||
keyword "alter"
|
keyword "alter"
|
||||||
@@ -460,11 +463,11 @@ privilegeName =
|
|||||||
, "ALL" <$ keyword "all"
|
, "ALL" <$ keyword "all"
|
||||||
]
|
]
|
||||||
|
|
||||||
-- | ON 后面的授权对象:* 表示全部表,否则是一张表(可带库前缀)
|
-- | ON 后面的授权对象:* 或一张表
|
||||||
grantObject :: Parser String
|
grantObject :: Parser String
|
||||||
grantObject = ("*" <$ symbol "*") <|> tableName
|
grantObject = ("*" <$ symbol "*") <|> tableName
|
||||||
|
|
||||||
-- | 读 GRANT:带 ON 的是给角色授权限,不带的是给用户加角色
|
-- | 读 GRANT:带 ON 的授权限,不带的加角色
|
||||||
grantStatement :: Parser Statement
|
grantStatement :: Parser Statement
|
||||||
grantStatement = do
|
grantStatement = do
|
||||||
keyword "grant"
|
keyword "grant"
|
||||||
@@ -554,6 +557,7 @@ decimalType = do
|
|||||||
Nothing -> CDecimal 10 0
|
Nothing -> CDecimal 10 0
|
||||||
Just (p, s) -> CDecimal p s
|
Just (p, s) -> CDecimal p s
|
||||||
|
|
||||||
|
-- | DECIMAL 的精度与小数位参数
|
||||||
decimalArgs :: Parser (Int, Int)
|
decimalArgs :: Parser (Int, Int)
|
||||||
decimalArgs = do
|
decimalArgs = do
|
||||||
p <- integer
|
p <- integer
|
||||||
@@ -619,6 +623,7 @@ parseStatement input =
|
|||||||
Left err -> Left (errorBundlePretty err)
|
Left err -> Left (errorBundlePretty err)
|
||||||
Right q -> Right q
|
Right q -> Right q
|
||||||
where
|
where
|
||||||
|
-- | 按关键字分派到各语句解析器
|
||||||
statementP =
|
statementP =
|
||||||
selectStatement
|
selectStatement
|
||||||
<|> try (CreateDatabase <$> (keyword "create" *> keyword "database" *> identifier))
|
<|> try (CreateDatabase <$> (keyword "create" *> keyword "database" *> identifier))
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
resolver: lts-24.59
|
||||||
|
|
||||||
|
extra-lib-dirs:
|
||||||
|
- ../storage/target/release
|
||||||
|
|
||||||
|
packages:
|
||||||
|
- .
|
||||||
|
- ../model
|
||||||
File renamed without changes.
@@ -2,32 +2,29 @@
|
|||||||
|
|
||||||
module Main where
|
module Main where
|
||||||
|
|
||||||
import ChuSQL.Algebra.Eval (evalRelOp)
|
import ChuSQL.Core.Engine.Algebra.Eval (evalRelOp)
|
||||||
import ChuSQL.Algebra.Expr (colsInExpr, evalExpr)
|
import ChuSQL.Core.Engine.Algebra.Expr (colsInExpr, evalExpr)
|
||||||
import ChuSQL.Algebra.Op (RelOp (..), renderPlan)
|
import ChuSQL.Core.Engine.Algebra.Op (RelOp (..), renderPlan)
|
||||||
import ChuSQL.Algebra.Optimize (optimize, pushProject)
|
import ChuSQL.Core.Engine.Algebra.Optimize (optimize, pushProject)
|
||||||
import ChuSQL.Algebra.Planner (translate)
|
import ChuSQL.Core.Engine.Algebra.Planner (translate)
|
||||||
import ChuSQL.Engine
|
import ChuSQL.Core.Engine
|
||||||
import ChuSQL.Model
|
import ChuSQL.Core.Model
|
||||||
import ChuSQL.Semantic (prepare)
|
import ChuSQL.Core.Engine.Semantic (prepare)
|
||||||
import ChuSQL.Storage (IndexResult (..), MonadStorage (..))
|
import ChuSQL.Core.Engine.Storage (IndexResult (..), MonadStorage (..))
|
||||||
import ChuSQL.Storage.IPC (IPCStorage (runIPCStorage), Request (ReqPing), Response (RespPong), doListTables, getPipeName, sendRequest, setPipeName)
|
import ChuSQL.Core.Engine.Storage.IPC (IPCStorage (runIPCStorage), closeConnection, doListTables, getDatabaseName, localStorageLink, setDatabaseName, setStorageLink)
|
||||||
import ChuSQL.Syntax.AST
|
import ChuSQL.Core.Engine.Syntax.AST
|
||||||
import ChuSQL.Syntax.Parser
|
import ChuSQL.Core.Engine.Syntax.Parser
|
||||||
import Control.Concurrent (threadDelay)
|
import Control.Exception (IOException, finally, try)
|
||||||
import Control.Exception (IOException, bracket, finally, try)
|
|
||||||
import Data.List (isInfixOf, sortOn)
|
import Data.List (isInfixOf, sortOn)
|
||||||
import Data.Unique (hashUnique, newUnique)
|
import Data.Unique (hashUnique, newUnique)
|
||||||
import System.CPUTime (getCPUTime)
|
import System.CPUTime (getCPUTime)
|
||||||
import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getTemporaryDirectory, removePathForcibly)
|
import System.Directory (createDirectoryIfMissing, getTemporaryDirectory, removePathForcibly)
|
||||||
import System.Exit (ExitCode (..))
|
|
||||||
import System.FilePath ((</>))
|
import System.FilePath ((</>))
|
||||||
import System.IO (Handle, IOMode (WriteMode), withFile)
|
|
||||||
import System.Process (CreateProcess (..), ProcessHandle, StdStream (NoStream, UseHandle), createProcess, proc, terminateProcess, waitForProcess)
|
|
||||||
import Test.Hspec
|
import Test.Hspec
|
||||||
|
|
||||||
-- 引擎层的 hspec 测试:语法、执行、优化器与 IPC 行为。
|
-- 引擎层的 hspec 测试:语法、执行、优化器与存储链路行为。
|
||||||
|
|
||||||
|
-- | 测试表 users:id / name / age
|
||||||
users :: Table
|
users :: Table
|
||||||
users =
|
users =
|
||||||
Table
|
Table
|
||||||
@@ -40,6 +37,7 @@ users =
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- | 测试表 orders:id/user_id/product
|
||||||
orders :: Table
|
orders :: Table
|
||||||
orders =
|
orders =
|
||||||
Table
|
Table
|
||||||
@@ -52,12 +50,14 @@ orders =
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- | 两个测试表组成的默认库
|
||||||
testDB :: Database
|
testDB :: Database
|
||||||
testDB = [("users", users), ("orders", orders)]
|
testDB = [("users", users), ("orders", orders)]
|
||||||
|
|
||||||
|
-- | 跑引擎层全部 hspec 用例
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main = hspec $ do
|
main = hspec $ do
|
||||||
describe "ChuSQL.Syntax.Parser" $ do
|
describe "ChuSQL.Core.Engine.Syntax.Parser" $ do
|
||||||
it "resolves table-qualified columns inside database-qualified tables" $ do
|
it "resolves table-qualified columns inside database-qualified tables" $ do
|
||||||
let db = [("sales.users", users)]
|
let db = [("sales.users", users)]
|
||||||
rowsOf (parseStatement "SELECT users.name FROM sales.users WHERE users.id = 1" >>= runStatement db)
|
rowsOf (parseStatement "SELECT users.name FROM sales.users WHERE users.id = 1" >>= runStatement db)
|
||||||
@@ -194,7 +194,7 @@ main = hspec $ do
|
|||||||
parseStatement "SELECT name FROM"
|
parseStatement "SELECT name FROM"
|
||||||
`shouldSatisfy` isLeft
|
`shouldSatisfy` isLeft
|
||||||
|
|
||||||
describe "ChuSQL.Syntax.Parser (JOIN)" $ do
|
describe "ChuSQL.Core.Engine.Syntax.Parser (JOIN)" $ do
|
||||||
it "parses a simple JOIN without aliases" $ do
|
it "parses a simple JOIN without aliases" $ do
|
||||||
parseStatement "SELECT name FROM users JOIN orders ON id = user_id"
|
parseStatement "SELECT name FROM users JOIN orders ON id = user_id"
|
||||||
`shouldBe` Right
|
`shouldBe` Right
|
||||||
@@ -263,8 +263,10 @@ main = hspec $ do
|
|||||||
(Eq (Col "u.id") (Col "o.user_id"))
|
(Eq (Col "u.id") (Col "o.user_id"))
|
||||||
)
|
)
|
||||||
|
|
||||||
describe "ChuSQL.Engine (SELECT)" $ do
|
describe "ChuSQL.Core.Engine (SELECT)" $ do
|
||||||
|
-- sql:在默认库上跑 SQL 取结果行
|
||||||
let sql input = parseStatement input >>= rowsOf . runStatement testDB
|
let sql input = parseStatement input >>= rowsOf . runStatement testDB
|
||||||
|
-- values:只取结果里的值
|
||||||
values input = fmap (map (map snd)) (sql input)
|
values input = fmap (map (map snd)) (sql input)
|
||||||
it "skips line comments between tokens and at EOF" $ do
|
it "skips line comments between tokens and at EOF" $ do
|
||||||
sql "-- start\nSELECT name -- list\nFROM users -- end" `shouldBe` sql "SELECT name FROM users"
|
sql "-- start\nSELECT name -- list\nFROM users -- end" `shouldBe` sql "SELECT name FROM users"
|
||||||
@@ -415,7 +417,7 @@ main = hspec $ do
|
|||||||
runStatement testDB (makeSelect ["nope"] "users" Nothing)
|
runStatement testDB (makeSelect ["nope"] "users" Nothing)
|
||||||
`shouldSatisfy` isLeft
|
`shouldSatisfy` isLeft
|
||||||
|
|
||||||
describe "ChuSQL.Engine (INSERT)" $ do
|
describe "ChuSQL.Core.Engine (INSERT)" $ do
|
||||||
it "parses a simple INSERT" $ do
|
it "parses a simple INSERT" $ do
|
||||||
parseStatement "INSERT INTO users (name, age) VALUES ('Dave', 22)"
|
parseStatement "INSERT INTO users (name, age) VALUES ('Dave', 22)"
|
||||||
`shouldBe` Right (Insert "users" ["name", "age"] [[LitStr "Dave", LitInt 22]])
|
`shouldBe` Right (Insert "users" ["name", "age"] [[LitStr "Dave", LitInt 22]])
|
||||||
@@ -464,7 +466,7 @@ main = hspec $ do
|
|||||||
runStatement testDB (Insert "users" ["id", "name"] [[LitInt 1, LitStr "A"], [LitInt 2]])
|
runStatement testDB (Insert "users" ["id", "name"] [[LitInt 1, LitStr "A"], [LitInt 2]])
|
||||||
`shouldSatisfy` isLeft
|
`shouldSatisfy` isLeft
|
||||||
|
|
||||||
describe "ChuSQL.Engine (INDEX)" $ do
|
describe "ChuSQL.Core.Engine (INDEX)" $ do
|
||||||
it "parses CREATE INDEX and DROP INDEX" $ do
|
it "parses CREATE INDEX and DROP INDEX" $ do
|
||||||
parseStatement "CREATE INDEX ON users (age)"
|
parseStatement "CREATE INDEX ON users (age)"
|
||||||
`shouldBe` Right (CreateIndex "users" "age")
|
`shouldBe` Right (CreateIndex "users" "age")
|
||||||
@@ -485,7 +487,7 @@ main = hspec $ do
|
|||||||
rowsOf (parseStatement "DROP INDEX ON users (age)" >>= runStatement testDB)
|
rowsOf (parseStatement "DROP INDEX ON users (age)" >>= runStatement testDB)
|
||||||
`shouldBe` Right []
|
`shouldBe` Right []
|
||||||
|
|
||||||
describe "ChuSQL.Engine (DROP COLUMN)" $ do
|
describe "ChuSQL.Core.Engine (DROP COLUMN)" $ do
|
||||||
it "parses ALTER TABLE ... DROP COLUMN case-insensitively" $ do
|
it "parses ALTER TABLE ... DROP COLUMN case-insensitively" $ do
|
||||||
parseStatement "ALTER TABLE users DROP COLUMN age"
|
parseStatement "ALTER TABLE users DROP COLUMN age"
|
||||||
`shouldBe` Right (DropColumn "users" "age")
|
`shouldBe` Right (DropColumn "users" "age")
|
||||||
@@ -514,7 +516,7 @@ main = hspec $ do
|
|||||||
it "the built-in id column cannot be dropped (the UI locates a row by it)" $ 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)
|
(parseStatement "ALTER TABLE users DROP COLUMN id" >>= runStatement testDB)
|
||||||
`shouldSatisfy` isLeft
|
`shouldSatisfy` isLeft
|
||||||
describe "ChuSQL.Engine (USER)" $ do
|
describe "ChuSQL.Core.Engine (USER)" $ do
|
||||||
it "parses CREATE USER / ALTER USER / DROP USER case-insensitively" $ do
|
it "parses CREATE USER / ALTER USER / DROP USER case-insensitively" $ do
|
||||||
parseStatement "CREATE USER alice IDENTIFIED BY 's3cret'"
|
parseStatement "CREATE USER alice IDENTIFIED BY 's3cret'"
|
||||||
`shouldBe` Right (CreateUser "alice" "s3cret")
|
`shouldBe` Right (CreateUser "alice" "s3cret")
|
||||||
@@ -552,7 +554,7 @@ main = hspec $ do
|
|||||||
(parseStatement "DROP USER alice" >>= runStatement testDB)
|
(parseStatement "DROP USER alice" >>= runStatement testDB)
|
||||||
`shouldSatisfy` isLeft
|
`shouldSatisfy` isLeft
|
||||||
|
|
||||||
describe "ChuSQL.Engine (ROLE)" $ do
|
describe "ChuSQL.Core.Engine (ROLE)" $ do
|
||||||
it "parses CREATE ROLE / DROP ROLE case-insensitively" $ 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 "create role analyst" `shouldBe` Right (CreateRole "analyst")
|
parseStatement "create role analyst" `shouldBe` Right (CreateRole "analyst")
|
||||||
@@ -616,7 +618,7 @@ main = hspec $ do
|
|||||||
parseStatement "SELECT * FROM users user" `shouldSatisfy` isLeft
|
parseStatement "SELECT * FROM users user" `shouldSatisfy` isLeft
|
||||||
parseStatement "SELECT user1 FROM users" `shouldBe` Right (Select ["user1"] (FromTable Nothing "users") Nothing [] [] Nothing)
|
parseStatement "SELECT user1 FROM users" `shouldBe` Right (Select ["user1"] (FromTable Nothing "users") Nothing [] [] Nothing)
|
||||||
|
|
||||||
describe "ChuSQL.Engine (DELETE)" $ do
|
describe "ChuSQL.Core.Engine (DELETE)" $ do
|
||||||
it "parses DELETE with WHERE" $ do
|
it "parses DELETE with WHERE" $ do
|
||||||
parseStatement "DELETE FROM users WHERE age < 18"
|
parseStatement "DELETE FROM users WHERE age < 18"
|
||||||
`shouldBe` Right (Delete "users" (Just (Lt (Col "age") (LitInt 18))))
|
`shouldBe` Right (Delete "users" (Just (Lt (Col "age") (LitInt 18))))
|
||||||
@@ -646,7 +648,7 @@ main = hspec $ do
|
|||||||
runStatement testDB (Delete "users" (Just (Gt (Col "unknown") (LitInt 18))))
|
runStatement testDB (Delete "users" (Just (Gt (Col "unknown") (LitInt 18))))
|
||||||
`shouldSatisfy` isLeft
|
`shouldSatisfy` isLeft
|
||||||
|
|
||||||
describe "ChuSQL.Engine (UPDATE)" $ do
|
describe "ChuSQL.Core.Engine (UPDATE)" $ do
|
||||||
it "parses UPDATE with WHERE" $ do
|
it "parses UPDATE with WHERE" $ do
|
||||||
parseStatement "UPDATE users SET age = 26 WHERE name = 'Alice'"
|
parseStatement "UPDATE users SET age = 26 WHERE name = 'Alice'"
|
||||||
`shouldBe` Right
|
`shouldBe` Right
|
||||||
@@ -707,7 +709,7 @@ main = hspec $ do
|
|||||||
runStatement testDB (Update "users" [("age", LitInt 0)] (Just (Gt (Col "unknown") (LitInt 1))))
|
runStatement testDB (Update "users" [("age", LitInt 0)] (Just (Gt (Col "unknown") (LitInt 1))))
|
||||||
`shouldSatisfy` isLeft
|
`shouldSatisfy` isLeft
|
||||||
|
|
||||||
describe "ChuSQL.Engine (ORDER BY)" $ do
|
describe "ChuSQL.Core.Engine (ORDER BY)" $ do
|
||||||
it "parses ORDER BY DESC" $ do
|
it "parses ORDER BY DESC" $ do
|
||||||
case parseStatement "SELECT name FROM users ORDER BY age DESC" of
|
case parseStatement "SELECT name FROM users ORDER BY age DESC" of
|
||||||
Right q -> selectOrderBy q `shouldBe` [("age", Desc)]
|
Right q -> selectOrderBy q `shouldBe` [("age", Desc)]
|
||||||
@@ -757,7 +759,7 @@ main = hspec $ do
|
|||||||
, [("name", VStr "Bob")]
|
, [("name", VStr "Bob")]
|
||||||
]
|
]
|
||||||
|
|
||||||
describe "ChuSQL.Engine (LIMIT)" $ do
|
describe "ChuSQL.Core.Engine (LIMIT)" $ do
|
||||||
it "parses LIMIT" $ do
|
it "parses LIMIT" $ do
|
||||||
case parseStatement "SELECT * FROM users LIMIT 2" of
|
case parseStatement "SELECT * FROM users LIMIT 2" of
|
||||||
Right q -> selectLimit q `shouldBe` Just 2
|
Right q -> selectLimit q `shouldBe` Just 2
|
||||||
@@ -819,7 +821,7 @@ main = hspec $ do
|
|||||||
parseStatement "SELECT * FROM users LIMIT -1"
|
parseStatement "SELECT * FROM users LIMIT -1"
|
||||||
`shouldSatisfy` isLeft
|
`shouldSatisfy` isLeft
|
||||||
|
|
||||||
describe "ChuSQL.Engine (JOIN)" $ do
|
describe "ChuSQL.Core.Engine (JOIN)" $ do
|
||||||
it "executes a two-table JOIN with aliases" $ do
|
it "executes a two-table JOIN with aliases" $ do
|
||||||
rowsOf (parseStatement "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id" >>= runStatement testDB)
|
rowsOf (parseStatement "SELECT u.name, o.product FROM users u JOIN orders o ON u.id = o.user_id" >>= runStatement testDB)
|
||||||
`shouldBe` Right
|
`shouldBe` Right
|
||||||
@@ -870,7 +872,8 @@ main = hspec $ do
|
|||||||
)
|
)
|
||||||
`shouldSatisfy` isLeft
|
`shouldSatisfy` isLeft
|
||||||
|
|
||||||
describe "ChuSQL.Engine (LEFT JOIN)" $ do
|
describe "ChuSQL.Core.Engine (LEFT JOIN)" $ do
|
||||||
|
-- sql:在默认库上跑 SQL 取结果行
|
||||||
let sql input = parseStatement input >>= rowsOf . runStatement testDB
|
let sql input = parseStatement input >>= rowsOf . runStatement testDB
|
||||||
|
|
||||||
it "keeps unmatched left rows and fills the right side with NULL" $ do
|
it "keeps unmatched left rows and fills the right side with NULL" $ do
|
||||||
@@ -930,7 +933,8 @@ main = hspec $ do
|
|||||||
, [("u.name", VStr "Carol"), ("o.product", VStr "Cup")]
|
, [("u.name", VStr "Carol"), ("o.product", VStr "Cup")]
|
||||||
]
|
]
|
||||||
|
|
||||||
describe "ChuSQL.Engine (JOIN 嵌套循环)" $ do
|
describe "ChuSQL.Core.Engine (JOIN 嵌套循环)" $ do
|
||||||
|
-- sql:在默认库上跑 SQL 取结果行
|
||||||
let sql input = parseStatement input >>= rowsOf . runStatement testDB
|
let sql input = parseStatement input >>= rowsOf . runStatement testDB
|
||||||
|
|
||||||
it "keeps columns from both sides when the ON has no equi key" $ do
|
it "keeps columns from both sides when the ON has no equi key" $ do
|
||||||
@@ -943,7 +947,8 @@ main = hspec $ do
|
|||||||
, [("u.name", VStr "Carol"), ("o.product", VStr "Cup")]
|
, [("u.name", VStr "Carol"), ("o.product", VStr "Cup")]
|
||||||
]
|
]
|
||||||
|
|
||||||
describe "ChuSQL.Engine (子查询)" $ do
|
describe "ChuSQL.Core.Engine (子查询)" $ do
|
||||||
|
-- sql:在默认库上跑 SQL 取结果行
|
||||||
let sql input = parseStatement input >>= rowsOf . runStatement testDB
|
let sql input = parseStatement input >>= rowsOf . runStatement testDB
|
||||||
|
|
||||||
it "IN (subquery) keeps the rows whose value the subquery returns" $ do
|
it "IN (subquery) keeps the rows whose value the subquery returns" $ do
|
||||||
@@ -997,9 +1002,12 @@ main = hspec $ do
|
|||||||
sameResultAsUnoptimized "SELECT name FROM users WHERE id IN (SELECT user_id FROM orders)"
|
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)"
|
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
|
describe "ChuSQL.Core.Engine (聚合与 GROUP BY)" $ do
|
||||||
|
-- sql:在默认库上跑 SQL 取结果行
|
||||||
let sql input = parseStatement input >>= rowsOf . runStatement testDB
|
let sql input = parseStatement input >>= rowsOf . runStatement testDB
|
||||||
|
-- sqlDb:在指定库上跑 SQL
|
||||||
sqlDb db input = parseStatement input >>= rowsOf . runStatement db
|
sqlDb db input = parseStatement input >>= rowsOf . runStatement db
|
||||||
|
-- vals:只取结果里的值
|
||||||
vals input = fmap (map (map snd)) (sql input)
|
vals input = fmap (map (map snd)) (sql input)
|
||||||
|
|
||||||
it "counts rows without GROUP BY" $ do
|
it "counts rows without GROUP BY" $ do
|
||||||
@@ -1104,7 +1112,7 @@ main = hspec $ do
|
|||||||
[ [("name", VStr "Carol")]
|
[ [("name", VStr "Carol")]
|
||||||
]
|
]
|
||||||
|
|
||||||
describe "ChuSQL.Algebra.Optimize" $ do
|
describe "ChuSQL.Core.Engine.Algebra.Optimize" $ do
|
||||||
it "keeps single-table WHERE results identical" $ do
|
it "keeps single-table WHERE results identical" $ do
|
||||||
sameResultAsUnoptimized "SELECT name FROM users WHERE age > 18"
|
sameResultAsUnoptimized "SELECT name FROM users WHERE age > 18"
|
||||||
|
|
||||||
@@ -1285,7 +1293,7 @@ main = hspec $ do
|
|||||||
rowsOf (parseStatement "SELECT u.name FROM users u WHERE u.id = 1" >>= runStatement testDB)
|
rowsOf (parseStatement "SELECT u.name FROM users u WHERE u.id = 1" >>= runStatement testDB)
|
||||||
`shouldBe` Right [[("u.name", VStr "Alice")]]
|
`shouldBe` Right [[("u.name", VStr "Alice")]]
|
||||||
|
|
||||||
describe "ChuSQL.Semantic" $ do
|
describe "ChuSQL.Core.Engine.Semantic" $ do
|
||||||
it "rejects an unknown column in WHERE" $ do
|
it "rejects an unknown column in WHERE" $ do
|
||||||
(parseStatement "SELECT name FROM users WHERE nope > 18" >>= runStatement testDB)
|
(parseStatement "SELECT name FROM users WHERE nope > 18" >>= runStatement testDB)
|
||||||
`shouldSatisfy` isLeft
|
`shouldSatisfy` isLeft
|
||||||
@@ -1386,7 +1394,7 @@ main = hspec $ do
|
|||||||
(parseStatement "SELECT * FROM users u JOIN orders o ON u.id = o.user_id" >>= runStatement testDB)
|
(parseStatement "SELECT * FROM users u JOIN orders o ON u.id = o.user_id" >>= runStatement testDB)
|
||||||
`shouldSatisfy` isRight
|
`shouldSatisfy` isRight
|
||||||
|
|
||||||
describe "ChuSQL.Algebra.Op" $ do
|
describe "ChuSQL.Core.Engine.Algebra.Op" $ do
|
||||||
it "renders a plan as indented text" $ do
|
it "renders a plan as indented text" $ do
|
||||||
case parseStatement "SELECT name FROM users WHERE age > 18" >>= translate of
|
case parseStatement "SELECT name FROM users WHERE age > 18" >>= translate of
|
||||||
Left err -> expectationFailure err
|
Left err -> expectationFailure err
|
||||||
@@ -1405,12 +1413,13 @@ main = hspec $ do
|
|||||||
, " Scan Just \"o\" \"orders\""
|
, " Scan Just \"o\" \"orders\""
|
||||||
]
|
]
|
||||||
|
|
||||||
describe "ChuSQL.Storage.IPC" $ do
|
describe "ChuSQL.Core.Engine.Storage.IPC" $ do
|
||||||
it "projects scan columns and preserves constant rows over the pipe" $ do
|
it "projects scan columns and preserves constant rows" $ do
|
||||||
withTestServer $ \srv -> withServerEnv srv $ do
|
withTestSession $ \srv -> withServerEnv srv $ do
|
||||||
runIPCStorage (insertMany "projected" [[("id", VInt 1), ("name", VStr "one")], [("id", VInt 2), ("name", VNull)]]) `shouldReturn` Right ()
|
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" ["name"]) `shouldReturn` Right [[("name", VStr "one")], [("name", VNull)]]
|
||||||
runIPCStorage (scanColumns "projected" []) `shouldReturn` Right [[], []]
|
runIPCStorage (scanColumns "projected" []) `shouldReturn` Right [[], []]
|
||||||
|
-- query:解析并执行一条 SQL
|
||||||
let query sql = case parseStatement sql of
|
let query sql = case parseStatement sql of
|
||||||
Left err -> expectationFailure err >> pure (Left err)
|
Left err -> expectationFailure err >> pure (Left err)
|
||||||
Right stmt -> runIPCStorage (runStatementM stmt)
|
Right stmt -> runIPCStorage (runStatementM stmt)
|
||||||
@@ -1418,8 +1427,9 @@ main = hspec $ do
|
|||||||
fmap (map (map snd)) result `shouldBe` Right [[VInt 1], [VInt 1]]
|
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")]]
|
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
|
it "recreates a dropped column without resurrecting old values" $ do
|
||||||
withTestServer $ \srv -> withServerEnv srv $ do
|
withTestSession $ \srv -> withServerEnv srv $ do
|
||||||
|
-- query:解析并执行一条 SQL
|
||||||
let query sql = case parseStatement sql of
|
let query sql = case parseStatement sql of
|
||||||
Left err -> expectationFailure err >> pure (Left err)
|
Left err -> expectationFailure err >> pure (Left err)
|
||||||
Right stmt -> runIPCStorage (runStatementM stmt)
|
Right stmt -> runIPCStorage (runStatementM stmt)
|
||||||
@@ -1433,19 +1443,19 @@ main = hspec $ do
|
|||||||
query "SELECT secret FROM reused WHERE id = 1" `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
|
it "scan returns rows with all three value types after insert" $ do
|
||||||
withTestServer $ \srv -> withServerEnv srv $ do
|
withTestSession $ \srv -> withServerEnv srv $ do
|
||||||
let row = [("id", VInt 7), ("name", VStr "Alice"), ("flag", VBool True)]
|
let row = [("id", VInt 7), ("name", VStr "Alice"), ("flag", VBool True)]
|
||||||
runIPCStorage (insert "users" row) `shouldReturn` Right ()
|
runIPCStorage (insert "users" row) `shouldReturn` Right ()
|
||||||
rows <- runIPCStorage (scan "users")
|
rows <- runIPCStorage (scan "users")
|
||||||
(map sortRow <$> rows) `shouldBe` Right [sortRow row]
|
(map sortRow <$> rows) `shouldBe` Right [sortRow row]
|
||||||
|
|
||||||
it "scan on a missing table returns Left" $ do
|
it "scan on a missing table returns Left" $ do
|
||||||
withTestServer $ \srv -> withServerEnv srv $ do
|
withTestSession $ \srv -> withServerEnv srv $ do
|
||||||
result <- runIPCStorage (scan "no_such_table")
|
result <- runIPCStorage (scan "no_such_table")
|
||||||
result `shouldSatisfy` isLeft
|
result `shouldSatisfy` isLeft
|
||||||
|
|
||||||
it "scan returns all 20 inserted rows in order" $ do
|
it "scan returns all 20 inserted rows in order" $ do
|
||||||
withTestServer $ \srv -> withServerEnv srv $ do
|
withTestSession $ \srv -> withServerEnv srv $ do
|
||||||
mapM_ (\i -> runIPCStorage (insert "many" [("id", VInt i)])) [1 .. 20 :: Int]
|
mapM_ (\i -> runIPCStorage (insert "many" [("id", VInt i)])) [1 .. 20 :: Int]
|
||||||
rows <- runIPCStorage (scan "many")
|
rows <- runIPCStorage (scan "many")
|
||||||
fmap length rows `shouldBe` Right 20
|
fmap length rows `shouldBe` Right 20
|
||||||
@@ -1453,15 +1463,15 @@ main = hspec $ do
|
|||||||
`shouldBe` Right (sortOn show (map (Just . VInt) [1 .. 20]))
|
`shouldBe` Right (sortOn show (map (Just . VInt) [1 .. 20]))
|
||||||
|
|
||||||
it "replaceAll leaves only the new rows" $ do
|
it "replaceAll leaves only the new rows" $ do
|
||||||
withTestServer $ \srv -> withServerEnv srv $ do
|
withTestSession $ \srv -> withServerEnv srv $ do
|
||||||
runIPCStorage (insert "t" [("id", VInt 1)]) `shouldReturn` Right ()
|
runIPCStorage (insert "t" [("id", VInt 1)]) `shouldReturn` Right ()
|
||||||
runIPCStorage (replaceAll "t" [[("id", VInt 9)], [("id", VInt 8)]]) `shouldReturn` Right ()
|
runIPCStorage (replaceAll "t" [[("id", VInt 9)], [("id", VInt 8)]]) `shouldReturn` Right ()
|
||||||
rows <- runIPCStorage (scan "t")
|
rows <- runIPCStorage (scan "t")
|
||||||
fmap (sortOn show . map (lookup "id")) rows
|
fmap (sortOn show . map (lookup "id")) rows
|
||||||
`shouldBe` Right (sortOn show [Just (VInt 9), Just (VInt 8)])
|
`shouldBe` Right (sortOn show [Just (VInt 9), Just (VInt 8)])
|
||||||
|
|
||||||
it "lookupByColumn finds a row inserted with an id over the pipe" $ do
|
it "lookupByColumn finds a row inserted with an id" $ do
|
||||||
withTestServer $ \srv -> withServerEnv srv $ do
|
withTestSession $ \srv -> withServerEnv srv $ do
|
||||||
let row = [("id", VInt 7), ("name", VStr "Zoe")]
|
let row = [("id", VInt 7), ("name", VStr "Zoe")]
|
||||||
runIPCStorage (insert "keyed" row) `shouldReturn` Right ()
|
runIPCStorage (insert "keyed" row) `shouldReturn` Right ()
|
||||||
found <- runIPCStorage (lookupByColumn "keyed" "id" (VInt 7))
|
found <- runIPCStorage (lookupByColumn "keyed" "id" (VInt 7))
|
||||||
@@ -1469,8 +1479,8 @@ main = hspec $ do
|
|||||||
missing <- runIPCStorage (lookupByColumn "keyed" "id" (VInt 8))
|
missing <- runIPCStorage (lookupByColumn "keyed" "id" (VInt 8))
|
||||||
missing `shouldBe` Right (IndexRows [])
|
missing `shouldBe` Right (IndexRows [])
|
||||||
|
|
||||||
it "looks up a string index over the pipe" $ do
|
it "looks up a string index" $ do
|
||||||
withTestServer $ \srv -> withServerEnv srv $ do
|
withTestSession $ \srv -> withServerEnv srv $ do
|
||||||
runIPCStorage (insert "names" [("id", VInt 1), ("name", VStr "alice")]) `shouldReturn` Right ()
|
runIPCStorage (insert "names" [("id", VInt 1), ("name", VStr "alice")]) `shouldReturn` Right ()
|
||||||
runIPCStorage (insert "names" [("id", VInt 2), ("name", VStr "bob")]) `shouldReturn` Right ()
|
runIPCStorage (insert "names" [("id", VInt 2), ("name", VStr "bob")]) `shouldReturn` Right ()
|
||||||
runIPCStorage (createIndex "names" "name") `shouldReturn` Right ()
|
runIPCStorage (createIndex "names" "name") `shouldReturn` Right ()
|
||||||
@@ -1479,8 +1489,8 @@ main = hspec $ do
|
|||||||
missing <- runIPCStorage (lookupByColumn "names" "name" (VStr "dave"))
|
missing <- runIPCStorage (lookupByColumn "names" "name" (VStr "dave"))
|
||||||
missing `shouldBe` Right (IndexRows [])
|
missing `shouldBe` Right (IndexRows [])
|
||||||
|
|
||||||
it "range scans an indexed column over the pipe" $ do
|
it "range scans an indexed column" $ do
|
||||||
withTestServer $ \srv -> withServerEnv srv $ do
|
withTestSession $ \srv -> withServerEnv srv $ do
|
||||||
runIPCStorage (insert "rng" [("id", VInt 1), ("code", VInt 100)]) `shouldReturn` Right ()
|
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 2), ("code", VInt 200)]) `shouldReturn` Right ()
|
||||||
runIPCStorage (insert "rng" [("id", VInt 3), ("code", VInt 300)]) `shouldReturn` Right ()
|
runIPCStorage (insert "rng" [("id", VInt 3), ("code", VInt 300)]) `shouldReturn` Right ()
|
||||||
@@ -1491,8 +1501,8 @@ main = hspec $ do
|
|||||||
fmap (fmap (map (lookup "id"))) open `shouldBe` Right (Just [Just (VInt 3)])
|
fmap (fmap (map (lookup "id"))) open `shouldBe` Right (Just [Just (VInt 3)])
|
||||||
runIPCStorage (scanRange "rng" "nope" Nothing Nothing) `shouldReturn` Right Nothing
|
runIPCStorage (scanRange "rng" "nope" Nothing Nothing) `shouldReturn` Right Nothing
|
||||||
|
|
||||||
it "replaceAll rebuilds the index over the pipe" $ do
|
it "replaceAll rebuilds the index" $ do
|
||||||
withTestServer $ \srv -> withServerEnv srv $ do
|
withTestSession $ \srv -> withServerEnv srv $ do
|
||||||
runIPCStorage (insert "rb" [("id", VInt 1)]) `shouldReturn` Right ()
|
runIPCStorage (insert "rb" [("id", VInt 1)]) `shouldReturn` Right ()
|
||||||
runIPCStorage (replaceAll "rb" [[("id", VInt 9), ("name", VStr "Zoe")]]) `shouldReturn` Right ()
|
runIPCStorage (replaceAll "rb" [[("id", VInt 9), ("name", VStr "Zoe")]]) `shouldReturn` Right ()
|
||||||
old <- runIPCStorage (lookupByColumn "rb" "id" (VInt 1))
|
old <- runIPCStorage (lookupByColumn "rb" "id" (VInt 1))
|
||||||
@@ -1502,7 +1512,7 @@ main = hspec $ do
|
|||||||
`shouldBe` Just (sortRow [("id", VInt 9), ("name", VStr "Zoe")])
|
`shouldBe` Just (sortRow [("id", VInt 9), ("name", VStr "Zoe")])
|
||||||
|
|
||||||
it "snapshot lists tables and scans each one to build the database" $ do
|
it "snapshot lists tables and scans each one to build the database" $ do
|
||||||
withTestServer $ \srv -> withServerEnv srv $ do
|
withTestSession $ \srv -> withServerEnv srv $ do
|
||||||
runIPCStorage (insert "a" [("id", VInt 1)]) `shouldReturn` Right ()
|
runIPCStorage (insert "a" [("id", VInt 1)]) `shouldReturn` Right ()
|
||||||
runIPCStorage (insert "b" [("id", VInt 2)]) `shouldReturn` Right ()
|
runIPCStorage (insert "b" [("id", VInt 2)]) `shouldReturn` Right ()
|
||||||
db <- runIPCStorage snapshot
|
db <- runIPCStorage snapshot
|
||||||
@@ -1511,38 +1521,31 @@ main = hspec $ do
|
|||||||
map (map (lookup "id") . tableRows . snd) db `shouldBe` [[Just (VInt 1)], [Just (VInt 2)]]
|
map (map (lookup "id") . tableRows . snd) db `shouldBe` [[Just (VInt 1)], [Just (VInt 2)]]
|
||||||
|
|
||||||
it "doListTables returns the tables that exist" $ do
|
it "doListTables returns the tables that exist" $ do
|
||||||
withTestServer $ \srv -> withServerEnv srv $ do
|
withTestSession $ \srv -> withServerEnv srv $ do
|
||||||
runIPCStorage (insert "t1" [("id", VInt 1)]) `shouldReturn` Right ()
|
runIPCStorage (insert "t1" [("id", VInt 1)]) `shouldReturn` Right ()
|
||||||
runIPCStorage (insert "t2" [("id", VInt 2)]) `shouldReturn` Right ()
|
runIPCStorage (insert "t2" [("id", VInt 2)]) `shouldReturn` Right ()
|
||||||
doListTables `shouldReturn` Right ["t1", "t2"]
|
doListTables `shouldReturn` Right ["t1", "t2"]
|
||||||
|
|
||||||
it "rows survive a restart on the same data directory" $ do
|
it "rows survive a restart on the same data directory" $ do
|
||||||
located <- locateServer
|
withTestSession $ \srv -> do
|
||||||
case located of
|
withServerEnv srv $ runIPCStorage (insert "persist" [("id", VInt 42)]) `shouldReturn` Right ()
|
||||||
Left err -> pendingWith err
|
closeConnection
|
||||||
Right bin -> do
|
openSessionIn (dataDir srv)
|
||||||
srv1 <- startServer bin
|
rows <- withServerEnv srv $ runIPCStorage (scan "persist")
|
||||||
let dir = dataDir srv1
|
|
||||||
withServerEnv srv1 $ runIPCStorage (insert "persist" [("id", VInt 42)]) `shouldReturn` Right ()
|
|
||||||
stopServer srv1
|
|
||||||
srv2 <- startServerAt bin dir
|
|
||||||
rows <- withServerEnv srv2 $ runIPCStorage (scan "persist")
|
|
||||||
stopServer srv2
|
|
||||||
cleanServerDir srv1
|
|
||||||
(map sortRow <$> rows) `shouldBe` Right [sortRow [("id", VInt 42)]]
|
(map sortRow <$> rows) `shouldBe` Right [sortRow [("id", VInt 42)]]
|
||||||
|
|
||||||
it "a missing server yields Left without throwing" $ do
|
it "an unusable config path yields Left without throwing" $ do
|
||||||
withPipeName "chusql-no-such-server" $ do
|
opened <- localStorageLink (Just "no/such/dir/chusql-core-storage.toml")
|
||||||
|
case opened of
|
||||||
|
Left err -> err `shouldSatisfy` (not . null)
|
||||||
|
Right _ -> expectationFailure "opening a missing config file should fail"
|
||||||
|
|
||||||
|
it "without a link the storage layer reports Left instead of throwing" $ do
|
||||||
|
closeConnection
|
||||||
result <- runIPCStorage (scan "users")
|
result <- runIPCStorage (scan "users")
|
||||||
result `shouldSatisfy` isLeft
|
result `shouldSatisfy` isLeft
|
||||||
#if !defined(mingw32_HOST_OS)
|
it "INSERT then SELECT WHERE id = k" $ do
|
||||||
it "an over-long socket path is reported instead of a raw connect failure" $ do
|
withTestSession $ \srv -> withServerEnv srv $ 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
|
|
||||||
_ <-
|
_ <-
|
||||||
runIPCStorage
|
runIPCStorage
|
||||||
( runStatementM
|
( runStatementM
|
||||||
@@ -1567,13 +1570,11 @@ main = hspec $ do
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
result `shouldBe` Right [[("name", VStr "Zoe")]]
|
result `shouldBe` Right [[("name", VStr "Zoe")]]
|
||||||
stopServer srv
|
-- 存储层的日志现在直接打在本进程的输出里,没有单独的 debug.log 可读;
|
||||||
logs <- readFile (dataDir srv </> "debug.log")
|
-- 「走索引而不是全表扫」由下面 CREATE INDEX 那组用例与 Rust 侧 runtime 测试覆盖。
|
||||||
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
|
it "CREATE INDEX makes a query on that column use the index" $ do
|
||||||
withTestServer $ \srv -> withServerEnv srv $ do
|
withTestSession $ \srv -> withServerEnv srv $ do
|
||||||
_ <- runIPCStorage (runStatementM (CreateTable "ix_t" [("id", TInt), ("code", TInt)]))
|
_ <- runIPCStorage (runStatementM (CreateTable "ix_t" [("id", TInt), ("code", TInt)]))
|
||||||
_ <-
|
_ <-
|
||||||
runIPCStorage
|
runIPCStorage
|
||||||
@@ -1599,8 +1600,8 @@ main = hspec $ do
|
|||||||
fmap sortRow (indexRow fresh)
|
fmap sortRow (indexRow fresh)
|
||||||
`shouldBe` Just (sortRow [("id", VInt 3), ("code", VInt 502)])
|
`shouldBe` Just (sortRow [("id", VInt 3), ("code", VInt 502)])
|
||||||
|
|
||||||
it "CREATE INDEX over the pipe accepts duplicates and returns every matching row" $ do
|
it "CREATE INDEX accepts duplicates and returns every matching row" $ do
|
||||||
withTestServer $ \srv -> withServerEnv srv $ do
|
withTestSession $ \srv -> withServerEnv srv $ do
|
||||||
_ <- runIPCStorage (runStatementM (CreateTable "dup_ix" [("id", TInt), ("age", TInt)]))
|
_ <- runIPCStorage (runStatementM (CreateTable "dup_ix" [("id", TInt), ("age", TInt)]))
|
||||||
_ <-
|
_ <-
|
||||||
runIPCStorage
|
runIPCStorage
|
||||||
@@ -1614,8 +1615,8 @@ main = hspec $ do
|
|||||||
Right (IndexRows rows) -> map (lookup "id") rows `shouldBe` [Just (VInt 1), Just (VInt 2)]
|
Right (IndexRows rows) -> map (lookup "id") rows `shouldBe` [Just (VInt 1), Just (VInt 2)]
|
||||||
_ -> expectationFailure "expected an indexed lookup with rows"
|
_ -> expectationFailure "expected an indexed lookup with rows"
|
||||||
|
|
||||||
it "DELETE removes only the matching rows over the pipe" $ do
|
it "DELETE removes only the matching rows" $ do
|
||||||
withTestServer $ \srv -> withServerEnv srv $ do
|
withTestSession $ \srv -> withServerEnv srv $ do
|
||||||
_ <- runIPCStorage (runStatementM (CreateTable "del_t" [("id", TInt), ("name", TStr)]))
|
_ <- runIPCStorage (runStatementM (CreateTable "del_t" [("id", TInt), ("name", TStr)]))
|
||||||
_ <-
|
_ <-
|
||||||
runIPCStorage
|
runIPCStorage
|
||||||
@@ -1632,8 +1633,8 @@ main = hspec $ do
|
|||||||
gone <- runIPCStorage (lookupByColumn "del_t" "id" (VInt 2))
|
gone <- runIPCStorage (lookupByColumn "del_t" "id" (VInt 2))
|
||||||
gone `shouldBe` Right (IndexRows [])
|
gone `shouldBe` Right (IndexRows [])
|
||||||
|
|
||||||
it "multi-row INSERT goes over the pipe in one statement" $ do
|
it "multi-row INSERT goes in one statement" $ do
|
||||||
withTestServer $ \srv -> withServerEnv srv $ do
|
withTestSession $ \srv -> withServerEnv srv $ do
|
||||||
_ <- runIPCStorage (runStatementM (CreateTable "many_t" [("id", TInt)]))
|
_ <- runIPCStorage (runStatementM (CreateTable "many_t" [("id", TInt)]))
|
||||||
_ <-
|
_ <-
|
||||||
runIPCStorage
|
runIPCStorage
|
||||||
@@ -1676,7 +1677,7 @@ main = hspec $ do
|
|||||||
parseStatement "CREATE TABLE t (id)"
|
parseStatement "CREATE TABLE t (id)"
|
||||||
`shouldSatisfy` isLeft
|
`shouldSatisfy` isLeft
|
||||||
|
|
||||||
describe "ChuSQL.Engine (CREATE TABLE)" $ do
|
describe "ChuSQL.Core.Engine (CREATE TABLE)" $ do
|
||||||
it "creates a table in memory" $ do
|
it "creates a table in memory" $ do
|
||||||
case runStatement testDB (CreateTable "newt" [("id", TInt), ("name", TStr)]) of
|
case runStatement testDB (CreateTable "newt" [("id", TInt), ("name", TStr)]) of
|
||||||
Left err -> expectationFailure err
|
Left err -> expectationFailure err
|
||||||
@@ -1710,8 +1711,10 @@ main = hspec $ do
|
|||||||
runStatement testDB (DropTable "nope")
|
runStatement testDB (DropTable "nope")
|
||||||
`shouldSatisfy` isLeft
|
`shouldSatisfy` isLeft
|
||||||
|
|
||||||
describe "ChuSQL.Engine (NULL)" $ do
|
describe "ChuSQL.Core.Engine (NULL)" $ do
|
||||||
|
-- rows:在给定库上跑 SQL 取结果行
|
||||||
let rows db input = parseStatement input >>= rowsOf . runStatement db
|
let rows db input = parseStatement input >>= rowsOf . runStatement db
|
||||||
|
-- vals:只取结果里的值
|
||||||
vals db input = fmap (map (map snd)) (rows db input)
|
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)
|
seeded = either (const testDB) fst (parseStatement "INSERT INTO users (id, name) VALUES (9, 'Nine')" >>= runStatement testDB)
|
||||||
|
|
||||||
@@ -1743,8 +1746,10 @@ main = hspec $ do
|
|||||||
vals seeded "SELECT age + 1 FROM users WHERE name = 'Nine'" `shouldBe` Right [[VNull]]
|
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]]
|
vals seeded "SELECT age * 2 FROM users WHERE name = 'Nine'" `shouldBe` Right [[VNull]]
|
||||||
|
|
||||||
describe "ChuSQL.Engine (TYPES)" $ do
|
describe "ChuSQL.Core.Engine (TYPES)" $ do
|
||||||
|
-- vals:在给定库上跑 SQL 只取值
|
||||||
let vals db input = fmap (map (map snd)) (parseStatement input >>= rowsOf . runStatement db)
|
let vals db input = fmap (map (map snd)) (parseStatement input >>= rowsOf . runStatement db)
|
||||||
|
-- withTable:先建表,返回新库
|
||||||
withTable ddl = either (const testDB) fst (parseStatement ddl >>= runStatement testDB)
|
withTable ddl = either (const testDB) fst (parseStatement ddl >>= runStatement testDB)
|
||||||
|
|
||||||
it "parses every column type" $ do
|
it "parses every column type" $ do
|
||||||
@@ -1788,9 +1793,12 @@ main = hspec $ do
|
|||||||
runStatement db (Insert "sized" ["d"] [[LitInt 5]])
|
runStatement db (Insert "sized" ["d"] [[LitInt 5]])
|
||||||
`shouldSatisfy` isLeft
|
`shouldSatisfy` isLeft
|
||||||
|
|
||||||
describe "ChuSQL.Engine (CONSTRAINTS)" $ do
|
describe "ChuSQL.Core.Engine (CONSTRAINTS)" $ do
|
||||||
|
-- vals:在给定库上跑 SQL 只取值
|
||||||
let vals db input = fmap (map (map snd)) (parseStatement input >>= rowsOf . runStatement db)
|
let vals db input = fmap (map (map snd)) (parseStatement input >>= rowsOf . runStatement db)
|
||||||
|
-- withTable:先建表,返回新库
|
||||||
withTable ddl = either (const testDB) fst (parseStatement ddl >>= runStatement testDB)
|
withTable ddl = either (const testDB) fst (parseStatement ddl >>= runStatement testDB)
|
||||||
|
-- step:执行一条语句,失败就返回原库
|
||||||
step db input = either (const db) fst (parseStatement input >>= runStatement db)
|
step db input = either (const db) fst (parseStatement input >>= runStatement db)
|
||||||
|
|
||||||
it "rejects omitting a NOT NULL column without a default" $ do
|
it "rejects omitting a NOT NULL column without a default" $ do
|
||||||
@@ -1819,10 +1827,14 @@ main = hspec $ do
|
|||||||
`shouldSatisfy` either (isInfixOf "CHECK") (const False)
|
`shouldSatisfy` either (isInfixOf "CHECK") (const False)
|
||||||
vals (step db "INSERT INTO ck (age) VALUES (5)") "SELECT age FROM ck" `shouldBe` Right [[VInt 5]]
|
vals (step db "INSERT INTO ck (age) VALUES (5)") "SELECT age FROM ck" `shouldBe` Right [[VInt 5]]
|
||||||
|
|
||||||
describe "ChuSQL.Engine (ALTER TABLE)" $ do
|
describe "ChuSQL.Core.Engine (ALTER TABLE)" $ do
|
||||||
|
-- vals:在给定库上跑 SQL 只取值
|
||||||
let vals db input = fmap (map (map snd)) (parseStatement input >>= rowsOf . runStatement db)
|
let vals db input = fmap (map (map snd)) (parseStatement input >>= rowsOf . runStatement db)
|
||||||
|
-- run:在给定库上执行语句
|
||||||
run db input = parseStatement input >>= runStatement db
|
run db input = parseStatement input >>= runStatement db
|
||||||
|
-- withTable:先建表,返回新库
|
||||||
withTable ddl = either (const testDB) fst (parseStatement ddl >>= runStatement testDB)
|
withTable ddl = either (const testDB) fst (parseStatement ddl >>= runStatement testDB)
|
||||||
|
-- step:执行一条语句,失败就返回原库
|
||||||
step db input = either (const db) fst (run db input)
|
step db input = either (const db) fst (run db input)
|
||||||
|
|
||||||
it "ADD COLUMN fills existing rows with the default or NULL" $ do
|
it "ADD COLUMN fills existing rows with the default or NULL" $ do
|
||||||
@@ -1865,77 +1877,18 @@ main = hspec $ do
|
|||||||
run nulls "ALTER TABLE nl ALTER COLUMN tag DROP NOT NULL" `shouldSatisfy` isRight
|
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
|
run strict "ALTER TABLE nl2 ALTER COLUMN tag DROP NOT NULL" `shouldSatisfy` isRight
|
||||||
|
|
||||||
|
-- | 每个测试表都清空行后的库
|
||||||
emptyDB :: Database
|
emptyDB :: Database
|
||||||
emptyDB = [(n, t{tableRows = []}) | (n, t) <- testDB]
|
emptyDB = [(n, t{tableRows = []}) | (n, t) <- testDB]
|
||||||
|
|
||||||
data IPCServer = IPCServer
|
-- | 一次进程内存储会话:进程内直连存储动态库
|
||||||
{ pipeName :: String
|
data StorageSession = StorageSession
|
||||||
, dataDir :: FilePath
|
{ dataDir :: FilePath
|
||||||
, processHandle :: ProcessHandle
|
|
||||||
}
|
}
|
||||||
|
|
||||||
locateServer :: IO (Either String FilePath)
|
-- | 写一份只属于这次会话的配置:数据目录与日志级别都从文件走
|
||||||
locateServer = do
|
writeSessionConfig :: FilePath -> IO FilePath
|
||||||
mdir <- firstDir [".." </> "chusql-storage", "chusql-storage"]
|
writeSessionConfig dir = do
|
||||||
case mdir of
|
|
||||||
Nothing -> pure (Left "chusql-storage directory not found (run these tests inside the repository)")
|
|
||||||
Just dir -> do
|
|
||||||
built <-
|
|
||||||
try (createProcess (proc "cargo" ["build", "--bin", "chusql-storage"]){cwd = Just dir, std_out = NoStream, std_err = NoStream}) ::
|
|
||||||
IO (Either IOException (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle))
|
|
||||||
case built of
|
|
||||||
Left e -> pure (Left ("cannot run cargo (Rust is required for the IPC tests): " ++ show e))
|
|
||||||
Right (_, _, _, ph) -> do
|
|
||||||
ec <- waitForProcess ph
|
|
||||||
case ec of
|
|
||||||
ExitFailure n -> pure (Left ("cargo build --bin chusql-storage failed with exit code " ++ show n))
|
|
||||||
ExitSuccess -> do
|
|
||||||
-- 二进制名跟平台走: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
|
|
||||||
|
|
||||||
startServer :: FilePath -> IO IPCServer
|
|
||||||
startServer bin = do
|
|
||||||
stamp <- uniqueStamp
|
|
||||||
tmp <- getTemporaryDirectory
|
|
||||||
let dir = tmp </> "chusql-hs-IPC" </> stamp
|
|
||||||
createDirectoryIfMissing True dir
|
|
||||||
startServerAt bin dir
|
|
||||||
|
|
||||||
startServerAt :: FilePath -> FilePath -> IO IPCServer
|
|
||||||
startServerAt bin dir = do
|
|
||||||
stamp <- uniqueStamp
|
|
||||||
let name = "chusql-hs-test-" ++ stamp
|
|
||||||
cfgPath <- writeServerConfig dir name
|
|
||||||
(_, _, _, ph) <- withFile (dir </> "debug.log") WriteMode $ \logHandle ->
|
|
||||||
createProcess
|
|
||||||
(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"
|
let cfgPath = dir ++ ".toml"
|
||||||
-- TOML 的普通字符串会吃反斜杠,路径统一用正斜杠
|
-- TOML 的普通字符串会吃反斜杠,路径统一用正斜杠
|
||||||
slashed = map (\c -> if c == '\\' then '/' else c) dir
|
slashed = map (\c -> if c == '\\' then '/' else c) dir
|
||||||
@@ -1944,14 +1897,27 @@ writeServerConfig dir name = do
|
|||||||
( unlines
|
( unlines
|
||||||
[ "[storage]"
|
[ "[storage]"
|
||||||
, "data_dir = \"" ++ slashed ++ "\""
|
, "data_dir = \"" ++ slashed ++ "\""
|
||||||
, "[server]"
|
|
||||||
, "pipe_name = \"" ++ name ++ "\""
|
|
||||||
, "[log]"
|
, "[log]"
|
||||||
, "level = \"debug\""
|
, "level = \"debug\""
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
pure cfgPath
|
pure cfgPath
|
||||||
|
|
||||||
|
-- | 打开一份存储,并把它装成引擎当前用的链路
|
||||||
|
openSessionAt :: FilePath -> IO (Either String StorageSession)
|
||||||
|
openSessionAt dir = do
|
||||||
|
cfgPath <- writeSessionConfig dir
|
||||||
|
opened <- localStorageLink (Just cfgPath)
|
||||||
|
case opened of
|
||||||
|
Left e -> pure (Left ("cannot open the chusql_core_storage library: " ++ e))
|
||||||
|
Right link -> do
|
||||||
|
setStorageLink link
|
||||||
|
pure (Right (StorageSession dir))
|
||||||
|
|
||||||
|
-- | 用同一份数据目录再开一次(模拟重启)
|
||||||
|
openSessionIn :: FilePath -> IO ()
|
||||||
|
openSessionIn dir = openSessionAt dir >>= either fail (const (pure ()))
|
||||||
|
|
||||||
-- | 每次调用都不同的后缀:CPU 时间加唯一编号
|
-- | 每次调用都不同的后缀:CPU 时间加唯一编号
|
||||||
uniqueStamp :: IO String
|
uniqueStamp :: IO String
|
||||||
uniqueStamp = do
|
uniqueStamp = do
|
||||||
@@ -1959,50 +1925,52 @@ uniqueStamp = do
|
|||||||
u <- hashUnique <$> newUnique
|
u <- hashUnique <$> newUnique
|
||||||
pure (show cpu ++ "-" ++ show u)
|
pure (show cpu ++ "-" ++ show u)
|
||||||
|
|
||||||
waitUntilReady :: IPCServer -> Int -> IO ()
|
-- | 删掉会话的数据目录与配置文件
|
||||||
waitUntilReady srv tries = do
|
cleanSessionDir :: FilePath -> IO ()
|
||||||
probe <- try (withServerEnv srv (sendRequest ReqPing)) :: IO (Either IOException Response)
|
cleanSessionDir dir = do
|
||||||
case probe of
|
_ <- try (removePathForcibly dir) :: IO (Either IOException ())
|
||||||
Right RespPong -> pure ()
|
_ <- try (removePathForcibly (dir ++ ".toml")) :: IO (Either IOException ())
|
||||||
_ | tries > 0 -> threadDelay 50000 >> waitUntilReady srv (tries - 1)
|
|
||||||
Right _ -> ioError (userError "server is up but the ping answer was not pong")
|
|
||||||
Left e -> ioError e
|
|
||||||
|
|
||||||
stopServer :: IPCServer -> IO ()
|
|
||||||
stopServer srv = do
|
|
||||||
_ <- try (terminateProcess (processHandle srv)) :: IO (Either IOException ())
|
|
||||||
_ <- try (waitForProcess (processHandle srv)) :: IO (Either IOException ExitCode)
|
|
||||||
pure ()
|
pure ()
|
||||||
|
|
||||||
cleanServerDir :: IPCServer -> IO ()
|
-- | 一个用例一份干净数据目录;动态库打不开则 pending
|
||||||
cleanServerDir srv = do
|
withTestSession :: (StorageSession -> IO ()) -> IO ()
|
||||||
_ <- try (removePathForcibly (dataDir srv)) :: IO (Either IOException ())
|
withTestSession act = do
|
||||||
_ <- try (removePathForcibly (dataDir srv ++ ".toml")) :: IO (Either IOException ())
|
tmp <- getTemporaryDirectory
|
||||||
pure ()
|
stamp <- uniqueStamp
|
||||||
|
let dir = tmp </> "chusql-hs-IPC" </> stamp
|
||||||
|
createDirectoryIfMissing True dir
|
||||||
|
opened <- openSessionAt dir
|
||||||
|
case opened of
|
||||||
|
Left err -> cleanSessionDir dir >> pendingWith err
|
||||||
|
Right session -> act session `finally` (closeConnection >> cleanSessionDir dir)
|
||||||
|
|
||||||
withTestServer :: (IPCServer -> IO ()) -> IO ()
|
-- | 语句不带库名,先建好测试库再切进去
|
||||||
withTestServer act = do
|
withServerEnv :: StorageSession -> IO a -> IO a
|
||||||
located <- locateServer
|
withServerEnv _srv act = do
|
||||||
case located of
|
oldDb <- getDatabaseName
|
||||||
Left err
|
ensureTestDatabase
|
||||||
| "cannot run cargo" `isInfixOf` err -> pendingWith err
|
setDatabaseName (Just testDatabase)
|
||||||
| otherwise -> expectationFailure err
|
act `finally` setDatabaseName oldDb
|
||||||
Right bin -> bracket (startServer bin) (\s -> stopServer s >> cleanServerDir s) act
|
|
||||||
|
|
||||||
withServerEnv :: IPCServer -> IO a -> IO a
|
-- | IPC 用例使用的库名
|
||||||
withServerEnv srv act = do
|
testDatabase :: String
|
||||||
old <- getPipeName
|
testDatabase = "ipctest"
|
||||||
setPipeName (pipeName srv)
|
|
||||||
act `finally` setPipeName old
|
|
||||||
|
|
||||||
withPipeName :: String -> IO a -> IO a
|
-- | 保证测试库已存在
|
||||||
withPipeName name act = do
|
ensureTestDatabase :: IO ()
|
||||||
old <- getPipeName
|
ensureTestDatabase = do
|
||||||
setPipeName name
|
listed <- runIPCStorage listDatabases
|
||||||
r <- act
|
case listed of
|
||||||
setPipeName old
|
Left e -> fail ("cannot list databases through the storage library: " ++ e)
|
||||||
pure r
|
Right names
|
||||||
|
| testDatabase `elem` names -> pure ()
|
||||||
|
| otherwise -> do
|
||||||
|
created <- runIPCStorage (createDatabase testDatabase)
|
||||||
|
case created of
|
||||||
|
Right () -> pure ()
|
||||||
|
Left e -> fail ("cannot create the test database: " ++ e)
|
||||||
|
|
||||||
|
-- | 按列名把一行排序
|
||||||
sortRow :: Row -> Row
|
sortRow :: Row -> Row
|
||||||
sortRow = sortOn fst
|
sortRow = sortOn fst
|
||||||
|
|
||||||
@@ -2011,14 +1979,17 @@ indexRow :: Either String IndexResult -> Maybe Row
|
|||||||
indexRow (Right (IndexRows (r : _))) = Just r
|
indexRow (Right (IndexRows (r : _))) = Just r
|
||||||
indexRow _ = Nothing
|
indexRow _ = Nothing
|
||||||
|
|
||||||
|
-- | 判断 Either 是 Left
|
||||||
isLeft :: Either a b -> Bool
|
isLeft :: Either a b -> Bool
|
||||||
isLeft (Left _) = True
|
isLeft (Left _) = True
|
||||||
isLeft (Right _) = False
|
isLeft (Right _) = False
|
||||||
|
|
||||||
|
-- | 判断 Either 是 Right
|
||||||
isRight :: Either a b -> Bool
|
isRight :: Either a b -> Bool
|
||||||
isRight (Right _) = True
|
isRight (Right _) = True
|
||||||
isRight (Left _) = False
|
isRight (Left _) = False
|
||||||
|
|
||||||
|
-- | 断言优化前后执行结果一致
|
||||||
sameResultAsUnoptimized :: String -> Expectation
|
sameResultAsUnoptimized :: String -> Expectation
|
||||||
sameResultAsUnoptimized sql =
|
sameResultAsUnoptimized sql =
|
||||||
case parseStatement sql >>= prepare testDB of
|
case parseStatement sql >>= prepare testDB of
|
||||||
@@ -2029,6 +2000,7 @@ sameResultAsUnoptimized sql =
|
|||||||
Right relOp ->
|
Right relOp ->
|
||||||
evalRelOp testDB (optimize testDB relOp) `shouldBe` evalRelOp testDB relOp
|
evalRelOp testDB (optimize testDB relOp) `shouldBe` evalRelOp testDB relOp
|
||||||
|
|
||||||
|
-- | 判断谓词是否下推到 Join 左子树
|
||||||
filterPushedIntoLeft :: RelOp -> Bool
|
filterPushedIntoLeft :: RelOp -> Bool
|
||||||
filterPushedIntoLeft (Project _ (Join _ l _ _)) = pushedIntoLeft l
|
filterPushedIntoLeft (Project _ (Join _ l _ _)) = pushedIntoLeft l
|
||||||
filterPushedIntoLeft _ = False
|
filterPushedIntoLeft _ = False
|
||||||
@@ -2040,6 +2012,7 @@ pushedIntoLeft (Range _ _ _ _ _) = True
|
|||||||
pushedIntoLeft (Project _ x) = pushedIntoLeft x
|
pushedIntoLeft (Project _ x) = pushedIntoLeft x
|
||||||
pushedIntoLeft _ = False
|
pushedIntoLeft _ = False
|
||||||
|
|
||||||
|
-- | 解析并优化 SQL,返回计划
|
||||||
optimizedPlan :: String -> Either String RelOp
|
optimizedPlan :: String -> Either String RelOp
|
||||||
optimizedPlan sql = do
|
optimizedPlan sql = do
|
||||||
q <- parseStatement sql
|
q <- parseStatement sql
|
||||||
@@ -2056,6 +2029,7 @@ leftJoinKinds (Sort _ x) = leftJoinKinds x
|
|||||||
leftJoinKinds (Limit _ x) = leftJoinKinds x
|
leftJoinKinds (Limit _ x) = leftJoinKinds x
|
||||||
leftJoinKinds _ = []
|
leftJoinKinds _ = []
|
||||||
|
|
||||||
|
-- | 取计划里第一个过滤条件
|
||||||
firstFilterCond :: RelOp -> Maybe Expr
|
firstFilterCond :: RelOp -> Maybe Expr
|
||||||
firstFilterCond (Filter p _) = Just p
|
firstFilterCond (Filter p _) = Just p
|
||||||
firstFilterCond (Project _ x) = firstFilterCond x
|
firstFilterCond (Project _ x) = firstFilterCond x
|
||||||
@@ -2071,6 +2045,7 @@ firstFilterCond (Scan _ _ _) = Nothing
|
|||||||
firstFilterCond (Lookup _ _ _ _) = Nothing
|
firstFilterCond (Lookup _ _ _ _) = Nothing
|
||||||
firstFilterCond (Range _ _ _ _ _) = Nothing
|
firstFilterCond (Range _ _ _ _ _) = Nothing
|
||||||
|
|
||||||
|
-- | 判断计划里是否还有过滤节点
|
||||||
anyFilter :: RelOp -> Bool
|
anyFilter :: RelOp -> Bool
|
||||||
anyFilter (Filter _ _) = True
|
anyFilter (Filter _ _) = True
|
||||||
anyFilter (Project _ x) = anyFilter x
|
anyFilter (Project _ x) = anyFilter x
|
||||||
@@ -2084,6 +2059,7 @@ anyFilter (Scan _ _ _) = False
|
|||||||
anyFilter (Lookup _ _ _ _) = False
|
anyFilter (Lookup _ _ _ _) = False
|
||||||
anyFilter (Range _ _ _ _ _) = False
|
anyFilter (Range _ _ _ _ _) = False
|
||||||
|
|
||||||
|
-- | 去掉计划里的投影节点
|
||||||
stripProjects :: RelOp -> RelOp
|
stripProjects :: RelOp -> RelOp
|
||||||
stripProjects (Project _ x) = stripProjects x
|
stripProjects (Project _ x) = stripProjects x
|
||||||
stripProjects (Compute items x) = Compute items (stripProjects x)
|
stripProjects (Compute items x) = Compute items (stripProjects x)
|
||||||
@@ -2097,29 +2073,35 @@ stripProjects (Scan a t _) = Scan a t Nothing
|
|||||||
stripProjects (Lookup a t c k) = Lookup a t c k
|
stripProjects (Lookup a t c k) = Lookup a t c k
|
||||||
stripProjects (Range a t c lo hi) = Range a t c lo hi
|
stripProjects (Range a t c lo hi) = Range a t c lo hi
|
||||||
|
|
||||||
|
-- | 解析并做投影下推,返回计划
|
||||||
projectedPlan :: String -> Either String RelOp
|
projectedPlan :: String -> Either String RelOp
|
||||||
projectedPlan sql = do
|
projectedPlan sql = do
|
||||||
q <- parseStatement sql
|
q <- parseStatement sql
|
||||||
plan <- translate q
|
plan <- translate q
|
||||||
Right (pushProject testDB ["*"] plan)
|
Right (pushProject testDB ["*"] plan)
|
||||||
|
|
||||||
|
-- | 取节点输出的列名
|
||||||
sideCols :: RelOp -> Maybe [String]
|
sideCols :: RelOp -> Maybe [String]
|
||||||
sideCols (Project cols _) = Just cols
|
sideCols (Project cols _) = Just cols
|
||||||
sideCols (Scan a _ cols) = fmap (map (qualify a)) cols
|
sideCols (Scan a _ cols) = fmap (map (qualify a)) cols
|
||||||
sideCols _ = Nothing
|
sideCols _ = Nothing
|
||||||
|
|
||||||
|
-- | 取 Join 左右两侧的输出列名
|
||||||
joinSideCols :: RelOp -> (Maybe [String], Maybe [String])
|
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)
|
joinSideCols _ = (Nothing, Nothing)
|
||||||
|
|
||||||
|
-- | 数左子树里过滤节点的个数
|
||||||
countFilters :: RelOp -> Int
|
countFilters :: RelOp -> Int
|
||||||
countFilters (Filter _ x) = 1 + countFilters x
|
countFilters (Filter _ x) = 1 + countFilters x
|
||||||
countFilters _ = 0
|
countFilters _ = 0
|
||||||
|
|
||||||
|
-- | 取 Join 左侧过滤节点数
|
||||||
leftSideFilters :: RelOp -> Maybe Int
|
leftSideFilters :: RelOp -> Maybe Int
|
||||||
leftSideFilters (Project _ (Join _ l _ _)) = Just (countFilters l)
|
leftSideFilters (Project _ (Join _ l _ _)) = Just (countFilters l)
|
||||||
leftSideFilters _ = Nothing
|
leftSideFilters _ = Nothing
|
||||||
|
|
||||||
|
-- | 去掉外层投影,返回真正的根节点
|
||||||
planRoot :: RelOp -> RelOp
|
planRoot :: RelOp -> RelOp
|
||||||
planRoot (Project _ x) = planRoot x
|
planRoot (Project _ x) = planRoot x
|
||||||
planRoot x = x
|
planRoot x = x
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
cabal-version: 2.4
|
||||||
|
|
||||||
|
name: chusql-core-model
|
||||||
|
version: 1.0.0
|
||||||
|
synopsis: ChuSQL 共用数据模型与线上协议类型
|
||||||
|
description: ChuSQL 各进程共用的纯数据类型:列与值的模型、存储层线上协议
|
||||||
|
(请求/响应/表结构与账号)以及查询结果集。这里不放任何执行
|
||||||
|
逻辑,前端只依赖这个包就不会把引擎链进自己的二进制。
|
||||||
|
build-type: Simple
|
||||||
|
|
||||||
|
library
|
||||||
|
hs-source-dirs: src
|
||||||
|
exposed-modules: ChuSQL.Core.Model
|
||||||
|
ChuSQL.Core.Protocol
|
||||||
|
build-depends: base >= 4.14 && < 5
|
||||||
|
, aeson
|
||||||
|
, hashable
|
||||||
|
, scientific
|
||||||
|
, text
|
||||||
|
default-language: Haskell2010
|
||||||
|
ghc-options: -Wall
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{-# LANGUAGE PatternSynonyms #-}
|
{-# LANGUAGE PatternSynonyms #-}
|
||||||
|
|
||||||
module ChuSQL.Model
|
module ChuSQL.Core.Model
|
||||||
( ColumnType (..)
|
( ColumnType (..)
|
||||||
, Column (..)
|
, Column (..)
|
||||||
, pattern TInt
|
, pattern TInt
|
||||||
@@ -35,7 +35,7 @@ import Data.Hashable (Hashable (..))
|
|||||||
import Data.List (isSuffixOf, stripPrefix)
|
import Data.List (isSuffixOf, stripPrefix)
|
||||||
import Data.Maybe (fromMaybe)
|
import Data.Maybe (fromMaybe)
|
||||||
|
|
||||||
-- 数据模型:列类型、列约束、值、行、表、数据库,外加几个查表小工具。
|
-- 数据模型:列类型、列约束、值、行、表、数据库,外加查表小工具。
|
||||||
|
|
||||||
-- | 列的基础类型
|
-- | 列的基础类型
|
||||||
data ColumnType
|
data ColumnType
|
||||||
@@ -70,16 +70,19 @@ data Column = Column
|
|||||||
plainColumn :: ColumnType -> Column
|
plainColumn :: ColumnType -> Column
|
||||||
plainColumn t = Column t True Nothing False False False Nothing
|
plainColumn t = Column t True Nothing False False False Nothing
|
||||||
|
|
||||||
|
-- | 普通整型列的匹配模式
|
||||||
pattern TInt :: Column
|
pattern TInt :: Column
|
||||||
pattern TInt <- Column CInt _ _ _ _ _ _
|
pattern TInt <- Column CInt _ _ _ _ _ _
|
||||||
where
|
where
|
||||||
TInt = plainColumn CInt
|
TInt = plainColumn CInt
|
||||||
|
|
||||||
|
-- | 普通字符串列的匹配模式
|
||||||
pattern TStr :: Column
|
pattern TStr :: Column
|
||||||
pattern TStr <- Column CStr _ _ _ _ _ _
|
pattern TStr <- Column CStr _ _ _ _ _ _
|
||||||
where
|
where
|
||||||
TStr = plainColumn CStr
|
TStr = plainColumn CStr
|
||||||
|
|
||||||
|
-- | 普通布尔列的匹配模式
|
||||||
pattern TBool :: Column
|
pattern TBool :: Column
|
||||||
pattern TBool <- Column CBool _ _ _ _ _ _
|
pattern TBool <- Column CBool _ _ _ _ _ _
|
||||||
where
|
where
|
||||||
@@ -146,8 +149,11 @@ parseColumnType raw = case (base, args) of
|
|||||||
("blob", []) -> Just CBlob
|
("blob", []) -> Just CBlob
|
||||||
_ -> Nothing
|
_ -> Nothing
|
||||||
where
|
where
|
||||||
|
-- | 去掉空白的类型名
|
||||||
cleaned = map toLower (filter (not . isSpace) raw)
|
cleaned = map toLower (filter (not . isSpace) raw)
|
||||||
|
-- | 基础名与参数段
|
||||||
(base, rest) = span (/= '(') cleaned
|
(base, rest) = span (/= '(') cleaned
|
||||||
|
-- | 括号里的参数表
|
||||||
args = case stripSuffixMaybe ")" (drop 1 rest) of
|
args = case stripSuffixMaybe ")" (drop 1 rest) of
|
||||||
Nothing -> []
|
Nothing -> []
|
||||||
Just inner -> map (filter (not . isSpace)) (splitOn ',' inner)
|
Just inner -> map (filter (not . isSpace)) (splitOn ',' inner)
|
||||||
@@ -193,6 +199,7 @@ data TypeClass
|
|||||||
| BooleanClass
|
| BooleanClass
|
||||||
deriving (Show, Eq)
|
deriving (Show, Eq)
|
||||||
|
|
||||||
|
-- | 整型大类判断
|
||||||
typeClassOf :: ColumnType -> TypeClass
|
typeClassOf :: ColumnType -> TypeClass
|
||||||
typeClassOf CInt = NumericClass
|
typeClassOf CInt = NumericClass
|
||||||
typeClassOf CBigInt = NumericClass
|
typeClassOf CBigInt = NumericClass
|
||||||
@@ -216,12 +223,14 @@ assignable target source
|
|||||||
| typeClassOf target == TemporalClass && typeClassOf source == TextClass = True
|
| typeClassOf target == TemporalClass && typeClassOf source == TextClass = True
|
||||||
| otherwise = False
|
| otherwise = False
|
||||||
|
|
||||||
|
-- | 是不是整数类型
|
||||||
integerType :: ColumnType -> Bool
|
integerType :: ColumnType -> Bool
|
||||||
integerType CInt = True
|
integerType CInt = True
|
||||||
integerType CBigInt = True
|
integerType CBigInt = True
|
||||||
integerType CSmallInt = True
|
integerType CSmallInt = True
|
||||||
integerType _ = False
|
integerType _ = False
|
||||||
|
|
||||||
|
-- | 是不是数值类型
|
||||||
numericType :: ColumnType -> Bool
|
numericType :: ColumnType -> Bool
|
||||||
numericType t = typeClassOf t == NumericClass
|
numericType t = typeClassOf t == NumericClass
|
||||||
|
|
||||||
@@ -264,6 +273,7 @@ coerceValue t v = case t of
|
|||||||
CTimestamp -> toTimestamp v
|
CTimestamp -> toTimestamp v
|
||||||
CBlob -> toText t v
|
CBlob -> toText t v
|
||||||
|
|
||||||
|
-- | 整数收进整型列
|
||||||
toInt :: ColumnType -> Value -> Either String Value
|
toInt :: ColumnType -> Value -> Either String Value
|
||||||
toInt _ (VInt n) = Right (VInt n)
|
toInt _ (VInt n) = Right (VInt n)
|
||||||
toInt t (VFloat d) = case properFraction d :: (Int, Double) of
|
toInt t (VFloat d) = case properFraction d :: (Int, Double) of
|
||||||
@@ -272,6 +282,7 @@ toInt t (VFloat d) = case properFraction d :: (Int, Double) of
|
|||||||
| otherwise -> Left ("cannot put a fractional number into " ++ typeLabel t)
|
| otherwise -> Left ("cannot put a fractional number into " ++ typeLabel t)
|
||||||
toInt t _ = Left ("cannot put this value into " ++ typeLabel t)
|
toInt t _ = Left ("cannot put this value into " ++ typeLabel t)
|
||||||
|
|
||||||
|
-- | 数值收进浮点列
|
||||||
toFloat :: ColumnType -> Value -> Either String Value
|
toFloat :: ColumnType -> Value -> Either String Value
|
||||||
toFloat _ (VInt n) = Right (VFloat (fromIntegral n))
|
toFloat _ (VInt n) = Right (VFloat (fromIntegral n))
|
||||||
toFloat _ (VFloat d) = Right (VFloat d)
|
toFloat _ (VFloat d) = Right (VFloat d)
|
||||||
@@ -284,29 +295,36 @@ toDecimal p s v = case v of
|
|||||||
VFloat d -> rounded d
|
VFloat d -> rounded d
|
||||||
_ -> Left ("cannot put this value into " ++ typeLabel (CDecimal p s))
|
_ -> Left ("cannot put this value into " ++ typeLabel (CDecimal p s))
|
||||||
where
|
where
|
||||||
|
-- | 缩放因子
|
||||||
scale = (10 :: Double) ^ max 0 s
|
scale = (10 :: Double) ^ max 0 s
|
||||||
|
-- | 按比例四舍五入
|
||||||
rounded d = Right (VFloat (fromIntegral (round (d * scale) :: Integer) / scale))
|
rounded d = Right (VFloat (fromIntegral (round (d * scale) :: Integer) / scale))
|
||||||
|
|
||||||
|
-- | 字符串收进文本列
|
||||||
toText :: ColumnType -> Value -> Either String Value
|
toText :: ColumnType -> Value -> Either String Value
|
||||||
toText _ (VStr s) = Right (VStr s)
|
toText _ (VStr s) = Right (VStr s)
|
||||||
toText t _ = Left ("cannot put this value into " ++ typeLabel t)
|
toText t _ = Left ("cannot put this value into " ++ typeLabel t)
|
||||||
|
|
||||||
|
-- | 按列长度限制收字符串
|
||||||
toSized :: Int -> Value -> Either String Value
|
toSized :: Int -> Value -> Either String Value
|
||||||
toSized n (VStr s)
|
toSized n (VStr s)
|
||||||
| length s <= n = Right (VStr s)
|
| length s <= n = Right (VStr s)
|
||||||
| otherwise = Left ("value is longer than " ++ show n ++ " characters")
|
| otherwise = Left ("value is longer than " ++ show n ++ " characters")
|
||||||
toSized _ _ = Left "cannot put this value into a character column"
|
toSized _ _ = Left "cannot put this value into a character column"
|
||||||
|
|
||||||
|
-- | 布尔收进布尔列
|
||||||
toBool :: ColumnType -> Value -> Either String Value
|
toBool :: ColumnType -> Value -> Either String Value
|
||||||
toBool _ (VBool b) = Right (VBool b)
|
toBool _ (VBool b) = Right (VBool b)
|
||||||
toBool t _ = Left ("cannot put this value into " ++ typeLabel t)
|
toBool t _ = Left ("cannot put this value into " ++ typeLabel t)
|
||||||
|
|
||||||
|
-- | 字符串收进日期列
|
||||||
toDate :: Value -> Either String Value
|
toDate :: Value -> Either String Value
|
||||||
toDate (VStr s)
|
toDate (VStr s)
|
||||||
| validDateText s = Right (VStr s)
|
| validDateText s = Right (VStr s)
|
||||||
| otherwise = Left "date must look like YYYY-MM-DD"
|
| otherwise = Left "date must look like YYYY-MM-DD"
|
||||||
toDate _ = Left "date must be a string"
|
toDate _ = Left "date must be a string"
|
||||||
|
|
||||||
|
-- | 字符串收进时间戳列
|
||||||
toTimestamp :: Value -> Either String Value
|
toTimestamp :: Value -> Either String Value
|
||||||
toTimestamp (VStr s)
|
toTimestamp (VStr s)
|
||||||
| validTimestampText s = Right (VStr s)
|
| validTimestampText s = Right (VStr s)
|
||||||
@@ -319,7 +337,7 @@ validDateText s = case splitOn '-' s of
|
|||||||
[y, m, d] -> allOf 4 y && allOf 2 m && allOf 2 d
|
[y, m, d] -> allOf 4 y && allOf 2 m && allOf 2 d
|
||||||
_ -> False
|
_ -> False
|
||||||
|
|
||||||
-- | YYYY-MM-DD HH:MM:SS,日期与时间之间允许空格或 T
|
-- | 日期时间格式;中间允许空格或 T
|
||||||
validTimestampText :: String -> Bool
|
validTimestampText :: String -> Bool
|
||||||
validTimestampText s = case break (\c -> c == ' ' || c == 'T') s of
|
validTimestampText s = case break (\c -> c == ' ' || c == 'T') s of
|
||||||
(d, _ : tm) -> validDateText d && validTimeText tm
|
(d, _ : tm) -> validDateText d && validTimeText tm
|
||||||
@@ -331,10 +349,12 @@ validTimeText t = case splitOn ':' t of
|
|||||||
[h, m, sec] -> allOf 2 h && allOf 2 m && allOf 2 sec
|
[h, m, sec] -> allOf 2 h && allOf 2 m && allOf 2 sec
|
||||||
_ -> False
|
_ -> False
|
||||||
|
|
||||||
|
-- | 一段字符串是不是固定长度的数字
|
||||||
allOf :: Int -> String -> Bool
|
allOf :: Int -> String -> Bool
|
||||||
allOf n s = length s == n && all isDigit s
|
allOf n s = length s == n && all isDigit s
|
||||||
|
|
||||||
|
|
||||||
|
-- | 引擎里的一行值
|
||||||
data Value
|
data Value
|
||||||
= VNull
|
= VNull
|
||||||
| VInt Int
|
| VInt Int
|
||||||
@@ -343,6 +363,7 @@ data Value
|
|||||||
| VBool Bool
|
| VBool Bool
|
||||||
deriving (Show, Eq)
|
deriving (Show, Eq)
|
||||||
|
|
||||||
|
-- | 值按类型打散搅拌
|
||||||
instance Hashable Value where
|
instance Hashable Value where
|
||||||
hashWithSalt s VNull = hashWithSalt (hashWithSalt s (0 :: Int)) ()
|
hashWithSalt s VNull = hashWithSalt (hashWithSalt s (0 :: Int)) ()
|
||||||
hashWithSalt s (VInt n) = hashWithSalt (hashWithSalt s (1 :: Int)) n
|
hashWithSalt s (VInt n) = hashWithSalt (hashWithSalt s (1 :: Int)) n
|
||||||
@@ -350,8 +371,10 @@ instance Hashable Value where
|
|||||||
hashWithSalt s (VStr t) = hashWithSalt (hashWithSalt s (3 :: Int)) t
|
hashWithSalt s (VStr t) = hashWithSalt (hashWithSalt s (3 :: Int)) t
|
||||||
hashWithSalt s (VBool b) = hashWithSalt (hashWithSalt s (4 :: Int)) b
|
hashWithSalt s (VBool b) = hashWithSalt (hashWithSalt s (4 :: Int)) b
|
||||||
|
|
||||||
|
-- | 一行数据
|
||||||
type Row = [(String, Value)]
|
type Row = [(String, Value)]
|
||||||
|
|
||||||
|
-- | 一张表:名字、列定义与全部行
|
||||||
data Table = Table
|
data Table = Table
|
||||||
{ tableName :: String
|
{ tableName :: String
|
||||||
, tableCols :: [(String, Column)]
|
, tableCols :: [(String, Column)]
|
||||||
@@ -359,6 +382,7 @@ data Table = Table
|
|||||||
}
|
}
|
||||||
deriving (Show)
|
deriving (Show)
|
||||||
|
|
||||||
|
-- | 库:库名到表的映射
|
||||||
type Database = [(String, Table)]
|
type Database = [(String, Table)]
|
||||||
|
|
||||||
-- | 取表的列名
|
-- | 取表的列名
|
||||||
@@ -373,6 +397,7 @@ colType t c = lookup c (tableCols t)
|
|||||||
lookupTable :: Database -> String -> Either String Table
|
lookupTable :: Database -> String -> Either String Table
|
||||||
lookupTable db t = maybe (Left ("unknown table: " ++ t)) Right (lookup t db)
|
lookupTable db t = maybe (Left ("unknown table: " ++ t)) Right (lookup t db)
|
||||||
|
|
||||||
|
-- | 通配列名
|
||||||
allColumns :: String
|
allColumns :: String
|
||||||
allColumns = "*"
|
allColumns = "*"
|
||||||
|
|
||||||
@@ -393,4 +418,5 @@ resolveColumn name env = case [(k, v) | (k, v) <- env, matches k] of
|
|||||||
[] -> Left ("unknown column: " ++ name)
|
[] -> Left ("unknown column: " ++ name)
|
||||||
_ -> Left ("ambiguous column: " ++ name)
|
_ -> Left ("ambiguous column: " ++ name)
|
||||||
where
|
where
|
||||||
|
-- | 裸名或带后缀的名称
|
||||||
matches k = k == name || ('.' : name) `isSuffixOf` k
|
matches k = k == name || ('.' : name) `isSuffixOf` k
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
|
module ChuSQL.Core.Protocol (
|
||||||
|
SchemaColumn (..),
|
||||||
|
Request (..),
|
||||||
|
Response (..),
|
||||||
|
TableInfo (..),
|
||||||
|
Account (..),
|
||||||
|
QueryResult (..),
|
||||||
|
valueToJSON,
|
||||||
|
valueFromJSON,
|
||||||
|
rowToJSON,
|
||||||
|
rowFromJSON,
|
||||||
|
queryResultJson,
|
||||||
|
) where
|
||||||
|
|
||||||
|
import ChuSQL.Core.Model (Row, Value (..))
|
||||||
|
import Data.Aeson (FromJSON (..), ToJSON (..), object, withObject, (.:), (.:?), (.!=), (.=))
|
||||||
|
import qualified Data.Aeson as A
|
||||||
|
import qualified Data.Aeson.Key as K
|
||||||
|
import qualified Data.Aeson.KeyMap as KM
|
||||||
|
import Data.Aeson.Types (Parser)
|
||||||
|
import Data.Scientific (floatingOrInteger, fromFloatDigits)
|
||||||
|
import qualified Data.Text as T
|
||||||
|
import Data.Text (Text)
|
||||||
|
|
||||||
|
-- 线上协议类型:存储层请求/响应与查询结果集。
|
||||||
|
|
||||||
|
-- | 数据字典里一列的线上形状
|
||||||
|
data SchemaColumn = SchemaColumn
|
||||||
|
{ scName :: String
|
||||||
|
, scType :: String
|
||||||
|
, scNullable :: Bool
|
||||||
|
, scDefault :: Maybe Value
|
||||||
|
, scAutoIncrement :: Bool
|
||||||
|
, scPrimaryKey :: Bool
|
||||||
|
, scUnique :: Bool
|
||||||
|
, scCheck :: Maybe String
|
||||||
|
}
|
||||||
|
deriving (Show, Eq)
|
||||||
|
|
||||||
|
-- | 列编码成 JSON
|
||||||
|
instance ToJSON SchemaColumn where
|
||||||
|
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
|
||||||
|
]
|
||||||
|
|
||||||
|
-- | JSON 解回列
|
||||||
|
instance FromJSON SchemaColumn where
|
||||||
|
parseJSON = withObject "SchemaColumn" $ \o -> do
|
||||||
|
n <- o .: "name"
|
||||||
|
t <- o .: "ty"
|
||||||
|
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 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
|
||||||
|
|
||||||
|
-- | 请求编码成 JSON
|
||||||
|
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)
|
||||||
|
, "table" .= t
|
||||||
|
, "row" .= rowToJSON r
|
||||||
|
]
|
||||||
|
toJSON (ReqInsertBatch t rs) =
|
||||||
|
object
|
||||||
|
[ "method" .= ("insert_batch" :: T.Text)
|
||||||
|
, "table" .= t
|
||||||
|
, "rows" .= map rowToJSON rs
|
||||||
|
]
|
||||||
|
toJSON (ReqDeleteKeys t ks) =
|
||||||
|
object
|
||||||
|
[ "method" .= ("delete_keys" :: T.Text)
|
||||||
|
, "table" .= t
|
||||||
|
, "keys" .= ks
|
||||||
|
]
|
||||||
|
toJSON (ReqReplaceAll t rs) =
|
||||||
|
object
|
||||||
|
[ "method" .= ("replace_all" :: T.Text)
|
||||||
|
, "table" .= t
|
||||||
|
, "rows" .= map rowToJSON rs
|
||||||
|
]
|
||||||
|
toJSON ReqListTables = object ["method" .= ("list_tables" :: T.Text)]
|
||||||
|
toJSON (ReqLookupByColumn t c k) =
|
||||||
|
object
|
||||||
|
[ "method" .= ("lookup_by_index" :: T.Text)
|
||||||
|
, "table" .= t
|
||||||
|
, "column" .= c
|
||||||
|
, "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
|
||||||
|
[ "method" .= ("describe_table" :: T.Text)
|
||||||
|
, "table" .= t
|
||||||
|
]
|
||||||
|
toJSON (ReqCreateTable t cols) =
|
||||||
|
object
|
||||||
|
[ "method" .= ("create_table" :: T.Text)
|
||||||
|
, "table" .= t
|
||||||
|
, "columns" .= cols
|
||||||
|
]
|
||||||
|
toJSON (ReqDropTable t) =
|
||||||
|
object ["method" .= ("drop_table" :: T.Text), "table" .= t]
|
||||||
|
toJSON (ReqCreateIndex t c) =
|
||||||
|
object
|
||||||
|
[ "method" .= ("create_index" :: T.Text)
|
||||||
|
, "table" .= t
|
||||||
|
, "column" .= c
|
||||||
|
]
|
||||||
|
toJSON (ReqDropIndex t c) =
|
||||||
|
object
|
||||||
|
[ "method" .= ("drop_index" :: T.Text)
|
||||||
|
, "table" .= t
|
||||||
|
, "column" .= c
|
||||||
|
]
|
||||||
|
toJSON (ReqDropColumn t c) =
|
||||||
|
object
|
||||||
|
[ "method" .= ("drop_column" :: T.Text)
|
||||||
|
, "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
|
||||||
|
{ tiTable :: String
|
||||||
|
, tiColumns :: [SchemaColumn]
|
||||||
|
, tiRows :: Int
|
||||||
|
, tiIndexes :: [String]
|
||||||
|
, tiStats :: [(String, Int, Bool)]
|
||||||
|
}
|
||||||
|
deriving (Show, Eq)
|
||||||
|
|
||||||
|
-- | 表信息编码成 JSON
|
||||||
|
instance ToJSON TableInfo where
|
||||||
|
toJSON info =
|
||||||
|
object
|
||||||
|
[ "table" .= tiTable info
|
||||||
|
, "columns" .= tiColumns info
|
||||||
|
, "row_count" .= tiRows info
|
||||||
|
, "indexes" .= map (\name -> object ["column" .= name]) (tiIndexes info)
|
||||||
|
, "stats" .= map statJson (tiStats info)
|
||||||
|
]
|
||||||
|
where
|
||||||
|
statJson (name, distinct, capped) =
|
||||||
|
object ["name" .= name, "distinct" .= distinct, "capped" .= capped]
|
||||||
|
|
||||||
|
-- | JSON 解回表信息
|
||||||
|
instance FromJSON TableInfo where
|
||||||
|
parseJSON = withObject "TableInfo" $ \o -> do
|
||||||
|
table <- o .: "table"
|
||||||
|
columns <- o .: "columns"
|
||||||
|
count <- o .: "row_count"
|
||||||
|
indexes <- o .:? "indexes" .!= []
|
||||||
|
stats <- o .:? "stats" .!= []
|
||||||
|
indexColumns <- mapM (\v -> withObject "IndexWire" (.: "column") v) (indexes :: [A.Value])
|
||||||
|
parsedStats <- mapM parseStat (stats :: [A.Value])
|
||||||
|
pure (TableInfo table columns count indexColumns parsedStats)
|
||||||
|
where
|
||||||
|
parseStat = withObject "ColumnStat" $ \o -> do
|
||||||
|
name <- o .: "name"
|
||||||
|
distinct <- o .: "distinct"
|
||||||
|
capped <- o .:? "capped" .!= False
|
||||||
|
pure (name, distinct, capped)
|
||||||
|
|
||||||
|
-- | 一条账号记录
|
||||||
|
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)
|
||||||
|
|
||||||
|
-- | JSON 解回账号
|
||||||
|
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"
|
||||||
|
|
||||||
|
-- | 账号编码成 JSON
|
||||||
|
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
|
||||||
|
| RespNoIndex
|
||||||
|
| RespOk
|
||||||
|
| RespError String
|
||||||
|
| RespCatalog [TableInfo]
|
||||||
|
|
||||||
|
-- | JSON 解回响应
|
||||||
|
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
|
||||||
|
"rows" -> RespRows <$> (o .: "rows" >>= mapM rowFromJSON)
|
||||||
|
"tables" -> RespTables <$> o .: "tables"
|
||||||
|
"error" -> RespError <$> o .: "message"
|
||||||
|
"schema" -> RespSchema <$> parseJSON (A.Object o)
|
||||||
|
"catalog" -> do
|
||||||
|
xs <- o .: "schemas" :: Parser [A.Value]
|
||||||
|
RespCatalog <$> mapM parseJSON xs
|
||||||
|
other -> fail ("unknown status: " ++ T.unpack other)
|
||||||
|
|
||||||
|
-- | 值编码成 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 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"
|
||||||
|
|
||||||
|
-- | 一行编码成 JSON
|
||||||
|
rowToJSON :: Row -> A.Value
|
||||||
|
rowToJSON r =
|
||||||
|
A.Object (KM.fromList [(K.fromString k, valueToJSON v) | (k, v) <- r])
|
||||||
|
|
||||||
|
-- | JSON 解回一行
|
||||||
|
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)
|
||||||
|
rowFromJSON _ = fail "row must be a JSON object"
|
||||||
|
|
||||||
|
-- | 一条语句的结果集:列名与排好的行
|
||||||
|
data QueryResult = QueryResult
|
||||||
|
{ qrColumns :: [Text]
|
||||||
|
, qrRows :: [[Value]]
|
||||||
|
, qrRowCount :: Int
|
||||||
|
, qrTruncated :: Bool
|
||||||
|
, qrDatabase :: Maybe Text
|
||||||
|
}
|
||||||
|
deriving (Eq, Show)
|
||||||
|
|
||||||
|
-- | JSON 解回结果集
|
||||||
|
instance FromJSON QueryResult where
|
||||||
|
parseJSON = withObject "QueryResult" $ \o -> do
|
||||||
|
cols <- o .: "columns"
|
||||||
|
rows <- o .: "rows"
|
||||||
|
QueryResult cols <$> mapM (mapM valueFromJSON) rows
|
||||||
|
<*> o .:? "rowCount" .!= 0
|
||||||
|
<*> o .:? "truncated" .!= False
|
||||||
|
<*> o .:? "database"
|
||||||
|
|
||||||
|
-- | 结果还原成 JSON(JSON 输出与 TCP 结果集共用)
|
||||||
|
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
|
||||||
|
]
|
||||||
File renamed without changes.
@@ -0,0 +1,12 @@
|
|||||||
|
# This file was autogenerated by Stack.
|
||||||
|
# You should not edit this file by hand.
|
||||||
|
# For more information, please see the documentation at:
|
||||||
|
# https://docs.haskellstack.org/en/stable/topics/lock_files
|
||||||
|
|
||||||
|
packages: []
|
||||||
|
snapshots:
|
||||||
|
- completed:
|
||||||
|
sha256: 0f728e30e843d7f00460ad56858b995a3b227fca683659c7df9f2fc3988a3ce1
|
||||||
|
size: 732465
|
||||||
|
url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/24/59.yaml
|
||||||
|
original: lts-24.59
|
||||||
+1
-33
@@ -15,22 +15,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chusql-storage"
|
name = "chusql-core-storage"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"interprocess",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"toml",
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "doctest-file"
|
|
||||||
version = "1.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -80,19 +73,6 @@ dependencies = [
|
|||||||
"hashbrown",
|
"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]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
@@ -147,12 +127,6 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "recvmsg"
|
|
||||||
version = "1.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.5"
|
version = "1.1.5"
|
||||||
@@ -289,12 +263,6 @@ version = "1.0.26"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "widestring"
|
|
||||||
version = "1.2.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "chusql-storage"
|
name = "chusql-core-storage"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "chusql_core_storage"
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
interprocess = "2"
|
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
+1
-5
@@ -29,14 +29,10 @@ pool_size = 1024
|
|||||||
# 要固定就写绝对路径,例如:
|
# 要固定就写绝对路径,例如:
|
||||||
# data_dir = "/srv/chusql/data"
|
# data_dir = "/srv/chusql/data"
|
||||||
|
|
||||||
[server]
|
|
||||||
# 命名管道名(Windows 上实际是 \\.\pipe\<这个名字>)。
|
|
||||||
pipe_name = "chusql-joint"
|
|
||||||
|
|
||||||
[log]
|
[log]
|
||||||
# 日志等级:off / error / warn / info / debug。debug 会多打每个请求和连接。
|
# 日志等级:off / error / warn / info / debug。debug 会多打每个请求和连接。
|
||||||
level = "info"
|
level = "info"
|
||||||
|
|
||||||
# 颜色没有配置项也没有环境变量:要写的那条流是终端就着色,重定向到文件就不着色。
|
# 颜色没有配置项也没有环境变量:要写的那条流是终端就着色,重定向到文件就不着色。
|
||||||
# 行格式:[等级 本地日期时间] [来源] 消息;来源 core=服务自身、pipe=由管道请求触发
|
# 行格式:[等级 本地日期时间] [来源] 消息;来源 core=服务自身、request=由存储请求触发
|
||||||
# ERROR 加粗红、WARN 加粗橙、DEBUG 加粗不变色、INFO 不加样式
|
# ERROR 加粗红、WARN 加粗橙、DEBUG 加粗不变色、INFO 不加样式
|
||||||
@@ -44,36 +44,44 @@ pub fn max_order(page_size: usize) -> usize {
|
|||||||
page_size.saturating_sub(HEADER) / (SLOT + max_cell_bytes()) + 1
|
page_size.saturating_sub(HEADER) / (SLOT + max_cell_bytes()) + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 读一个 u8
|
||||||
fn read_u8(p: &Page, off: usize) -> u8 {
|
fn read_u8(p: &Page, off: usize) -> u8 {
|
||||||
p.data[off]
|
p.data[off]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 写一个 u8
|
||||||
fn write_u8(p: &mut Page, off: usize, v: u8) {
|
fn write_u8(p: &mut Page, off: usize, v: u8) {
|
||||||
p.data[off] = v;
|
p.data[off] = v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 读一个小端 u16
|
||||||
fn read_u16(p: &Page, off: usize) -> u16 {
|
fn read_u16(p: &Page, off: usize) -> u16 {
|
||||||
u16::from_le_bytes([p.data[off], p.data[off + 1]])
|
u16::from_le_bytes([p.data[off], p.data[off + 1]])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 写一个小端 u16
|
||||||
fn write_u16(p: &mut Page, off: usize, v: u16) {
|
fn write_u16(p: &mut Page, off: usize, v: u16) {
|
||||||
p.data[off..off + 2].copy_from_slice(&v.to_le_bytes());
|
p.data[off..off + 2].copy_from_slice(&v.to_le_bytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 读一个小端 u32
|
||||||
fn read_u32(p: &Page, off: usize) -> u32 {
|
fn read_u32(p: &Page, off: usize) -> u32 {
|
||||||
u32::from_le_bytes([p.data[off], p.data[off + 1], p.data[off + 2], p.data[off + 3]])
|
u32::from_le_bytes([p.data[off], p.data[off + 1], p.data[off + 2], p.data[off + 3]])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 写一个小端 u32
|
||||||
fn write_u32(p: &mut Page, off: usize, v: u32) {
|
fn write_u32(p: &mut Page, off: usize, v: u32) {
|
||||||
p.data[off..off + 4].copy_from_slice(&v.to_le_bytes());
|
p.data[off..off + 4].copy_from_slice(&v.to_le_bytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 读一个小端 u64
|
||||||
fn read_u64(p: &Page, off: usize) -> u64 {
|
fn read_u64(p: &Page, off: usize) -> u64 {
|
||||||
let mut b = [0u8; 8];
|
let mut b = [0u8; 8];
|
||||||
b.copy_from_slice(&p.data[off..off + 8]);
|
b.copy_from_slice(&p.data[off..off + 8]);
|
||||||
u64::from_le_bytes(b)
|
u64::from_le_bytes(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 写一个小端 u64
|
||||||
fn write_u64(p: &mut Page, off: usize, v: u64) {
|
fn write_u64(p: &mut Page, off: usize, v: u64) {
|
||||||
p.data[off..off + 8].copy_from_slice(&v.to_le_bytes());
|
p.data[off..off + 8].copy_from_slice(&v.to_le_bytes());
|
||||||
}
|
}
|
||||||
@@ -269,16 +277,19 @@ impl DiskBTree {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 读根页页号;空树是 None
|
||||||
fn root(&mut self) -> io::Result<Option<PageId>> {
|
fn root(&mut self) -> io::Result<Option<PageId>> {
|
||||||
let r = self.file.with_page(META_PAGE, |p| read_u64(p, OFF_ROOT))?;
|
let r = self.file.with_page(META_PAGE, |p| read_u64(p, OFF_ROOT))?;
|
||||||
Ok(if r == 0 { None } else { Some(r) })
|
Ok(if r == 0 { None } else { Some(r) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 写根页页号
|
||||||
fn set_root(&mut self, r: Option<PageId>) -> io::Result<()> {
|
fn set_root(&mut self, r: Option<PageId>) -> io::Result<()> {
|
||||||
self.file
|
self.file
|
||||||
.update_page(META_PAGE, |p| write_u64(p, OFF_ROOT, r.unwrap_or(0)))
|
.update_page(META_PAGE, |p| write_u64(p, OFF_ROOT, r.unwrap_or(0)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 追加一个新节点页
|
||||||
fn alloc(&mut self, node: &Node) -> io::Result<PageId> {
|
fn alloc(&mut self, node: &Node) -> io::Result<PageId> {
|
||||||
let page = self.file.append_page()?;
|
let page = self.file.append_page()?;
|
||||||
let id = page.id;
|
let id = page.id;
|
||||||
@@ -286,10 +297,12 @@ impl DiskBTree {
|
|||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 按页号读节点
|
||||||
fn load(&mut self, id: PageId) -> io::Result<Node> {
|
fn load(&mut self, id: PageId) -> io::Result<Node> {
|
||||||
self.file.with_page(id, get_node)
|
self.file.with_page(id, get_node)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 按页号写节点
|
||||||
fn store(&mut self, id: PageId, node: &Node) -> io::Result<()> {
|
fn store(&mut self, id: PageId, node: &Node) -> io::Result<()> {
|
||||||
self.file.update_page(id, |p| put_node(p, node))?
|
self.file.update_page(id, |p| put_node(p, node))?
|
||||||
}
|
}
|
||||||
@@ -417,6 +430,7 @@ impl DiskBTree {
|
|||||||
self.delete_rec(root, key, value)
|
self.delete_rec(root, key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 递归删一个 (key, value)
|
||||||
fn delete_rec(&mut self, id: PageId, key: &[u8], value: u64) -> io::Result<bool> {
|
fn delete_rec(&mut self, id: PageId, key: &[u8], value: u64) -> io::Result<bool> {
|
||||||
let mut node = self.load(id)?;
|
let mut node = self.load(id)?;
|
||||||
if node.kind == NODE_LEAF {
|
if node.kind == NODE_LEAF {
|
||||||
@@ -438,7 +452,7 @@ impl DiskBTree {
|
|||||||
self.scan(None, None)
|
self.scan(None, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 范围扫描:lo / hi 都是闭区间,None 表示这一侧不限
|
/// 范围扫描:lo / hi 都是闭区间,None 表示不限
|
||||||
pub fn scan(
|
pub fn scan(
|
||||||
&mut self,
|
&mut self,
|
||||||
lo: Option<&[u8]>,
|
lo: Option<&[u8]>,
|
||||||
@@ -250,7 +250,7 @@ impl Catalog {
|
|||||||
Ok(had_column)
|
Ok(had_column)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 整理完成:记住这一版已经重写过(隐藏列名要留住,防止被重新写进来)
|
/// 记住这一版已整理过(隐藏列名要留住)
|
||||||
pub fn mark_compacted(&mut self, table: &str) -> io::Result<()> {
|
pub fn mark_compacted(&mut self, table: &str) -> io::Result<()> {
|
||||||
let entry = self
|
let entry = self
|
||||||
.tables
|
.tables
|
||||||
@@ -336,7 +336,7 @@ fn infer_type(v: &serde_json::Value) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 旧写法与新别名都并到规范名字,参数(varchar(20))原样保留;不认识的原样返回
|
/// 类型别名统一成规范名,参数原样保留
|
||||||
pub fn normalize_type(ty: &str) -> String {
|
pub fn normalize_type(ty: &str) -> String {
|
||||||
let trimmed = ty.trim();
|
let trimmed = ty.trim();
|
||||||
let (base, args) = match trimmed.find('(') {
|
let (base, args) = match trimmed.find('(') {
|
||||||
@@ -4,15 +4,8 @@ use serde::Deserialize;
|
|||||||
|
|
||||||
use crate::log::Level;
|
use crate::log::Level;
|
||||||
|
|
||||||
// 全局配置:只从 TOML 文件读,没写的项回退内置默认值。
|
// 全局配置:只从 TOML 文件读,缺项回退内置默认值;
|
||||||
//
|
// 存储相关分区由本模块解析校验,其它层的分区一概不碰。
|
||||||
// 这个文件是整条链路的唯一配置文件,按分区各管各的:
|
|
||||||
// 存储只看 [page]/[btree]/[buffer]/[storage]/[server]/[log],
|
|
||||||
// 其他分区(例如 web 的 [web])由对应的层自己读,这里既不校验也不关心。
|
|
||||||
// 位置是安装时定下的固定路径,只有命令行 --config 能覆盖;没有任何环境变量通道。
|
|
||||||
// (唯一跟环境变量有关的是路径的默认值:配置文件和默认数据目录都按平台惯例算,
|
|
||||||
// 见 default_config_path / default_data_dir,它们只是算默认位置,不是配置通道。)
|
|
||||||
// 读一次就关,不长期占着文件句柄。
|
|
||||||
|
|
||||||
/// 配置文件名
|
/// 配置文件名
|
||||||
pub const CONFIG_FILE_NAME: &str = "chusql.toml";
|
pub const CONFIG_FILE_NAME: &str = "chusql.toml";
|
||||||
@@ -26,15 +19,13 @@ pub const DATA_DIR_NAME: &str = "data";
|
|||||||
pub const DEFAULT_POOL_SIZE: usize = 1024;
|
pub const DEFAULT_POOL_SIZE: usize = 1024;
|
||||||
pub const DEFAULT_PAGE_SIZE: usize = 4096;
|
pub const DEFAULT_PAGE_SIZE: usize = 4096;
|
||||||
pub const DEFAULT_BTREE_ORDER: usize = 4;
|
pub const DEFAULT_BTREE_ORDER: usize = 4;
|
||||||
pub const DEFAULT_PIPE_NAME: &str = "chusql-joint";
|
|
||||||
pub const DEFAULT_LOG_LEVEL: &str = "info";
|
pub const DEFAULT_LOG_LEVEL: &str = "info";
|
||||||
|
|
||||||
const MIN_PAGE_SIZE: usize = 512;
|
const MIN_PAGE_SIZE: usize = 512;
|
||||||
const MAX_PAGE_SIZE: usize = 65536;
|
const MAX_PAGE_SIZE: usize = 65536;
|
||||||
const MIN_BTREE_ORDER: usize = 3;
|
const MIN_BTREE_ORDER: usize = 3;
|
||||||
|
|
||||||
/// 安装后的固定配置路径:Windows 用 %APPDATA%,其它平台用 XDG 惯例。
|
/// 默认配置文件路径(按平台惯例)
|
||||||
/// 环境变量缺失时退回相对路径 ChuSQL/chusql.toml。
|
|
||||||
pub fn default_config_path() -> PathBuf {
|
pub fn default_config_path() -> PathBuf {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
@@ -60,14 +51,7 @@ pub fn default_config_path() -> PathBuf {
|
|||||||
PathBuf::from(APP_DIR_NAME).join(CONFIG_FILE_NAME)
|
PathBuf::from(APP_DIR_NAME).join(CONFIG_FILE_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [storage] data_dir 没写时的默认数据目录,按各平台惯例自动切换:
|
/// Windows 默认数据目录
|
||||||
///
|
|
||||||
/// 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)]
|
#[cfg(windows)]
|
||||||
pub fn default_data_dir() -> PathBuf {
|
pub fn default_data_dir() -> PathBuf {
|
||||||
let local = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty());
|
let local = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty());
|
||||||
@@ -81,7 +65,7 @@ pub fn default_data_dir() -> PathBuf {
|
|||||||
PathBuf::from(APP_DIR_NAME).join(DATA_DIR_NAME)
|
PathBuf::from(APP_DIR_NAME).join(DATA_DIR_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 见 Windows 版本的注释:这里走 XDG 惯例。
|
/// Unix 默认数据目录(XDG 惯例)
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
pub fn default_data_dir() -> PathBuf {
|
pub fn default_data_dir() -> PathBuf {
|
||||||
let xdg = std::env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty());
|
let xdg = std::env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty());
|
||||||
@@ -107,18 +91,17 @@ pub struct Config {
|
|||||||
pub btree_order: usize,
|
pub btree_order: usize,
|
||||||
pub pool_size: usize,
|
pub pool_size: usize,
|
||||||
pub data_dir: PathBuf,
|
pub data_dir: PathBuf,
|
||||||
pub pipe_name: String,
|
|
||||||
pub log_level: Level,
|
pub log_level: Level,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
|
/// 内置默认配置
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Config {
|
Config {
|
||||||
page_size: DEFAULT_PAGE_SIZE,
|
page_size: DEFAULT_PAGE_SIZE,
|
||||||
btree_order: DEFAULT_BTREE_ORDER,
|
btree_order: DEFAULT_BTREE_ORDER,
|
||||||
pool_size: DEFAULT_POOL_SIZE,
|
pool_size: DEFAULT_POOL_SIZE,
|
||||||
data_dir: default_data_dir(),
|
data_dir: default_data_dir(),
|
||||||
pipe_name: DEFAULT_PIPE_NAME.to_string(),
|
|
||||||
log_level: Level::Info,
|
log_level: Level::Info,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,7 +136,6 @@ struct FileConfig {
|
|||||||
btree: Option<FileBtree>,
|
btree: Option<FileBtree>,
|
||||||
buffer: Option<FileBuffer>,
|
buffer: Option<FileBuffer>,
|
||||||
storage: Option<FileStorage>,
|
storage: Option<FileStorage>,
|
||||||
server: Option<FileServer>,
|
|
||||||
log: Option<FileLog>,
|
log: Option<FileLog>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,19 +163,13 @@ struct FileStorage {
|
|||||||
data_dir: Option<String>,
|
data_dir: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
struct FileServer {
|
|
||||||
pipe_name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
struct FileLog {
|
struct FileLog {
|
||||||
level: Option<String>,
|
level: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 加载配置:explicit 有值就用它(读不到要报错),否则用固定安装路径(存在才读)
|
/// 加载配置:显式路径优先,其次默认位置
|
||||||
pub fn load(explicit: Option<&str>) -> Result<Loaded, String> {
|
pub fn load(explicit: Option<&str>) -> Result<Loaded, String> {
|
||||||
let explicit = explicit.map(str::trim).filter(|p| !p.is_empty());
|
let explicit = explicit.map(str::trim).filter(|p| !p.is_empty());
|
||||||
let path = match explicit {
|
let path = match explicit {
|
||||||
@@ -249,15 +225,11 @@ pub fn resolve(text: Option<&str>, path: Option<PathBuf>) -> Result<Loaded, Stri
|
|||||||
&file_origin,
|
&file_origin,
|
||||||
);
|
);
|
||||||
let (data_dir, dir_origin) = pick(
|
let (data_dir, dir_origin) = pick(
|
||||||
file.storage.and_then(|s| s.data_dir),
|
file.storage.as_ref().and_then(|s| s.data_dir.clone()),
|
||||||
default_data_dir().to_string_lossy().into_owned(),
|
default_data_dir().to_string_lossy().into_owned(),
|
||||||
&file_origin,
|
&file_origin,
|
||||||
);
|
);
|
||||||
let (pipe_name, pipe_origin) = pick(
|
// 管名已经没有了:存储不再是独立进程,管道/套接字那一层随 FFI 一起删掉
|
||||||
file.server.and_then(|s| s.pipe_name),
|
|
||||||
DEFAULT_PIPE_NAME.to_string(),
|
|
||||||
&file_origin,
|
|
||||||
);
|
|
||||||
let (log_level, log_origin) = pick(
|
let (log_level, log_origin) = pick(
|
||||||
file.log.and_then(|l| l.level),
|
file.log.and_then(|l| l.level),
|
||||||
DEFAULT_LOG_LEVEL.to_string(),
|
DEFAULT_LOG_LEVEL.to_string(),
|
||||||
@@ -265,7 +237,6 @@ pub fn resolve(text: Option<&str>, path: Option<PathBuf>) -> Result<Loaded, Stri
|
|||||||
);
|
);
|
||||||
|
|
||||||
let data_dir = data_dir.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 = log_level.trim().to_string();
|
||||||
|
|
||||||
let log_level = Level::parse(&log_level)
|
let log_level = Level::parse(&log_level)
|
||||||
@@ -274,9 +245,6 @@ pub fn resolve(text: Option<&str>, path: Option<PathBuf>) -> Result<Loaded, Stri
|
|||||||
if data_dir.is_empty() {
|
if data_dir.is_empty() {
|
||||||
return Err("storage.data_dir must not be empty".to_string());
|
return Err("storage.data_dir must not be empty".to_string());
|
||||||
}
|
}
|
||||||
if pipe_name.is_empty() {
|
|
||||||
return Err("server.pipe_name must not be empty".to_string());
|
|
||||||
}
|
|
||||||
validate_layout(page_size, btree_order)?;
|
validate_layout(page_size, btree_order)?;
|
||||||
if pool_size == 0 {
|
if pool_size == 0 {
|
||||||
return Err("buffer.pool_size must be at least 1".to_string());
|
return Err("buffer.pool_size must be at least 1".to_string());
|
||||||
@@ -286,7 +254,6 @@ pub fn resolve(text: Option<&str>, path: Option<PathBuf>) -> Result<Loaded, Stri
|
|||||||
("btree.order", btree_order.to_string(), order_origin),
|
("btree.order", btree_order.to_string(), order_origin),
|
||||||
("buffer.pool_size", pool_size.to_string(), pool_origin),
|
("buffer.pool_size", pool_size.to_string(), pool_origin),
|
||||||
("storage.data_dir", data_dir.clone(), dir_origin),
|
("storage.data_dir", data_dir.clone(), dir_origin),
|
||||||
("server.pipe_name", pipe_name.clone(), pipe_origin),
|
|
||||||
("log.level", log_level.name().to_string(), log_origin),
|
("log.level", log_level.name().to_string(), log_origin),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -296,7 +263,6 @@ pub fn resolve(text: Option<&str>, path: Option<PathBuf>) -> Result<Loaded, Stri
|
|||||||
btree_order,
|
btree_order,
|
||||||
pool_size,
|
pool_size,
|
||||||
data_dir: PathBuf::from(data_dir),
|
data_dir: PathBuf::from(data_dir),
|
||||||
pipe_name,
|
|
||||||
log_level,
|
log_level,
|
||||||
},
|
},
|
||||||
config_path: if text.is_some() { path } else { None },
|
config_path: if text.is_some() { path } else { None },
|
||||||
@@ -341,7 +307,7 @@ fn validate_layout(page_size: usize, btree_order: usize) -> Result<(), String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// 全局配置里别的层(例如 web)的分区不能被存储层当成错误
|
/// 别的层的分区不能被当成错误
|
||||||
#[test]
|
#[test]
|
||||||
fn ignores_sections_owned_by_other_layers() {
|
fn ignores_sections_owned_by_other_layers() {
|
||||||
let text = r#"
|
let text = r#"
|
||||||
@@ -351,8 +317,13 @@ size = 8192
|
|||||||
[storage]
|
[storage]
|
||||||
data_dir = "../data"
|
data_dir = "../data"
|
||||||
|
|
||||||
[web]
|
[server]
|
||||||
|
host = "127.0.0.1"
|
||||||
port = 7777
|
port = 7777
|
||||||
|
max_message = 1048576
|
||||||
|
|
||||||
|
[web]
|
||||||
|
port = 7778
|
||||||
user = "root"
|
user = "root"
|
||||||
"#;
|
"#;
|
||||||
let loaded = resolve(Some(text), Some(PathBuf::from("chusql.toml"))).unwrap();
|
let loaded = resolve(Some(text), Some(PathBuf::from("chusql.toml"))).unwrap();
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
use std::ffi::{c_char, CStr};
|
||||||
|
use std::panic::{catch_unwind, AssertUnwindSafe};
|
||||||
|
use std::ptr;
|
||||||
|
use std::slice;
|
||||||
|
|
||||||
|
use crate::runtime::Storage;
|
||||||
|
|
||||||
|
// C ABI:Haskell 侧唯一存储入口,请求与响应都是 UTF-8 JSON。
|
||||||
|
// 不 panic 穿过边界,失败走线程局部 last_error。
|
||||||
|
|
||||||
|
const OK: i32 = 0;
|
||||||
|
const ERR: i32 = -1;
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static LAST_ERROR: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记下本线程最近的失败信息
|
||||||
|
fn set_error(message: impl AsRef<str>) {
|
||||||
|
let text = message.as_ref().as_bytes().to_vec();
|
||||||
|
LAST_ERROR.with(|slot| *slot.borrow_mut() = text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 版本号字符串(静态常量,调用方不要释放)。
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn chusql_storage_version() -> *const c_char {
|
||||||
|
const VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "\0");
|
||||||
|
VERSION.as_ptr() as *const c_char
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开数据目录;失败返回 NULL
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `config_path` 要么是 NULL,要么指向以 NUL 结尾的有效字符串。
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn chusql_storage_open(config_path: *const c_char) -> *mut Storage {
|
||||||
|
let opened = catch_unwind(AssertUnwindSafe(|| {
|
||||||
|
let path = if config_path.is_null() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let text = unsafe { CStr::from_ptr(config_path) }
|
||||||
|
.to_str()
|
||||||
|
.map_err(|_| "config path is not valid UTF-8".to_string())?;
|
||||||
|
if text.is_empty() { None } else { Some(text.to_string()) }
|
||||||
|
};
|
||||||
|
Storage::open(path.as_deref()).map(Box::new)
|
||||||
|
}));
|
||||||
|
match opened {
|
||||||
|
Ok(Ok(storage)) => Box::into_raw(storage),
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
set_error(e);
|
||||||
|
ptr::null_mut()
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
set_error("panic in chusql_storage_open");
|
||||||
|
ptr::null_mut()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关闭句柄(NULL 安全)
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `handle` 必须是 chusql_storage_open 返回且未被关闭过的句柄。
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn chusql_storage_close(handle: *mut Storage) {
|
||||||
|
if handle.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let _ = catch_unwind(AssertUnwindSafe(|| unsafe { drop(Box::from_raw(handle)) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理一条请求,响应缓冲须归还
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `handle` 必须是有效句柄;`request` 指向 `request_len` 个字节;`out`/`out_len` 可写。
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn chusql_storage_request(
|
||||||
|
handle: *const Storage,
|
||||||
|
request: *const u8,
|
||||||
|
request_len: usize,
|
||||||
|
out: *mut *mut u8,
|
||||||
|
out_len: *mut usize,
|
||||||
|
) -> i32 {
|
||||||
|
if !out.is_null() {
|
||||||
|
unsafe { *out = ptr::null_mut() };
|
||||||
|
}
|
||||||
|
if !out_len.is_null() {
|
||||||
|
unsafe { *out_len = 0 };
|
||||||
|
}
|
||||||
|
if handle.is_null() || request.is_null() || out.is_null() || out_len.is_null() {
|
||||||
|
set_error("invalid argument");
|
||||||
|
return ERR;
|
||||||
|
}
|
||||||
|
|
||||||
|
let storage = unsafe { &*handle };
|
||||||
|
let bytes = unsafe { slice::from_raw_parts(request, request_len) };
|
||||||
|
let text = match std::str::from_utf8(bytes) {
|
||||||
|
Ok(text) => text,
|
||||||
|
Err(e) => {
|
||||||
|
set_error(format!("request is not valid UTF-8: {}", e));
|
||||||
|
return ERR;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = match catch_unwind(AssertUnwindSafe(|| storage.request_line(text))) {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(_) => {
|
||||||
|
set_error("panic in chusql_storage_request");
|
||||||
|
return ERR;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut buffer = response.into_bytes().into_boxed_slice();
|
||||||
|
let ptr = buffer.as_mut_ptr();
|
||||||
|
let len = buffer.len();
|
||||||
|
std::mem::forget(buffer);
|
||||||
|
unsafe {
|
||||||
|
*out = ptr;
|
||||||
|
*out_len = len;
|
||||||
|
}
|
||||||
|
OK
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 归还响应缓冲
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `data`/`len` 必须是 chusql_storage_request 原样返回且未被释放过的一对值。
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn chusql_storage_free(data: *mut u8, len: usize) {
|
||||||
|
if data.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let _ = catch_unwind(AssertUnwindSafe(|| unsafe {
|
||||||
|
let slice = ptr::slice_from_raw_parts_mut(data, len);
|
||||||
|
let _owned: Box<[u8]> = Box::from_raw(slice);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取本线程最近的失败信息
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `out_len` 可写,或为 NULL。
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn chusql_storage_last_error(out_len: *mut usize) -> *const u8 {
|
||||||
|
LAST_ERROR.with(|slot| {
|
||||||
|
let bytes = slot.borrow();
|
||||||
|
if !out_len.is_null() {
|
||||||
|
unsafe { *out_len = bytes.len() };
|
||||||
|
}
|
||||||
|
if bytes.is_empty() { ptr::null() } else { bytes.as_ptr() }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -20,9 +20,11 @@ fn decode_row(bytes: &[u8], columns: Option<&[String]>, hidden: &HashSet<String>
|
|||||||
struct RowVisitor<'a>(Option<&'a [String]>, &'a HashSet<String>);
|
struct RowVisitor<'a>(Option<&'a [String]>, &'a HashSet<String>);
|
||||||
impl<'de> Visitor<'de> for RowVisitor<'_> {
|
impl<'de> Visitor<'de> for RowVisitor<'_> {
|
||||||
type Value = Row;
|
type Value = Row;
|
||||||
|
/// 说明期望的输入形态
|
||||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
f.write_str("a row object")
|
f.write_str("a row object")
|
||||||
}
|
}
|
||||||
|
/// 逐字段解出一行
|
||||||
fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<Row, M::Error> {
|
fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<Row, M::Error> {
|
||||||
let mut row = Row::new();
|
let mut row = Row::new();
|
||||||
while let Some(key) = map.next_key::<String>()? {
|
while let Some(key) = map.next_key::<String>()? {
|
||||||
@@ -284,7 +286,7 @@ impl HeapTable {
|
|||||||
self.indexes.iter().any(|i| i.column == column)
|
self.indexes.iter().any(|i| i.column == column)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 建索引:要求这一列取值唯一
|
/// 建索引:id 列必须唯一,其余列可重复
|
||||||
pub fn build_index(
|
pub fn build_index(
|
||||||
&mut self,
|
&mut self,
|
||||||
column: &str,
|
column: &str,
|
||||||
@@ -360,10 +362,12 @@ impl HeapTable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 设置隐藏列集合
|
||||||
pub fn set_hidden_columns(&mut self, columns: HashSet<String>) {
|
pub fn set_hidden_columns(&mut self, columns: HashSet<String>) {
|
||||||
self.hidden_columns = columns;
|
self.hidden_columns = columns;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 隐藏一列并摘掉它的索引
|
||||||
pub fn hide_column(&mut self, column: &str) {
|
pub fn hide_column(&mut self, column: &str) {
|
||||||
self.detach_index(column);
|
self.detach_index(column);
|
||||||
self.hidden_columns.insert(column.to_string());
|
self.hidden_columns.insert(column.to_string());
|
||||||
@@ -487,7 +491,7 @@ impl HeapTable {
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 范围扫描:某一列有索引就走叶子链,没有索引返回 None(上层退回全表扫描)
|
/// 按索引做范围扫描;没索引返回 None
|
||||||
pub fn scan_range(
|
pub fn scan_range(
|
||||||
&mut self,
|
&mut self,
|
||||||
column: &str,
|
column: &str,
|
||||||
@@ -595,6 +599,7 @@ impl HeapTable {
|
|||||||
self.scan_columns(None)
|
self.scan_columns(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 全表扫描,只解码指定列
|
||||||
pub fn scan_columns(&mut self, columns: Option<&[String]>) -> io::Result<Vec<Row>> {
|
pub fn scan_columns(&mut self, columns: Option<&[String]>) -> io::Result<Vec<Row>> {
|
||||||
Ok(self
|
Ok(self
|
||||||
.scan_projected_with_positions(columns)?
|
.scan_projected_with_positions(columns)?
|
||||||
@@ -608,6 +613,7 @@ impl HeapTable {
|
|||||||
self.scan_projected_with_positions(None)
|
self.scan_projected_with_positions(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 全表扫描,带位置与列投影
|
||||||
fn scan_projected_with_positions(&mut self, columns: Option<&[String]>) -> io::Result<Vec<(PageId, u16, Row)>> {
|
fn scan_projected_with_positions(&mut self, columns: Option<&[String]>) -> io::Result<Vec<(PageId, u16, Row)>> {
|
||||||
let n = self.file.num_pages()?;
|
let n = self.file.num_pages()?;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
//! `scripts/init.sql` 的最小读取:只认 CREATE TABLE 与 CREATE INDEX。
|
|
||||||
//! 系统库的 schema 由这份脚本声明,存储进程按它建系统表。
|
|
||||||
|
|
||||||
use crate::protocol::SchemaColumn;
|
use crate::protocol::SchemaColumn;
|
||||||
|
|
||||||
|
// 读取 scripts/init.sql,只认 CREATE TABLE 与 CREATE INDEX。
|
||||||
|
// 系统库的 schema 由这份脚本声明,进程按它建系统表。
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct InitColumn {
|
pub struct InitColumn {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -68,7 +68,7 @@ pub fn parse(text: &str) -> Result<Vec<InitTable>, String> {
|
|||||||
Ok(tables)
|
Ok(tables)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 一行一列:`name type [NOT NULL] [AUTO_INCREMENT] [PRIMARY KEY] [UNIQUE]`
|
/// 解析表定义里的列清单
|
||||||
fn columns(body: &str) -> Result<Vec<InitColumn>, String> {
|
fn columns(body: &str) -> Result<Vec<InitColumn>, String> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for part in split_top_level(body) {
|
for part in split_top_level(body) {
|
||||||
@@ -166,8 +166,9 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// 仓库里的系统库脚本必须始终能解析
|
/// 仓库里的系统库脚本必须始终能解析
|
||||||
const SYSTEM_SCHEMA: &str = include_str!("../../scripts/init.sql");
|
const SYSTEM_SCHEMA: &str = include_str!("../../../scripts/init.sql");
|
||||||
|
|
||||||
|
/// 仓库自带脚本必须能解析出账号表
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_the_repository_init_script() {
|
fn parses_the_repository_init_script() {
|
||||||
let tables = parse(SYSTEM_SCHEMA).expect("scripts/init.sql must parse");
|
let tables = parse(SYSTEM_SCHEMA).expect("scripts/init.sql must parse");
|
||||||
@@ -186,6 +187,7 @@ mod tests {
|
|||||||
assert_eq!(users.indexes, ["user"]);
|
assert_eq!(users.indexes, ["user"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 未知语句与表级约束要报错
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_unknown_statements_and_table_level_constraints() {
|
fn rejects_unknown_statements_and_table_level_constraints() {
|
||||||
assert!(parse("DROP TABLE users;").is_err());
|
assert!(parse("DROP TABLE users;").is_err());
|
||||||
@@ -193,6 +195,7 @@ mod tests {
|
|||||||
assert!(parse("CREATE INDEX missing (user);").is_err());
|
assert!(parse("CREATE INDEX missing (user);").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 注释与空语句要被跳过
|
||||||
#[test]
|
#[test]
|
||||||
fn ignores_comments_and_blank_statements() {
|
fn ignores_comments_and_blank_statements() {
|
||||||
let tables = parse("-- note\nCREATE TABLE t (a int NOT NULL);\n\n/* done */\n").unwrap();
|
let tables = parse("-- note\nCREATE TABLE t (a int NOT NULL);\n\n/* done */\n").unwrap();
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
// 存储层库入口:对外暴露下面这些模块,服务与集成测试都从这里引。
|
// 存储层库入口:对外暴露下面这些模块,服务与集成测试都从这里引。
|
||||||
|
|
||||||
pub mod btree;
|
pub mod btree;
|
||||||
|
pub mod catalog;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod endpoint;
|
pub mod ffi;
|
||||||
pub mod heap;
|
pub mod heap;
|
||||||
pub mod initsql;
|
pub mod initsql;
|
||||||
pub mod log;
|
pub mod log;
|
||||||
pub mod page;
|
pub mod page;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
pub mod catalog;
|
pub mod runtime;
|
||||||
pub mod wal;
|
pub mod wal;
|
||||||
@@ -22,7 +22,7 @@ pub enum Level {
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Channel {
|
pub enum Channel {
|
||||||
Core,
|
Core,
|
||||||
Pipe,
|
Request,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Channel {
|
impl Channel {
|
||||||
@@ -30,7 +30,7 @@ impl Channel {
|
|||||||
fn tag(self) -> &'static str {
|
fn tag(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Channel::Core => "core",
|
Channel::Core => "core",
|
||||||
Channel::Pipe => "pipe",
|
Channel::Request => "request",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,7 +102,7 @@ pub fn init() {
|
|||||||
enable_vt();
|
enable_vt();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 写出一行:warn / error 走 stderr,info / debug 走 stdout。
|
/// 写出一行日志,按等级选流向
|
||||||
pub fn write(channel: Channel, level: Level, args: fmt::Arguments) {
|
pub fn write(channel: Channel, level: Level, args: fmt::Arguments) {
|
||||||
let msg = args.to_string();
|
let msg = args.to_string();
|
||||||
let to_stderr = streams_to_stderr(level);
|
let to_stderr = streams_to_stderr(level);
|
||||||
@@ -116,12 +116,12 @@ pub fn write(channel: Channel, level: Level, args: fmt::Arguments) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 这个等级写 stderr 吗:warn / error 是,info / debug 写 stdout
|
/// 这个等级是否写 stderr
|
||||||
fn streams_to_stderr(level: Level) -> bool {
|
fn streams_to_stderr(level: Level) -> bool {
|
||||||
matches!(level, Level::Error | Level::Warn)
|
matches!(level, Level::Error | Level::Warn)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 是否着色:只按要写的那条流是不是终端判断,没有环境变量开关
|
/// 按目标流是否终端决定是否着色
|
||||||
fn color_enabled(to_stderr: bool) -> bool {
|
fn color_enabled(to_stderr: bool) -> bool {
|
||||||
let cached = if to_stderr { &COLOR_STDERR } else { &COLOR_STDOUT };
|
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() })
|
*cached.get_or_init(|| if to_stderr { std::io::stderr().is_terminal() } else { std::io::stdout().is_terminal() })
|
||||||
@@ -151,6 +151,7 @@ pub struct Stamp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for Stamp {
|
impl fmt::Display for Stamp {
|
||||||
|
/// 格式化成时间戳文本
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(
|
write!(
|
||||||
f,
|
f,
|
||||||
@@ -266,8 +267,8 @@ macro_rules! log_debug {
|
|||||||
(core, $($arg:tt)*) => {
|
(core, $($arg:tt)*) => {
|
||||||
$crate::log_line!($crate::log::Channel::Core, $crate::log::Level::Debug, $($arg)*)
|
$crate::log_line!($crate::log::Channel::Core, $crate::log::Level::Debug, $($arg)*)
|
||||||
};
|
};
|
||||||
(pipe, $($arg:tt)*) => {
|
(request, $($arg:tt)*) => {
|
||||||
$crate::log_line!($crate::log::Channel::Pipe, $crate::log::Level::Debug, $($arg)*)
|
$crate::log_line!($crate::log::Channel::Request, $crate::log::Level::Debug, $($arg)*)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,8 +277,8 @@ macro_rules! log_info {
|
|||||||
(core, $($arg:tt)*) => {
|
(core, $($arg:tt)*) => {
|
||||||
$crate::log_line!($crate::log::Channel::Core, $crate::log::Level::Info, $($arg)*)
|
$crate::log_line!($crate::log::Channel::Core, $crate::log::Level::Info, $($arg)*)
|
||||||
};
|
};
|
||||||
(pipe, $($arg:tt)*) => {
|
(request, $($arg:tt)*) => {
|
||||||
$crate::log_line!($crate::log::Channel::Pipe, $crate::log::Level::Info, $($arg)*)
|
$crate::log_line!($crate::log::Channel::Request, $crate::log::Level::Info, $($arg)*)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,8 +287,8 @@ macro_rules! log_warn {
|
|||||||
(core, $($arg:tt)*) => {
|
(core, $($arg:tt)*) => {
|
||||||
$crate::log_line!($crate::log::Channel::Core, $crate::log::Level::Warn, $($arg)*)
|
$crate::log_line!($crate::log::Channel::Core, $crate::log::Level::Warn, $($arg)*)
|
||||||
};
|
};
|
||||||
(pipe, $($arg:tt)*) => {
|
(request, $($arg:tt)*) => {
|
||||||
$crate::log_line!($crate::log::Channel::Pipe, $crate::log::Level::Warn, $($arg)*)
|
$crate::log_line!($crate::log::Channel::Request, $crate::log::Level::Warn, $($arg)*)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,8 +297,8 @@ macro_rules! log_error {
|
|||||||
(core, $($arg:tt)*) => {
|
(core, $($arg:tt)*) => {
|
||||||
$crate::log_line!($crate::log::Channel::Core, $crate::log::Level::Error, $($arg)*)
|
$crate::log_line!($crate::log::Channel::Core, $crate::log::Level::Error, $($arg)*)
|
||||||
};
|
};
|
||||||
(pipe, $($arg:tt)*) => {
|
(request, $($arg:tt)*) => {
|
||||||
$crate::log_line!($crate::log::Channel::Pipe, $crate::log::Level::Error, $($arg)*)
|
$crate::log_line!($crate::log::Channel::Request, $crate::log::Level::Error, $($arg)*)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,6 +316,7 @@ macro_rules! log_line {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// 已知日期换算要正确
|
||||||
#[test]
|
#[test]
|
||||||
fn civil_from_days_known_dates() {
|
fn civil_from_days_known_dates() {
|
||||||
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||||
@@ -324,6 +326,7 @@ mod tests {
|
|||||||
assert_eq!(civil_from_days(19_782), (2024, 2, 29));
|
assert_eq!(civil_from_days(19_782), (2024, 2, 29));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 行里要有等级、时间与来源
|
||||||
#[test]
|
#[test]
|
||||||
fn render_has_time_channel_and_colors() {
|
fn render_has_time_channel_and_colors() {
|
||||||
let stamp = Stamp {
|
let stamp = Stamp {
|
||||||
@@ -337,16 +340,16 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
render(Level::Error, Channel::Pipe, true, &stamp, "boom"),
|
render(Level::Error, Channel::Request, true, &stamp, "boom"),
|
||||||
"[\u{1b}[1m\u{1b}[31mERROR\u{1b}[0m 2026-09-24 13:45:12.130] [pipe] boom"
|
"[\u{1b}[1m\u{1b}[31mERROR\u{1b}[0m 2026-09-24 13:45:12.130] [request] boom"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
render(Level::Warn, Channel::Core, true, &stamp, "careful"),
|
render(Level::Warn, Channel::Core, true, &stamp, "careful"),
|
||||||
"[\u{1b}[1m\u{1b}[38;5;208mWARN \u{1b}[0m 2026-09-24 13:45:12.130] [core] careful"
|
"[\u{1b}[1m\u{1b}[38;5;208mWARN \u{1b}[0m 2026-09-24 13:45:12.130] [core] careful"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
render(Level::Debug, Channel::Pipe, true, &stamp, "detail"),
|
render(Level::Debug, Channel::Request, true, &stamp, "detail"),
|
||||||
"[\u{1b}[1mDEBUG\u{1b}[0m 2026-09-24 13:45:12.130] [pipe] detail"
|
"[\u{1b}[1mDEBUG\u{1b}[0m 2026-09-24 13:45:12.130] [request] detail"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
render(Level::Info, Channel::Core, true, &stamp, "hello"),
|
render(Level::Info, Channel::Core, true, &stamp, "hello"),
|
||||||
@@ -354,6 +357,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// warn 与 error 走 stderr
|
||||||
#[test]
|
#[test]
|
||||||
fn warnings_and_errors_go_to_stderr() {
|
fn warnings_and_errors_go_to_stderr() {
|
||||||
assert!(streams_to_stderr(Level::Error));
|
assert!(streams_to_stderr(Level::Error));
|
||||||
@@ -282,6 +282,7 @@ impl PageFile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for PageFile {
|
impl Drop for PageFile {
|
||||||
|
/// 析构时把脏页写回
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let _ = self.flush();
|
let _ = self.flush();
|
||||||
}
|
}
|
||||||
@@ -38,6 +38,7 @@ pub struct SchemaColumn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Default for SchemaColumn {
|
impl Default for SchemaColumn {
|
||||||
|
/// 列定义的默认值
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
SchemaColumn {
|
SchemaColumn {
|
||||||
name: String::new(),
|
name: String::new(),
|
||||||
@@ -52,6 +53,7 @@ impl Default for SchemaColumn {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// serde 里缺省为真
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -188,6 +190,7 @@ impl Request {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 是不是保留系统表名
|
||||||
pub fn reserved_table(table: &str) -> bool {
|
pub fn reserved_table(table: &str) -> bool {
|
||||||
table.to_ascii_lowercase().starts_with("__chusql_")
|
table.to_ascii_lowercase().starts_with("__chusql_")
|
||||||
}
|
}
|
||||||
@@ -1,26 +1,21 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::{BufRead, BufReader, Write};
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::thread;
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use chusql_storage::catalog::Catalog;
|
use crate::catalog::Catalog;
|
||||||
use chusql_storage::config::{self, Config, Loaded};
|
use crate::config::{self, Config, Loaded};
|
||||||
use chusql_storage::endpoint;
|
use crate::heap::HeapTable;
|
||||||
use chusql_storage::heap::HeapTable;
|
use crate::initsql::{self, InitColumn, InitTable};
|
||||||
use chusql_storage::initsql::{self, InitColumn, InitTable};
|
use crate::log;
|
||||||
use chusql_storage::log;
|
use crate::protocol::{
|
||||||
use chusql_storage::protocol::{
|
|
||||||
reserved_table, Account, ColumnStatWire, IndexWire, Request, Response, Row, SchemaColumn, TableSchemaWire, USERS_TABLE,
|
reserved_table, Account, ColumnStatWire, IndexWire, Request, Response, Row, SchemaColumn, TableSchemaWire, USERS_TABLE,
|
||||||
};
|
};
|
||||||
use chusql_storage::wal::{Wal, WalOp};
|
use crate::wal::{Wal, WalOp};
|
||||||
use chusql_storage::{log_debug, log_error, log_info, log_warn};
|
use crate::{log_debug, log_error, log_info, log_warn};
|
||||||
use interprocess::local_socket::prelude::*;
|
|
||||||
use interprocess::TryClone;
|
|
||||||
|
|
||||||
// 本地套接字服务:按行读写 JSON。端点地址由 endpoint 模块给出:
|
// 进程内存储运行时:Haskell 侧经 ffi.rs 调进来。
|
||||||
// Windows 是具名管道 \\.\pipe\<name>,Unix 是文件系统套接字 <dir>/<name>.sock。
|
// request_line 的语义等同原来的「一行 JSON 请求 → 一行 JSON 响应」套接字协议。
|
||||||
|
|
||||||
struct Server {
|
struct Server {
|
||||||
cfg: Config,
|
cfg: Config,
|
||||||
@@ -360,7 +355,7 @@ impl Server {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 旧类型系统留下的数据升级到新格式:列类型名规范化、缺列的行补 null
|
/// 老数据迁移:类型名规范、缺列补 null
|
||||||
fn migrate_data(&self) -> std::io::Result<()> {
|
fn migrate_data(&self) -> std::io::Result<()> {
|
||||||
let names: Vec<String> = self
|
let names: Vec<String> = self
|
||||||
.catalog
|
.catalog
|
||||||
@@ -647,12 +642,12 @@ impl Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Request::Ping => {
|
Request::Ping => {
|
||||||
log_debug!(pipe, "ping");
|
log_debug!(request, "ping");
|
||||||
Response::Pong
|
Response::Pong
|
||||||
}
|
}
|
||||||
|
|
||||||
Request::Scan { table, columns } => {
|
Request::Scan { table, columns } => {
|
||||||
log_debug!(pipe, "scan table={}", table);
|
log_debug!(request, "scan table={}", table);
|
||||||
|
|
||||||
let result = (|| {
|
let result = (|| {
|
||||||
if let Some(cols) = &columns {
|
if let Some(cols) = &columns {
|
||||||
@@ -670,7 +665,7 @@ impl Server {
|
|||||||
match result {
|
match result {
|
||||||
Ok(rows) => Response::Rows { rows },
|
Ok(rows) => Response::Rows { rows },
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log_warn!(pipe, "scan {}: {}", table, e);
|
log_warn!(request, "scan {}: {}", table, e);
|
||||||
Response::Error {
|
Response::Error {
|
||||||
message: format!("scan {}: {}", table, e),
|
message: format!("scan {}: {}", table, e),
|
||||||
}
|
}
|
||||||
@@ -679,13 +674,13 @@ impl Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Request::Insert { table, row } => {
|
Request::Insert { table, row } => {
|
||||||
log_debug!(pipe, "insert table={}", table);
|
log_debug!(request, "insert table={}", table);
|
||||||
let tname = table.clone();
|
let tname = table.clone();
|
||||||
let op = WalOp::Insert { table, row };
|
let op = WalOp::Insert { table, row };
|
||||||
match self.write_via_wal(&op) {
|
match self.write_via_wal(&op) {
|
||||||
Ok(()) => Response::Ok,
|
Ok(()) => Response::Ok,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log_warn!(pipe, "insert {}: {}", tname, e);
|
log_warn!(request, "insert {}: {}", tname, e);
|
||||||
Response::Error {
|
Response::Error {
|
||||||
message: format!("insert {}: {}", tname, e),
|
message: format!("insert {}: {}", tname, e),
|
||||||
}
|
}
|
||||||
@@ -694,13 +689,13 @@ impl Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Request::InsertBatch { table, rows } => {
|
Request::InsertBatch { table, rows } => {
|
||||||
log_debug!(pipe, "insert_batch table={} rows={}", table, rows.len());
|
log_debug!(request, "insert_batch table={} rows={}", table, rows.len());
|
||||||
let tname = table.clone();
|
let tname = table.clone();
|
||||||
let op = WalOp::InsertBatch { table, rows };
|
let op = WalOp::InsertBatch { table, rows };
|
||||||
match self.write_via_wal(&op) {
|
match self.write_via_wal(&op) {
|
||||||
Ok(()) => Response::Ok,
|
Ok(()) => Response::Ok,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log_warn!(pipe, "insert_batch {}: {}", tname, e);
|
log_warn!(request, "insert_batch {}: {}", tname, e);
|
||||||
Response::Error {
|
Response::Error {
|
||||||
message: format!("insert_batch {}: {}", tname, e),
|
message: format!("insert_batch {}: {}", tname, e),
|
||||||
}
|
}
|
||||||
@@ -709,13 +704,13 @@ impl Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Request::DeleteKeys { table, keys } => {
|
Request::DeleteKeys { table, keys } => {
|
||||||
log_debug!(pipe, "delete_keys table={} keys={}", table, keys.len());
|
log_debug!(request, "delete_keys table={} keys={}", table, keys.len());
|
||||||
let tname = table.clone();
|
let tname = table.clone();
|
||||||
let op = WalOp::DeleteKeys { table, keys };
|
let op = WalOp::DeleteKeys { table, keys };
|
||||||
match self.write_via_wal(&op) {
|
match self.write_via_wal(&op) {
|
||||||
Ok(()) => Response::Ok,
|
Ok(()) => Response::Ok,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log_warn!(pipe, "delete_keys {}: {}", tname, e);
|
log_warn!(request, "delete_keys {}: {}", tname, e);
|
||||||
Response::Error {
|
Response::Error {
|
||||||
message: format!("delete_keys {}: {}", tname, e),
|
message: format!("delete_keys {}: {}", tname, e),
|
||||||
}
|
}
|
||||||
@@ -724,12 +719,12 @@ impl Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Request::LookupByIndex { table, column, key } => {
|
Request::LookupByIndex { table, column, key } => {
|
||||||
log_debug!(pipe, "lookup_by_index table={} column={} key={}", table, column, key);
|
log_debug!(request, "lookup_by_index table={} column={} key={}", table, column, key);
|
||||||
let indexed =
|
let indexed =
|
||||||
match self.with_existing_table(&table, |t| Ok(t.has_index(&column))) {
|
match self.with_existing_table(&table, |t| Ok(t.has_index(&column))) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log_warn!(pipe, "lookup_by_index {}: {}", table, e);
|
log_warn!(request, "lookup_by_index {}: {}", table, e);
|
||||||
return Response::Error {
|
return Response::Error {
|
||||||
message: format!("lookup_by_index {}: {}", table, e),
|
message: format!("lookup_by_index {}: {}", table, e),
|
||||||
};
|
};
|
||||||
@@ -752,7 +747,7 @@ impl Server {
|
|||||||
match found {
|
match found {
|
||||||
Ok(rows) => Response::Rows { rows },
|
Ok(rows) => Response::Rows { rows },
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log_warn!(pipe, "lookup_by_index {}: {}", table, e);
|
log_warn!(request, "lookup_by_index {}: {}", table, e);
|
||||||
Response::Error {
|
Response::Error {
|
||||||
message: format!("lookup_by_index {}: {}", table, e),
|
message: format!("lookup_by_index {}: {}", table, e),
|
||||||
}
|
}
|
||||||
@@ -761,12 +756,12 @@ impl Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Request::RangeByIndex { table, column, lo, lo_inclusive, hi, hi_inclusive } => {
|
Request::RangeByIndex { table, column, lo, lo_inclusive, hi, hi_inclusive } => {
|
||||||
log_debug!(pipe, "range_by_index table={} column={}", table, column);
|
log_debug!(request, "range_by_index table={} column={}", table, column);
|
||||||
let indexed =
|
let indexed =
|
||||||
match self.with_existing_table(&table, |t| Ok(t.has_index(&column))) {
|
match self.with_existing_table(&table, |t| Ok(t.has_index(&column))) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log_warn!(pipe, "range_by_index {}: {}", table, e);
|
log_warn!(request, "range_by_index {}: {}", table, e);
|
||||||
return Response::Error {
|
return Response::Error {
|
||||||
message: format!("range_by_index {}: {}", table, e),
|
message: format!("range_by_index {}: {}", table, e),
|
||||||
};
|
};
|
||||||
@@ -781,7 +776,7 @@ impl Server {
|
|||||||
Ok(Some(rows)) => Response::Rows { rows },
|
Ok(Some(rows)) => Response::Rows { rows },
|
||||||
Ok(None) => Response::NoIndex,
|
Ok(None) => Response::NoIndex,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log_warn!(pipe, "range_by_index {}: {}", table, e);
|
log_warn!(request, "range_by_index {}: {}", table, e);
|
||||||
Response::Error {
|
Response::Error {
|
||||||
message: format!("range_by_index {}: {}", table, e),
|
message: format!("range_by_index {}: {}", table, e),
|
||||||
}
|
}
|
||||||
@@ -790,13 +785,13 @@ impl Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Request::ReplaceAll { table, rows } => {
|
Request::ReplaceAll { table, rows } => {
|
||||||
log_debug!(pipe, "replace_all table={} rows={}", table, rows.len());
|
log_debug!(request, "replace_all table={} rows={}", table, rows.len());
|
||||||
let tname = table.clone();
|
let tname = table.clone();
|
||||||
let op = WalOp::ReplaceAll { table, rows };
|
let op = WalOp::ReplaceAll { table, rows };
|
||||||
match self.write_via_wal(&op) {
|
match self.write_via_wal(&op) {
|
||||||
Ok(()) => Response::Ok,
|
Ok(()) => Response::Ok,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log_warn!(pipe, "replace_all {}: {}", tname, e);
|
log_warn!(request, "replace_all {}: {}", tname, e);
|
||||||
Response::Error {
|
Response::Error {
|
||||||
message: format!("replace_all {}: {}", tname, e),
|
message: format!("replace_all {}: {}", tname, e),
|
||||||
}
|
}
|
||||||
@@ -805,11 +800,11 @@ impl Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Request::ListTables => {
|
Request::ListTables => {
|
||||||
log_debug!(pipe, "list_tables");
|
log_debug!(request, "list_tables");
|
||||||
match self.list_tables() {
|
match self.list_tables() {
|
||||||
Ok(tables) => Response::Tables { tables },
|
Ok(tables) => Response::Tables { tables },
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log_warn!(pipe, "list_tables: {}", e);
|
log_warn!(request, "list_tables: {}", e);
|
||||||
Response::Error {
|
Response::Error {
|
||||||
message: format!("list_tables: {}", e),
|
message: format!("list_tables: {}", e),
|
||||||
}
|
}
|
||||||
@@ -818,7 +813,7 @@ impl Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Request::ListCatalog => {
|
Request::ListCatalog => {
|
||||||
log_debug!(pipe, "list_catalog");
|
log_debug!(request, "list_catalog");
|
||||||
let c = self.catalog.lock().unwrap();
|
let c = self.catalog.lock().unwrap();
|
||||||
let schemas: Vec<TableSchemaWire> = c
|
let schemas: Vec<TableSchemaWire> = c
|
||||||
.all_tables()
|
.all_tables()
|
||||||
@@ -836,7 +831,7 @@ impl Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Request::DescribeTable { table } => {
|
Request::DescribeTable { table } => {
|
||||||
log_debug!(pipe, "describe_table table={}", table);
|
log_debug!(request, "describe_table table={}", table);
|
||||||
match self.describe_table(&table) {
|
match self.describe_table(&table) {
|
||||||
Ok((cols, count, indexes, stats)) => Response::Schema {
|
Ok((cols, count, indexes, stats)) => Response::Schema {
|
||||||
table,
|
table,
|
||||||
@@ -846,7 +841,7 @@ impl Server {
|
|||||||
stats,
|
stats,
|
||||||
},
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log_warn!(pipe, "describe_table {}: {}", table, e);
|
log_warn!(request, "describe_table {}: {}", table, e);
|
||||||
Response::Error {
|
Response::Error {
|
||||||
message: format!("describe_table {}: {}", table, e),
|
message: format!("describe_table {}: {}", table, e),
|
||||||
}
|
}
|
||||||
@@ -855,7 +850,7 @@ impl Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Request::CreateTable { table, columns } => {
|
Request::CreateTable { table, columns } => {
|
||||||
log_debug!(pipe, "create_table table={} cols={}", table, columns.len());
|
log_debug!(request, "create_table table={} cols={}", table, columns.len());
|
||||||
|
|
||||||
{
|
{
|
||||||
let c = self.catalog.lock().unwrap();
|
let c = self.catalog.lock().unwrap();
|
||||||
@@ -887,7 +882,7 @@ impl Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Request::DropTable { table } => {
|
Request::DropTable { table } => {
|
||||||
log_debug!(pipe, "drop_table table={}", table);
|
log_debug!(request, "drop_table table={}", table);
|
||||||
|
|
||||||
let dropped_indexes: Vec<String> = {
|
let dropped_indexes: Vec<String> = {
|
||||||
let mut c = self.catalog.lock().unwrap();
|
let mut c = self.catalog.lock().unwrap();
|
||||||
@@ -919,11 +914,11 @@ impl Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Request::CreateIndex { table, column } => {
|
Request::CreateIndex { table, column } => {
|
||||||
log_debug!(pipe, "create_index table={} column={}", table, column);
|
log_debug!(request, "create_index table={} column={}", table, column);
|
||||||
match self.create_index(&table, &column) {
|
match self.create_index(&table, &column) {
|
||||||
Ok(()) => Response::Ok,
|
Ok(()) => Response::Ok,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log_warn!(pipe, "create_index {}({}): {}", table, column, e);
|
log_warn!(request, "create_index {}({}): {}", table, column, e);
|
||||||
Response::Error {
|
Response::Error {
|
||||||
message: format!("create_index {}({}): {}", table, column, e),
|
message: format!("create_index {}({}): {}", table, column, e),
|
||||||
}
|
}
|
||||||
@@ -932,11 +927,11 @@ impl Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Request::DropIndex { table, column } => {
|
Request::DropIndex { table, column } => {
|
||||||
log_debug!(pipe, "drop_index table={} column={}", table, column);
|
log_debug!(request, "drop_index table={} column={}", table, column);
|
||||||
match self.drop_index(&table, &column) {
|
match self.drop_index(&table, &column) {
|
||||||
Ok(()) => Response::Ok,
|
Ok(()) => Response::Ok,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log_warn!(pipe, "drop_index {}({}): {}", table, column, e);
|
log_warn!(request, "drop_index {}({}): {}", table, column, e);
|
||||||
Response::Error {
|
Response::Error {
|
||||||
message: format!("drop_index {}({}): {}", table, column, e),
|
message: format!("drop_index {}({}): {}", table, column, e),
|
||||||
}
|
}
|
||||||
@@ -945,11 +940,11 @@ impl Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Request::DropColumn { table, column } => {
|
Request::DropColumn { table, column } => {
|
||||||
log_debug!(pipe, "drop_column table={} column={}", table, column);
|
log_debug!(request, "drop_column table={} column={}", table, column);
|
||||||
match self.drop_column(&table, &column) {
|
match self.drop_column(&table, &column) {
|
||||||
Ok(()) => Response::Ok,
|
Ok(()) => Response::Ok,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log_warn!(pipe, "drop_column {}({}): {}", table, column, e);
|
log_warn!(request, "drop_column {}({}): {}", table, column, e);
|
||||||
Response::Error {
|
Response::Error {
|
||||||
message: format!("drop_column {}({}): {}", table, column, e),
|
message: format!("drop_column {}({}): {}", table, column, e),
|
||||||
}
|
}
|
||||||
@@ -958,11 +953,11 @@ impl Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Request::Compact { table } => {
|
Request::Compact { table } => {
|
||||||
log_debug!(pipe, "compact table={}", table);
|
log_debug!(request, "compact table={}", table);
|
||||||
match self.compact_table(&table) {
|
match self.compact_table(&table) {
|
||||||
Ok(()) => Response::Ok,
|
Ok(()) => Response::Ok,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log_warn!(pipe, "compact {}: {}", table, e);
|
log_warn!(request, "compact {}: {}", table, e);
|
||||||
Response::Error {
|
Response::Error {
|
||||||
message: format!("compact {}: {}", table, e),
|
message: format!("compact {}: {}", table, e),
|
||||||
}
|
}
|
||||||
@@ -971,13 +966,13 @@ impl Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Request::ReplaceSchema { table, columns, rows } => {
|
Request::ReplaceSchema { table, columns, rows } => {
|
||||||
log_debug!(pipe, "replace_schema table={} cols={}", table, columns.len());
|
log_debug!(request, "replace_schema table={} cols={}", table, columns.len());
|
||||||
let tname = table.clone();
|
let tname = table.clone();
|
||||||
let op = WalOp::ReplaceSchema { table, columns, rows };
|
let op = WalOp::ReplaceSchema { table, columns, rows };
|
||||||
match self.write_via_wal(&op) {
|
match self.write_via_wal(&op) {
|
||||||
Ok(()) => Response::Ok,
|
Ok(()) => Response::Ok,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log_warn!(pipe, "replace_schema {}: {}", tname, e);
|
log_warn!(request, "replace_schema {}: {}", tname, e);
|
||||||
Response::Error {
|
Response::Error {
|
||||||
message: format!("replace_schema {}: {}", tname, e),
|
message: format!("replace_schema {}: {}", tname, e),
|
||||||
}
|
}
|
||||||
@@ -1015,6 +1010,7 @@ impl Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 账号列表转成行
|
||||||
fn account_rows(accounts: &[Account]) -> std::io::Result<Vec<Row>> {
|
fn account_rows(accounts: &[Account]) -> std::io::Result<Vec<Row>> {
|
||||||
accounts.iter().map(|a| Ok(HashMap::from([
|
accounts.iter().map(|a| Ok(HashMap::from([
|
||||||
("id".into(), serde_json::json!(a.id)),
|
("id".into(), serde_json::json!(a.id)),
|
||||||
@@ -1029,7 +1025,7 @@ fn account_rows(accounts: &[Account]) -> std::io::Result<Vec<Row>> {
|
|||||||
]))).collect()
|
]))).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 系统表:列定义与索引都按 scripts/init.sql 写进数据字典
|
/// 按 init.sql 把系统表写进字典
|
||||||
fn declare_system_schema(catalog: &mut Catalog, rows: &[Row]) -> std::io::Result<()> {
|
fn declare_system_schema(catalog: &mut Catalog, rows: &[Row]) -> std::io::Result<()> {
|
||||||
for table in system_schema() {
|
for table in system_schema() {
|
||||||
let stats: &[Row] = if table.name == USERS_TABLE { rows } else { &[] };
|
let stats: &[Row] = if table.name == USERS_TABLE { rows } else { &[] };
|
||||||
@@ -1044,7 +1040,7 @@ fn declare_system_schema(catalog: &mut Catalog, rows: &[Row]) -> std::io::Result
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读一行账号:新格式是平铺的列,老格式是 {id, account:"<json>"}
|
/// 读一行账号(兼容老格式)
|
||||||
fn decode_account_row(row: &Row) -> std::io::Result<Account> {
|
fn decode_account_row(row: &Row) -> std::io::Result<Account> {
|
||||||
if let Some(encoded) = row.get("account").and_then(serde_json::Value::as_str) {
|
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"));
|
return serde_json::from_str(encoded).map_err(|_| std::io::Error::other("invalid account record"));
|
||||||
@@ -1081,7 +1077,7 @@ fn now_stamp() -> String {
|
|||||||
format_timestamp(seconds)
|
format_timestamp(seconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unix 秒 → YYYY-MM-DD HH:MM:SS(UTC)
|
/// Unix 秒转时间戳文本
|
||||||
fn format_timestamp(seconds: i64) -> String {
|
fn format_timestamp(seconds: i64) -> String {
|
||||||
let days = seconds.div_euclid(86_400);
|
let days = seconds.div_euclid(86_400);
|
||||||
let rest = seconds.rem_euclid(86_400);
|
let rest = seconds.rem_euclid(86_400);
|
||||||
@@ -1106,6 +1102,7 @@ fn civil_from_days(days: i64) -> (i64, u32, u32) {
|
|||||||
(if month <= 2 { year + 1 } else { year }, month, day)
|
(if month <= 2 { year + 1 } else { year }, month, day)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 规范化并校验账号名
|
||||||
fn account_name(user: &str) -> std::io::Result<String> {
|
fn account_name(user: &str) -> std::io::Result<String> {
|
||||||
if user.is_empty() || user.len() > 64 || !user.bytes().all(|c| c.is_ascii_alphanumeric() || b"_.-".contains(&c)) {
|
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"));
|
return Err(std::io::Error::other("invalid account name"));
|
||||||
@@ -1113,6 +1110,7 @@ fn account_name(user: &str) -> std::io::Result<String> {
|
|||||||
Ok(user.to_ascii_lowercase())
|
Ok(user.to_ascii_lowercase())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 校验口令哈希长度
|
||||||
fn validate_account_hash(hash: &str) -> std::io::Result<()> {
|
fn validate_account_hash(hash: &str) -> std::io::Result<()> {
|
||||||
if hash.is_empty() || hash.len() > 256 {
|
if hash.is_empty() || hash.len() > 256 {
|
||||||
return Err(std::io::Error::other("invalid account credential"));
|
return Err(std::io::Error::other("invalid account credential"));
|
||||||
@@ -1120,6 +1118,7 @@ fn validate_account_hash(hash: &str) -> std::io::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 造一条新账号记录
|
||||||
fn new_account(id: i64, user: &str, password_hash: String) -> std::io::Result<Account> {
|
fn new_account(id: i64, user: &str, password_hash: String) -> std::io::Result<Account> {
|
||||||
validate_account_hash(&password_hash)?;
|
validate_account_hash(&password_hash)?;
|
||||||
Ok(Account {
|
Ok(Account {
|
||||||
@@ -1133,7 +1132,7 @@ fn new_account(id: i64, user: &str, password_hash: String) -> std::io::Result<Ac
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 一张表的索引定义转线上格式
|
/// 一张表的索引定义转线上格式
|
||||||
fn index_wires(schema: &chusql_storage::catalog::TableSchema) -> Vec<IndexWire> {
|
fn index_wires(schema: &crate::catalog::TableSchema) -> Vec<IndexWire> {
|
||||||
let mut out = vec![IndexWire {
|
let mut out = vec![IndexWire {
|
||||||
column: "id".to_string(),
|
column: "id".to_string(),
|
||||||
}];
|
}];
|
||||||
@@ -1148,7 +1147,7 @@ fn index_wires(schema: &chusql_storage::catalog::TableSchema) -> Vec<IndexWire>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 一张表的列统计 -> 线上格式
|
/// 一张表的列统计 -> 线上格式
|
||||||
fn stat_wires(schema: &chusql_storage::catalog::TableSchema) -> Vec<ColumnStatWire> {
|
fn stat_wires(schema: &crate::catalog::TableSchema) -> Vec<ColumnStatWire> {
|
||||||
schema
|
schema
|
||||||
.stats
|
.stats
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1161,6 +1160,7 @@ fn stat_wires(schema: &chusql_storage::catalog::TableSchema) -> Vec<ColumnStatWi
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for Server {
|
impl Drop for Server {
|
||||||
|
/// 析构时保存数据字典
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if let Ok(c) = self.catalog.lock() {
|
if let Ok(c) = self.catalog.lock() {
|
||||||
let _ = c.save(self.catalog_path());
|
let _ = c.save(self.catalog_path());
|
||||||
@@ -1194,6 +1194,7 @@ struct Databases {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Databases {
|
impl Databases {
|
||||||
|
/// 装载系统库与已有的具名库
|
||||||
fn new(cfg: Config) -> std::io::Result<Self> {
|
fn new(cfg: Config) -> std::io::Result<Self> {
|
||||||
// 系统库按需创建:目录、字典与系统表缺失时自动补齐。
|
// 系统库按需创建:目录、字典与系统表缺失时自动补齐。
|
||||||
// 其余库只在 data/databases/<name>/ 下已存在时才装载,启动不会凭空建库。
|
// 其余库只在 data/databases/<name>/ 下已存在时才装载,启动不会凭空建库。
|
||||||
@@ -1226,7 +1227,7 @@ impl Databases {
|
|||||||
Ok(Self { root: cfg, system, named: Mutex::new(named) })
|
Ok(Self { root: cfg, system, named: Mutex::new(named) })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 在 data_dir/<name>/ 下开一个保留库(目录与字典、WAL 一起建起来)
|
/// 按名字打开或新建一个库
|
||||||
fn open(cfg: &Config, name: &str) -> std::io::Result<Server> {
|
fn open(cfg: &Config, name: &str) -> std::io::Result<Server> {
|
||||||
let mut config = cfg.clone();
|
let mut config = cfg.clone();
|
||||||
config.data_dir = cfg.data_dir.join(name);
|
config.data_dir = cfg.data_dir.join(name);
|
||||||
@@ -1241,6 +1242,7 @@ impl Databases {
|
|||||||
Ok(server)
|
Ok(server)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 库级分发:先处理库管理,再转给选中的库
|
||||||
fn request(&self, mut value: serde_json::Value) -> std::io::Result<Response> {
|
fn request(&self, mut value: serde_json::Value) -> std::io::Result<Response> {
|
||||||
use std::io::Error;
|
use std::io::Error;
|
||||||
let method = value.get("method").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
let method = value.get("method").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
@@ -1329,6 +1331,7 @@ impl Databases {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 校验库名是否合法
|
||||||
fn valid_database(name: &str) -> bool {
|
fn valid_database(name: &str) -> bool {
|
||||||
let lower = name.to_ascii_lowercase();
|
let lower = name.to_ascii_lowercase();
|
||||||
let device = matches!(lower.as_str(), "con" | "prn" | "aux" | "nul")
|
let device = matches!(lower.as_str(), "con" | "prn" | "aux" | "nul")
|
||||||
@@ -1339,37 +1342,6 @@ fn valid_database(name: &str) -> bool {
|
|||||||
&& name.as_bytes().first().is_some_and(|c| c.is_ascii_alphabetic() || *c == b'_')
|
&& name.as_bytes().first().is_some_and(|c| c.is_ascii_alphabetic() || *c == b'_')
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 处理一条连接
|
|
||||||
fn handle(server: &Databases, conn: LocalSocketStream) -> std::io::Result<()> {
|
|
||||||
let mut writer = conn.try_clone()?;
|
|
||||||
let reader = BufReader::new(conn);
|
|
||||||
|
|
||||||
for line in reader.lines() {
|
|
||||||
let line = line?;
|
|
||||||
if line.trim().is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let resp = match serde_json::from_str::<serde_json::Value>(&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 {
|
|
||||||
message: format!("parse error: {}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
writeln!(writer, "{}", serde_json::to_string(&resp).unwrap())?;
|
|
||||||
writer.flush()?;
|
|
||||||
}
|
|
||||||
log_debug!(pipe, "connection closed");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 把配置来源打进日志
|
/// 把配置来源打进日志
|
||||||
fn log_config(loaded: &Loaded) {
|
fn log_config(loaded: &Loaded) {
|
||||||
match &loaded.config_path {
|
match &loaded.config_path {
|
||||||
@@ -1381,92 +1353,44 @@ fn log_config(loaded: &Loaded) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 命令行用法
|
/// 进程内存储句柄:一个数据目录一份状态,跨线程共享(内部各自加锁)。
|
||||||
fn print_usage() {
|
pub struct Storage {
|
||||||
println!("chusql-storage: ChuSQL 存储服务");
|
databases: Databases,
|
||||||
println!();
|
|
||||||
println!("用法: chusql-storage [--config <path>]");
|
|
||||||
println!();
|
|
||||||
println!("配置文件固定位置(安装时确定,按系统惯例):");
|
|
||||||
println!(" {}", config::default_config_path().display());
|
|
||||||
println!("该文件存在才读,不存在就用内置默认值(不是错误)。");
|
|
||||||
println!("--config <path> 是唯一的显式覆盖手段;文件不存在或读不了会报错退出。");
|
|
||||||
println!("配置分区: [page] [btree] [buffer] [storage] [server] [log]");
|
|
||||||
println!("--help, -h 显示本帮助。");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 解析命令行:只认 --config <path> / --config=<path> / --help / -h
|
impl Storage {
|
||||||
fn parse_args(args: &[String]) -> Result<Option<String>, String> {
|
/// 打开数据目录并初始化存储
|
||||||
let mut explicit = None;
|
pub fn open(config_path: Option<&str>) -> Result<Storage, String> {
|
||||||
let mut i = 0;
|
let loaded = config::load(config_path)?;
|
||||||
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 args: Vec<String> = 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 <path>],--help 看帮助");
|
|
||||||
std::process::exit(2);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let loaded = match config::load(explicit.as_deref()) {
|
|
||||||
Ok(l) => l,
|
|
||||||
Err(e) => {
|
|
||||||
log_error!(core, "config error: {}", e);
|
|
||||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
log::init();
|
log::init();
|
||||||
log::set_level(loaded.config.log_level);
|
log::set_level(loaded.config.log_level);
|
||||||
|
log_info!(core, "chusql-core-storage {} starting", env!("CARGO_PKG_VERSION"));
|
||||||
log_info!(core, "chusql-storage {} starting", env!("CARGO_PKG_VERSION"));
|
|
||||||
log_config(&loaded);
|
log_config(&loaded);
|
||||||
|
let databases = Databases::new(loaded.config).map_err(|e| e.to_string())?;
|
||||||
let server = Arc::new(Databases::new(loaded.config)?);
|
Ok(Storage { databases })
|
||||||
|
|
||||||
let listener = endpoint::listen(&server.root.pipe_name)?;
|
|
||||||
|
|
||||||
log_info!(
|
|
||||||
core,
|
|
||||||
"listening on {}",
|
|
||||||
endpoint::display(&server.root.pipe_name)
|
|
||||||
);
|
|
||||||
|
|
||||||
for conn in listener.incoming() {
|
|
||||||
let conn = conn?;
|
|
||||||
let server = Arc::clone(&server);
|
|
||||||
thread::spawn(move || {
|
|
||||||
log_debug!(pipe, "connection accepted");
|
|
||||||
if let Err(e) = handle(&server, conn) {
|
|
||||||
log_error!(pipe, "connection error: {}", e);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
/// 一行请求 JSON 换一行响应 JSON
|
||||||
|
pub fn request_line(&self, line: &str) -> String {
|
||||||
|
let response = match serde_json::from_str::<serde_json::Value>(line) {
|
||||||
|
Ok(value) => match self.databases.request(value) {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(e) => Response::Error { message: e.to_string() },
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
log_warn!(core, "bad request line: {}", e);
|
||||||
|
Response::Error {
|
||||||
|
message: format!("parse error: {}", e),
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
match serde_json::to_string(&response) {
|
||||||
|
Ok(text) => text,
|
||||||
|
Err(e) => {
|
||||||
|
log_error!(core, "response encode failed: {}", e);
|
||||||
|
"{\"status\":\"error\",\"message\":\"response encode failed\"}".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
File renamed without changes.
@@ -1,9 +1,10 @@
|
|||||||
use chusql_storage::btree::{fits_in_page, DiskBTree, MAX_KEY_BYTES};
|
use chusql_core_storage::btree::{fits_in_page, DiskBTree, MAX_KEY_BYTES};
|
||||||
use chusql_storage::config::{DEFAULT_BTREE_ORDER, DEFAULT_PAGE_SIZE};
|
use chusql_core_storage::config::{DEFAULT_BTREE_ORDER, DEFAULT_PAGE_SIZE};
|
||||||
use chusql_storage::page::DEFAULT_POOL_SIZE;
|
use chusql_core_storage::page::DEFAULT_POOL_SIZE;
|
||||||
|
|
||||||
// B+ 树测试:字节键、重复键、范围扫描、重开与配置校验。
|
// B+ 树测试:字节键、重复键、范围扫描、重开与配置校验。
|
||||||
|
|
||||||
|
/// 按默认参数打开指定索引文件
|
||||||
fn open(dir: &std::path::Path, name: &str) -> DiskBTree {
|
fn open(dir: &std::path::Path, name: &str) -> DiskBTree {
|
||||||
DiskBTree::open(dir.join(name), DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, DEFAULT_POOL_SIZE).unwrap()
|
DiskBTree::open(dir.join(name), DEFAULT_PAGE_SIZE, DEFAULT_BTREE_ORDER, DEFAULT_POOL_SIZE).unwrap()
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
use chusql_storage::catalog::Catalog;
|
use chusql_core_storage::catalog::Catalog;
|
||||||
use chusql_storage::protocol::Row;
|
use chusql_core_storage::protocol::Row;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
// 数据字典测试:补列、去重、未知表。
|
// 数据字典测试:补列、去重、未知表。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/// 用键值对构造一行
|
||||||
fn row(pairs: &[(&str, serde_json::Value)]) -> Row {
|
fn row(pairs: &[(&str, serde_json::Value)]) -> Row {
|
||||||
let mut m = Row::new();
|
let mut m = Row::new();
|
||||||
for (k, v) in pairs {
|
for (k, v) in pairs {
|
||||||
@@ -85,7 +86,7 @@ fn remove_column_drops_definition_stats_and_index() {
|
|||||||
/// 老类型名并到新写法,参数保留,未知类型原样
|
/// 老类型名并到新写法,参数保留,未知类型原样
|
||||||
#[test]
|
#[test]
|
||||||
fn normalize_type_maps_legacy_names() {
|
fn normalize_type_maps_legacy_names() {
|
||||||
use chusql_storage::catalog::normalize_type;
|
use chusql_core_storage::catalog::normalize_type;
|
||||||
assert_eq!(normalize_type("integer"), "int");
|
assert_eq!(normalize_type("integer"), "int");
|
||||||
assert_eq!(normalize_type("TEXT"), "str");
|
assert_eq!(normalize_type("TEXT"), "str");
|
||||||
assert_eq!(normalize_type("BOOLEAN"), "bool");
|
assert_eq!(normalize_type("BOOLEAN"), "bool");
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use chusql_storage::config::{
|
use chusql_core_storage::config::{self, Config, DEFAULT_BTREE_ORDER, DEFAULT_PAGE_SIZE, Origin};
|
||||||
self, Config, DEFAULT_BTREE_ORDER, DEFAULT_PAGE_SIZE, DEFAULT_PIPE_NAME, Origin,
|
use chusql_core_storage::log::Level;
|
||||||
};
|
|
||||||
use chusql_storage::log::Level;
|
|
||||||
|
|
||||||
// 配置测试:默认值、文件覆盖、环境变量彻底无效、非法值报错。
|
// 配置测试:默认值、文件覆盖、环境变量彻底无效、非法值报错。
|
||||||
|
|
||||||
@@ -27,7 +25,6 @@ fn defaults_without_file() {
|
|||||||
assert_eq!(loaded.config.page_size, DEFAULT_PAGE_SIZE);
|
assert_eq!(loaded.config.page_size, DEFAULT_PAGE_SIZE);
|
||||||
assert_eq!(loaded.config.btree_order, DEFAULT_BTREE_ORDER);
|
assert_eq!(loaded.config.btree_order, DEFAULT_BTREE_ORDER);
|
||||||
assert_eq!(loaded.config.data_dir, config::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);
|
assert_eq!(loaded.config.log_level, Level::Info);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,11 +47,11 @@ fn file_overrides_only_written_keys() {
|
|||||||
assert_eq!(order.2, Origin::Default);
|
assert_eq!(order.2, Origin::Default);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 六项全写都生效
|
/// 五项全写都生效
|
||||||
#[test]
|
#[test]
|
||||||
fn file_all_keys() {
|
fn file_all_keys() {
|
||||||
let _guard = env_guard();
|
let _guard = env_guard();
|
||||||
let text = "[page]\nsize = 8192\n[btree]\norder = 32\n[buffer]\npool_size = 128\n[storage]\ndata_dir = \"mydata\"\n[server]\npipe_name = \"mypipe\"\n[log]\nlevel = \"debug\"\n";
|
let text = "[page]\nsize = 8192\n[btree]\norder = 32\n[buffer]\npool_size = 128\n[storage]\ndata_dir = \"mydata\"\n[log]\nlevel = \"debug\"\n";
|
||||||
let path = PathBuf::from("t.toml");
|
let path = PathBuf::from("t.toml");
|
||||||
let loaded = config::resolve(Some(text), Some(path.clone())).unwrap();
|
let loaded = config::resolve(Some(text), Some(path.clone())).unwrap();
|
||||||
|
|
||||||
@@ -62,7 +59,6 @@ fn file_all_keys() {
|
|||||||
assert_eq!(loaded.config.btree_order, 32);
|
assert_eq!(loaded.config.btree_order, 32);
|
||||||
assert_eq!(loaded.config.pool_size, 128);
|
assert_eq!(loaded.config.pool_size, 128);
|
||||||
assert_eq!(loaded.config.data_dir, PathBuf::from("mydata"));
|
assert_eq!(loaded.config.data_dir, PathBuf::from("mydata"));
|
||||||
assert_eq!(loaded.config.pipe_name, "mypipe");
|
|
||||||
assert_eq!(loaded.config.log_level, Level::Debug);
|
assert_eq!(loaded.config.log_level, Level::Debug);
|
||||||
assert!(
|
assert!(
|
||||||
loaded
|
loaded
|
||||||
@@ -72,7 +68,7 @@ fn file_all_keys() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 环境变量完全无效:这条通道已经删掉,设了 CHUSQL_* 也只认文件/内置默认
|
/// 设了 CHUSQL_* 环境变量也不生效
|
||||||
#[test]
|
#[test]
|
||||||
fn env_vars_are_ignored_entirely() {
|
fn env_vars_are_ignored_entirely() {
|
||||||
let _guard = env_guard();
|
let _guard = env_guard();
|
||||||
@@ -102,7 +98,7 @@ fn env_vars_are_ignored_entirely() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 固定路径按系统惯例:<配置目录>/ChuSQL/chusql.toml
|
/// 固定配置路径在系统配置目录的 ChuSQL 下
|
||||||
#[test]
|
#[test]
|
||||||
fn default_config_path_follows_system_convention() {
|
fn default_config_path_follows_system_convention() {
|
||||||
let _guard = env_guard();
|
let _guard = env_guard();
|
||||||
@@ -121,7 +117,7 @@ fn default_config_path_follows_system_convention() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 默认数据目录也按系统惯例:Windows 落 %LOCALAPPDATA%\ChuSQL\data,Unix 落 XDG 数据目录
|
/// 默认数据目录落地按系统惯例
|
||||||
#[test]
|
#[test]
|
||||||
fn default_data_dir_follows_system_convention() {
|
fn default_data_dir_follows_system_convention() {
|
||||||
let _guard = env_guard();
|
let _guard = env_guard();
|
||||||
@@ -170,7 +166,7 @@ fn explicit_missing_path_is_an_error() {
|
|||||||
assert!(err.contains("chusql-no-such-config-9f21.toml"), "got {}", err);
|
assert!(err.contains("chusql-no-such-config-9f21.toml"), "got {}", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 不给 --config:固定路径没有文件时全用内置默认,且不报错
|
/// 不给 --config 且无配置文件时用内置默认
|
||||||
#[test]
|
#[test]
|
||||||
fn load_without_explicit_path_uses_builtin_defaults() {
|
fn load_without_explicit_path_uses_builtin_defaults() {
|
||||||
let _guard = env_guard();
|
let _guard = env_guard();
|
||||||
@@ -181,12 +177,23 @@ fn load_without_explicit_path_uses_builtin_defaults() {
|
|||||||
} else {
|
} else {
|
||||||
assert_eq!(loaded.config, Config::default());
|
assert_eq!(loaded.config, Config::default());
|
||||||
assert_eq!(loaded.config.data_dir, config::default_data_dir());
|
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.origins.iter().all(|(_, _, o)| *o == Origin::Default));
|
||||||
assert!(loaded.config_path.is_none());
|
assert!(loaded.config_path.is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [server] 分区归别的层管;本层拼写错误要报错
|
||||||
|
#[test]
|
||||||
|
fn server_section_belongs_to_another_layer() {
|
||||||
|
let _guard = env_guard();
|
||||||
|
let db_server = "[server]\nhost = \"0.0.0.0\"\nport = 7778\nmax_rows = 10\n";
|
||||||
|
let loaded = config::resolve(Some(db_server), None).unwrap();
|
||||||
|
assert_eq!(loaded.config.data_dir, config::default_data_dir());
|
||||||
|
|
||||||
|
let typo = "[storage]\ndata_dirs = \"oops\"\n";
|
||||||
|
assert!(config::resolve(Some(typo), None).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
/// 日志等级非法要报错
|
/// 日志等级非法要报错
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_bad_log_level() {
|
fn rejects_bad_log_level() {
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
use chusql_storage::config::DEFAULT_PAGE_SIZE;
|
use chusql_core_storage::config::DEFAULT_PAGE_SIZE;
|
||||||
use chusql_storage::heap::HeapTable;
|
use chusql_core_storage::heap::HeapTable;
|
||||||
use chusql_storage::page::DEFAULT_POOL_SIZE;
|
use chusql_core_storage::page::DEFAULT_POOL_SIZE;
|
||||||
use chusql_storage::protocol::Row;
|
use chusql_core_storage::protocol::Row;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
// 堆表测试:插查、跨页、大行、重开、索引重建。
|
// 堆表测试:插查、跨页、大行、重开、索引重建。
|
||||||
|
|
||||||
|
/// 用键值对构造一行
|
||||||
fn row(pairs: &[(&str, serde_json::Value)]) -> Row {
|
fn row(pairs: &[(&str, serde_json::Value)]) -> Row {
|
||||||
let mut m = Row::new();
|
let mut m = Row::new();
|
||||||
for (k, v) in pairs {
|
for (k, v) in pairs {
|
||||||
@@ -144,7 +145,7 @@ fn replace_all_rebuilds_index() {
|
|||||||
dir.path().join("t.db"),
|
dir.path().join("t.db"),
|
||||||
dir.path().join("t.idx"),
|
dir.path().join("t.idx"),
|
||||||
DEFAULT_PAGE_SIZE,
|
DEFAULT_PAGE_SIZE,
|
||||||
chusql_storage::config::DEFAULT_BTREE_ORDER,
|
chusql_core_storage::config::DEFAULT_BTREE_ORDER,
|
||||||
DEFAULT_POOL_SIZE,
|
DEFAULT_POOL_SIZE,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -176,7 +177,7 @@ fn delete_by_keys_removes_rows() {
|
|||||||
dir.path().join("t.db"),
|
dir.path().join("t.db"),
|
||||||
dir.path().join("t.idx"),
|
dir.path().join("t.idx"),
|
||||||
DEFAULT_PAGE_SIZE,
|
DEFAULT_PAGE_SIZE,
|
||||||
chusql_storage::config::DEFAULT_BTREE_ORDER,
|
chusql_core_storage::config::DEFAULT_BTREE_ORDER,
|
||||||
DEFAULT_POOL_SIZE,
|
DEFAULT_POOL_SIZE,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -214,7 +215,7 @@ fn insert_rows_batch() {
|
|||||||
dir.path().join("t.db"),
|
dir.path().join("t.db"),
|
||||||
dir.path().join("t.idx"),
|
dir.path().join("t.idx"),
|
||||||
DEFAULT_PAGE_SIZE,
|
DEFAULT_PAGE_SIZE,
|
||||||
chusql_storage::config::DEFAULT_BTREE_ORDER,
|
chusql_core_storage::config::DEFAULT_BTREE_ORDER,
|
||||||
DEFAULT_POOL_SIZE,
|
DEFAULT_POOL_SIZE,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -234,7 +235,7 @@ fn insert_rows_batch() {
|
|||||||
assert!(t.insert_rows(&dup).is_err());
|
assert!(t.insert_rows(&dup).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 第二列索引:建索引时要求唯一,建完之后插入/删除都跟着维护
|
/// 第二列索引:建完插入/删除都跟着维护,值可重复
|
||||||
#[test]
|
#[test]
|
||||||
fn secondary_index_is_maintained() {
|
fn secondary_index_is_maintained() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -242,7 +243,7 @@ fn secondary_index_is_maintained() {
|
|||||||
dir.path().join("t.db"),
|
dir.path().join("t.db"),
|
||||||
dir.path().join("t.idx"),
|
dir.path().join("t.idx"),
|
||||||
DEFAULT_PAGE_SIZE,
|
DEFAULT_PAGE_SIZE,
|
||||||
chusql_storage::config::DEFAULT_BTREE_ORDER,
|
chusql_core_storage::config::DEFAULT_BTREE_ORDER,
|
||||||
DEFAULT_POOL_SIZE,
|
DEFAULT_POOL_SIZE,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -258,7 +259,7 @@ fn secondary_index_is_maintained() {
|
|||||||
"code",
|
"code",
|
||||||
&dir.path().join("t.code.idx"),
|
&dir.path().join("t.code.idx"),
|
||||||
DEFAULT_PAGE_SIZE,
|
DEFAULT_PAGE_SIZE,
|
||||||
chusql_storage::config::DEFAULT_BTREE_ORDER,
|
chusql_core_storage::config::DEFAULT_BTREE_ORDER,
|
||||||
DEFAULT_POOL_SIZE,
|
DEFAULT_POOL_SIZE,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -292,7 +293,7 @@ fn secondary_index_is_maintained() {
|
|||||||
"age",
|
"age",
|
||||||
&dir.path().join("d.age.idx"),
|
&dir.path().join("d.age.idx"),
|
||||||
DEFAULT_PAGE_SIZE,
|
DEFAULT_PAGE_SIZE,
|
||||||
chusql_storage::config::DEFAULT_BTREE_ORDER,
|
chusql_core_storage::config::DEFAULT_BTREE_ORDER,
|
||||||
DEFAULT_POOL_SIZE,
|
DEFAULT_POOL_SIZE,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use chusql_storage::config::DEFAULT_PAGE_SIZE;
|
use chusql_core_storage::config::DEFAULT_PAGE_SIZE;
|
||||||
use chusql_storage::page::{Page, PageFile};
|
use chusql_core_storage::page::{Page, PageFile};
|
||||||
|
|
||||||
// 页文件测试:读写、追加、覆写、页大小校验与页缓存。
|
// 页文件测试:读写、追加、覆写、页大小校验与页缓存。
|
||||||
|
|
||||||
@@ -1,18 +1,23 @@
|
|||||||
use std::io::{BufRead, BufReader, Write};
|
use std::cell::RefCell;
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::sync::Arc;
|
||||||
use std::thread;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use interprocess::local_socket::prelude::*;
|
use chusql_core_storage::runtime::Storage;
|
||||||
use interprocess::TryClone;
|
|
||||||
|
|
||||||
// 端点集成测试:起真 server,跑协议、索引与配置(端点由 endpoint 模块统一算)。
|
// 进程内存储集成测试:协议请求、索引、账号与配置。
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
/// 当前线程的存储实例:一个用例一个线程,用例内部按顺序起/停
|
||||||
|
static CURRENT: RefCell<Option<Arc<Storage>>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次会话就是当前线程的存储句柄
|
||||||
|
struct Conn(Arc<Storage>);
|
||||||
|
|
||||||
|
/// 扫描能按列投影,空列返回空行
|
||||||
#[test]
|
#[test]
|
||||||
fn scan_projects_columns_and_preserves_empty_rows() -> Result<(), Box<dyn std::error::Error>> {
|
fn scan_projects_columns_and_preserves_empty_rows() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let pipe = unique_pipe_name();
|
let (_srv, _data) = start_server();
|
||||||
let (_srv, _data) = start_server(&pipe);
|
let mut c = connect()?;
|
||||||
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":"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}]}))?;
|
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 [
|
for (columns, expected) in [
|
||||||
@@ -27,11 +32,11 @@ fn scan_projects_columns_and_preserves_empty_rows() -> Result<(), Box<dyn std::e
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 删列不改堆字节,重启后不再暴露该列
|
||||||
#[test]
|
#[test]
|
||||||
fn drop_column_keeps_heap_bytes_and_hides_values_after_restart() -> Result<(), Box<dyn std::error::Error>> {
|
fn drop_column_keeps_heap_bytes_and_hides_values_after_restart() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let pipe = unique_pipe_name();
|
let (server, data) = start_server();
|
||||||
let (server, data) = start_server(&pipe);
|
let mut c = connect()?;
|
||||||
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":"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}]}))?;
|
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"] {
|
for column in ["name", "age"] {
|
||||||
@@ -43,8 +48,8 @@ fn drop_column_keeps_heap_bytes_and_hides_values_after_restart() -> Result<(), B
|
|||||||
assert_eq!(std::fs::read(&heap)?, before, "DROP COLUMN must not rewrite heap pages");
|
assert_eq!(std::fs::read(&heap)?, before, "DROP COLUMN must not rewrite heap pages");
|
||||||
drop(c);
|
drop(c);
|
||||||
drop(server);
|
drop(server);
|
||||||
let _server = start_server_in(&pipe, data.path());
|
let _server = start_server_in(data.path());
|
||||||
let mut c = connect(&pipe)?;
|
let mut c = connect()?;
|
||||||
for request in [
|
for request in [
|
||||||
serde_json::json!({"method":"scan","table":"items"}),
|
serde_json::json!({"method":"scan","table":"items"}),
|
||||||
serde_json::json!({"method":"range_by_index","table":"items","column":"age","lo":0}),
|
serde_json::json!({"method":"range_by_index","table":"items","column":"age","lo":0}),
|
||||||
@@ -64,12 +69,12 @@ fn drop_column_keeps_heap_bytes_and_hides_values_after_restart() -> Result<(), B
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 各库字典与行互相隔离,删库不影响别库
|
||||||
#[test]
|
#[test]
|
||||||
fn databases_isolate_catalog_and_rows() -> Result<(), Box<dyn std::error::Error>> {
|
fn databases_isolate_catalog_and_rows() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let pipe = unique_pipe_name();
|
let (_srv, data) = start_server();
|
||||||
let (_srv, data) = start_server(&pipe);
|
let mut c = connect()?;
|
||||||
let mut c = connect(&pipe)?;
|
let request = |c: &mut Conn, value: serde_json::Value| -> Result<serde_json::Value, serde_json::Error> {
|
||||||
let request = |c: &mut LocalSocketStream, value: serde_json::Value| -> Result<serde_json::Value, serde_json::Error> {
|
|
||||||
serde_json::from_str(&send(c, &value.to_string()))
|
serde_json::from_str(&send(c, &value.to_string()))
|
||||||
};
|
};
|
||||||
for database in ["alpha", "beta"] {
|
for database in ["alpha", "beta"] {
|
||||||
@@ -90,12 +95,12 @@ fn databases_isolate_catalog_and_rows() -> Result<(), Box<dyn std::error::Error>
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// 隐藏列的 WAL 重放两次幂等,且不动堆文件
|
||||||
#[test]
|
#[test]
|
||||||
fn hidden_column_wal_replays_twice_without_touching_heap() -> Result<(), Box<dyn std::error::Error>> {
|
fn hidden_column_wal_replays_twice_without_touching_heap() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
use chusql_storage::wal::{Wal, WalOp};
|
use chusql_core_storage::wal::{Wal, WalOp};
|
||||||
let pipe = unique_pipe_name();
|
let (server, data) = start_server();
|
||||||
let (server, data) = start_server(&pipe);
|
let mut c = connect()?;
|
||||||
let mut c = connect(&pipe)?;
|
|
||||||
request_ok(&mut c, serde_json::json!({"method":"insert","table":"items","row":{"id":1,"secret":"old"}}))?;
|
request_ok(&mut c, serde_json::json!({"method":"insert","table":"items","row":{"id":1,"secret":"old"}}))?;
|
||||||
drop(c);
|
drop(c);
|
||||||
drop(server);
|
drop(server);
|
||||||
@@ -109,8 +114,8 @@ fn hidden_column_wal_replays_twice_without_touching_heap() -> Result<(), Box<dyn
|
|||||||
assert_eq!(wal.read()?, Some(op));
|
assert_eq!(wal.read()?, Some(op));
|
||||||
assert!(std::fs::metadata(wal.path())?.len() < 100);
|
assert!(std::fs::metadata(wal.path())?.len() < 100);
|
||||||
drop(wal);
|
drop(wal);
|
||||||
let server = start_server_in(&pipe, data.path());
|
let server = start_server_in(data.path());
|
||||||
let mut c = connect(&pipe)?;
|
let mut c = connect()?;
|
||||||
let result: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
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}]));
|
assert_eq!(result["rows"], serde_json::json!([{"id":1}]));
|
||||||
let after = std::fs::read(&heap)?;
|
let after = std::fs::read(&heap)?;
|
||||||
@@ -129,11 +134,11 @@ fn hidden_column_wal_replays_twice_without_touching_heap() -> Result<(), Box<dyn
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 删列失败时保留 WAL,重启后补做并放行
|
||||||
#[test]
|
#[test]
|
||||||
fn hidden_column_failure_preserves_wal_until_recovery() -> Result<(), Box<dyn std::error::Error>> {
|
fn hidden_column_failure_preserves_wal_until_recovery() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let pipe = unique_pipe_name();
|
let (server, data) = start_server();
|
||||||
let (server, data) = start_server(&pipe);
|
let mut c = connect()?;
|
||||||
let mut c = connect(&pipe)?;
|
|
||||||
request_ok(&mut c, serde_json::json!({"method":"insert","table":"items","row":{"id":1,"secret":"old"}}))?;
|
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");
|
let index = data.path().join("databases/main/items.secret.idx");
|
||||||
std::fs::create_dir(&index)?;
|
std::fs::create_dir(&index)?;
|
||||||
@@ -146,18 +151,18 @@ fn hidden_column_failure_preserves_wal_until_recovery() -> Result<(), Box<dyn st
|
|||||||
drop(c);
|
drop(c);
|
||||||
drop(server);
|
drop(server);
|
||||||
std::fs::remove_dir(index)?;
|
std::fs::remove_dir(index)?;
|
||||||
let _server = start_server_in(&pipe, data.path());
|
let _server = start_server_in(data.path());
|
||||||
let mut c = connect(&pipe)?;
|
let mut c = connect()?;
|
||||||
let rows: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
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}]));
|
assert_eq!(rows["rows"], serde_json::json!([{"id":1}]));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 删带索引的列会连索引文件一起删掉
|
||||||
#[test]
|
#[test]
|
||||||
fn drop_column_with_a_live_index_removes_the_index_file() -> Result<(), Box<dyn std::error::Error>> {
|
fn drop_column_with_a_live_index_removes_the_index_file() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let pipe = unique_pipe_name();
|
let (_srv, data) = start_server();
|
||||||
let (_srv, data) = start_server(&pipe);
|
let mut c = connect()?;
|
||||||
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":"insert","table":"items","row":{"id":1,"code":7}}))?;
|
||||||
request_ok(&mut c, serde_json::json!({"method":"create_index","table":"items","column":"code"}))?;
|
request_ok(&mut c, serde_json::json!({"method":"create_index","table":"items","column":"code"}))?;
|
||||||
let index = data.path().join("databases/main/items.code.idx");
|
let index = data.path().join("databases/main/items.code.idx");
|
||||||
@@ -176,11 +181,11 @@ fn drop_column_with_a_live_index_removes_the_index_file() -> Result<(), Box<dyn
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 整理表会重写页面,清掉隐藏列字节
|
||||||
#[test]
|
#[test]
|
||||||
fn compact_reclaims_hidden_column_bytes() -> Result<(), Box<dyn std::error::Error>> {
|
fn compact_reclaims_hidden_column_bytes() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let pipe = unique_pipe_name();
|
let (server, data) = start_server();
|
||||||
let (server, data) = start_server(&pipe);
|
let mut c = connect()?;
|
||||||
let mut c = connect(&pipe)?;
|
|
||||||
for id in 1..=3 {
|
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":"insert","table":"items","row":{"id":id,"secret":"padding","note":"n"}}))?;
|
||||||
}
|
}
|
||||||
@@ -200,18 +205,18 @@ fn compact_reclaims_hidden_column_bytes() -> Result<(), Box<dyn std::error::Erro
|
|||||||
request_ok(&mut c, serde_json::json!({"method":"insert","table":"items","row":{"id":4,"note":"x"}}))?;
|
request_ok(&mut c, serde_json::json!({"method":"insert","table":"items","row":{"id":4,"note":"x"}}))?;
|
||||||
drop(c);
|
drop(c);
|
||||||
drop(server);
|
drop(server);
|
||||||
let _server = start_server_in(&pipe, data.path());
|
let _server = start_server_in(data.path());
|
||||||
let mut c = connect(&pipe)?;
|
let mut c = connect()?;
|
||||||
let rows: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
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);
|
assert_eq!(rows["rows"].as_array().map(Vec::len), Some(4), "rows survive compaction: {}", rows);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 启动时自动整理带隐藏列的表
|
||||||
#[test]
|
#[test]
|
||||||
fn startup_compaction_reclaims_dropped_columns() -> Result<(), Box<dyn std::error::Error>> {
|
fn startup_compaction_reclaims_dropped_columns() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let pipe = unique_pipe_name();
|
let (server, data) = start_server();
|
||||||
let (server, data) = start_server(&pipe);
|
let mut c = connect()?;
|
||||||
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":"insert","table":"items","row":{"id":1,"secret":"padding"}}))?;
|
||||||
request_ok(&mut c, serde_json::json!({"method":"drop_column","table":"items","column":"secret"}))?;
|
request_ok(&mut c, serde_json::json!({"method":"drop_column","table":"items","column":"secret"}))?;
|
||||||
let heap = data.path().join("databases/main/items.db");
|
let heap = data.path().join("databases/main/items.db");
|
||||||
@@ -219,8 +224,8 @@ fn startup_compaction_reclaims_dropped_columns() -> Result<(), Box<dyn std::erro
|
|||||||
drop(c);
|
drop(c);
|
||||||
drop(server);
|
drop(server);
|
||||||
|
|
||||||
let _server = start_server_in(&pipe, data.path());
|
let _server = start_server_in(data.path());
|
||||||
let mut c = connect(&pipe)?;
|
let mut c = connect()?;
|
||||||
assert!(
|
assert!(
|
||||||
!std::fs::read(&heap)?.windows(6).any(|w| w == &b"secret"[..]),
|
!std::fs::read(&heap)?.windows(6).any(|w| w == &b"secret"[..]),
|
||||||
"startup must compact tables that still carry hidden columns"
|
"startup must compact tables that still carry hidden columns"
|
||||||
@@ -230,11 +235,11 @@ fn startup_compaction_reclaims_dropped_columns() -> Result<(), Box<dyn std::erro
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 非法库名被拒;system 是唯一保留库
|
||||||
#[test]
|
#[test]
|
||||||
fn databases_reject_unsafe_names_and_keep_only_system_reserved() -> Result<(), Box<dyn std::error::Error>> {
|
fn databases_reject_unsafe_names_and_keep_only_system_reserved() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let pipe = unique_pipe_name();
|
let (_srv, data) = start_server();
|
||||||
let (_srv, data) = start_server(&pipe);
|
let mut c = connect()?;
|
||||||
let mut c = connect(&pipe)?;
|
|
||||||
for database in ["", "../escape", "a/b", "a.b", "CON", "__chusql_users"] {
|
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()))?;
|
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}");
|
assert_eq!(response["status"], "error", "{database}: {response}");
|
||||||
@@ -255,11 +260,11 @@ fn databases_reject_unsafe_names_and_keep_only_system_reserved() -> Result<(), B
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 账号只存在 system 库,默认工作库不存在
|
||||||
#[test]
|
#[test]
|
||||||
fn system_database_owns_accounts_and_nothing_is_default() -> Result<(), Box<dyn std::error::Error>> {
|
fn system_database_owns_accounts_and_nothing_is_default() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let pipe = unique_pipe_name();
|
let (_srv, data) = start_server();
|
||||||
let (_srv, data) = start_server(&pipe);
|
let mut c = connect()?;
|
||||||
let mut c = connect(&pipe)?;
|
|
||||||
// 启动只建 system,另一个是测试自己建的 main
|
// 启动只建 system,另一个是测试自己建的 main
|
||||||
let listed: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"list_databases"}"#))?;
|
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"]));
|
assert_eq!(listed["tables"], serde_json::json!(["main", "system"]));
|
||||||
@@ -278,12 +283,11 @@ fn system_database_owns_accounts_and_nothing_is_default() -> Result<(), Box<dyn
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 没选库的裸表名请求会被拒;限定名(db.table)不用选库
|
/// 裸表名没选库会被拒;限定名不用选库
|
||||||
#[test]
|
#[test]
|
||||||
fn unqualified_requests_need_a_selected_database() -> Result<(), Box<dyn std::error::Error>> {
|
fn unqualified_requests_need_a_selected_database() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let pipe = unique_pipe_name();
|
let (_srv, _data) = start_server();
|
||||||
let (_srv, _data) = start_server(&pipe);
|
let mut c = connect()?;
|
||||||
let mut c = connect(&pipe)?;
|
|
||||||
let response: serde_json::Value = serde_json::from_str(&send_raw(&mut c, r#"{"method":"scan","table":"items"}"#))?;
|
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_eq!(response["status"], "error", "{response}");
|
||||||
assert!(response["message"].as_str().unwrap_or("").contains("no database selected"), "{response}");
|
assert!(response["message"].as_str().unwrap_or("").contains("no database selected"), "{response}");
|
||||||
@@ -298,85 +302,59 @@ fn unqualified_requests_need_a_selected_database() -> Result<(), Box<dyn std::er
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 发一条请求并断言状态是 ok
|
/// 发一条请求并断言状态是 ok
|
||||||
fn request_ok(c: &mut LocalSocketStream, value: serde_json::Value) -> Result<(), Box<dyn std::error::Error>> {
|
fn request_ok(c: &mut Conn, value: serde_json::Value) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let response: serde_json::Value = serde_json::from_str(&send(c, &value.to_string()))?;
|
let response: serde_json::Value = serde_json::from_str(&send(c, &value.to_string()))?;
|
||||||
assert_eq!(response["status"], "ok", "{value}: {response}");
|
assert_eq!(response["status"], "ok", "{value}: {response}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 唯一管道名,避免测试互抢
|
/// 一次存储实例的持有者
|
||||||
fn unique_pipe_name() -> String {
|
struct ServerProc;
|
||||||
let id = std::process::id();
|
|
||||||
let ns = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos();
|
|
||||||
format!("chusql-test-{}-{}", id, ns)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ServerProc(Child);
|
|
||||||
|
|
||||||
impl Drop for ServerProc {
|
impl Drop for ServerProc {
|
||||||
/// 测试结束杀掉子进程
|
/// 实例由本线程 CURRENT 管,这里不用清理
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {}
|
||||||
let _ = self.0.kill();
|
|
||||||
let _ = self.0.wait();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 起 server,等管道可连
|
/// 起存储,返回句柄与数据目录
|
||||||
fn start_server(pipe: &str) -> (ServerProc, tempfile::TempDir) {
|
fn start_server() -> (ServerProc, tempfile::TempDir) {
|
||||||
let data = tempfile::tempdir().unwrap();
|
let data = tempfile::tempdir().unwrap();
|
||||||
let server = start_server_in(pipe, data.path());
|
let server = start_server_in(data.path());
|
||||||
(server, data)
|
(server, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start_server_in(pipe: &str, data: &std::path::Path) -> ServerProc {
|
/// 在指定数据目录起存储并建测试库
|
||||||
let config = write_config(data, pipe);
|
fn start_server_in(data: &std::path::Path) -> ServerProc {
|
||||||
let child = Command::new(env!("CARGO_BIN_EXE_chusql-storage"))
|
open_storage(&write_config(data));
|
||||||
.arg("--config")
|
create_fixture_database();
|
||||||
.arg(&config)
|
ServerProc
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.spawn()
|
|
||||||
.expect("failed to spawn server");
|
|
||||||
|
|
||||||
let srv = ServerProc(child);
|
|
||||||
|
|
||||||
for _ in 0..100 {
|
|
||||||
if connect(pipe).is_ok() {
|
|
||||||
// 服务启动只建 system 库,测试库在这里显式建
|
|
||||||
create_fixture_database(pipe);
|
|
||||||
return srv;
|
|
||||||
}
|
|
||||||
thread::sleep(Duration::from_millis(50));
|
|
||||||
}
|
|
||||||
panic!("server did not start within 5s");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 写一份配置给子进程:管名与数据目录都走 TOML,
|
/// 按配置文件打开存储,替换本线程上一个实例
|
||||||
/// 路径用单引号字面量串,免得 Windows 反斜杠要转义。
|
fn open_storage(config: &std::path::Path) {
|
||||||
fn write_config(data: &std::path::Path, pipe: &str) -> std::path::PathBuf {
|
CURRENT.with(|slot| *slot.borrow_mut() = None);
|
||||||
|
let storage = Arc::new(Storage::open(Some(config.to_str().unwrap())).expect("open storage"));
|
||||||
|
CURRENT.with(|slot| *slot.borrow_mut() = Some(storage));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成只写数据目录的临时配置文件
|
||||||
|
fn write_config(data: &std::path::Path) -> std::path::PathBuf {
|
||||||
let id = std::process::id();
|
let id = std::process::id();
|
||||||
let ns = std::time::SystemTime::now()
|
let ns = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_nanos();
|
.as_nanos();
|
||||||
let path = data.join(format!("chusql-{}-{}.toml", id, ns));
|
let path = data.join(format!("chusql-{}-{}.toml", id, ns));
|
||||||
let text = format!(
|
let text = format!("[storage]\ndata_dir = '{}'\n", data.display());
|
||||||
"[server]\npipe_name = '{}'\n[storage]\ndata_dir = '{}'\n",
|
|
||||||
pipe,
|
|
||||||
data.display()
|
|
||||||
);
|
|
||||||
std::fs::write(&path, text).unwrap();
|
std::fs::write(&path, text).unwrap();
|
||||||
path
|
path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 命名库重启后仍在,账号仍是全局的
|
||||||
#[test]
|
#[test]
|
||||||
fn named_database_survives_restart_and_accounts_stay_global() -> Result<(), Box<dyn std::error::Error>> {
|
fn named_database_survives_restart_and_accounts_stay_global() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let pipe = unique_pipe_name();
|
let (server, data) = start_server();
|
||||||
let (server, data) = start_server(&pipe);
|
let mut c = connect()?;
|
||||||
let mut c = connect(&pipe)?;
|
|
||||||
for request in [
|
for request in [
|
||||||
serde_json::json!({"method":"create_database","database":"sales"}),
|
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":"create_table","database":"sales","table":"items","columns":[{"name":"id","ty":"int"}]}),
|
||||||
@@ -387,8 +365,8 @@ fn named_database_survives_restart_and_accounts_stay_global() -> Result<(), Box<
|
|||||||
}
|
}
|
||||||
drop(c);
|
drop(c);
|
||||||
drop(server);
|
drop(server);
|
||||||
let _restarted = start_server_in(&pipe, data.path());
|
let _restarted = start_server_in(data.path());
|
||||||
let mut c = connect(&pipe)?;
|
let mut c = connect()?;
|
||||||
let rows: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"sales.items"}"#))?;
|
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}]));
|
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"}"#))?;
|
let accounts: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"accounts_list","database":"sales"}"#))?;
|
||||||
@@ -398,16 +376,17 @@ fn named_database_survives_restart_and_accounts_stay_global() -> Result<(), Box<
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 连到指定端点的存储进程。端点规则与 server 侧共用 endpoint 模块,
|
/// 连到本线程当前的存储:进程内实现就是本线程当前句柄
|
||||||
/// 所以 Windows 的具名管道与 Unix 的套接字文件都由同一处决定。
|
fn connect() -> std::io::Result<Conn> {
|
||||||
fn connect(pipe: &str) -> std::io::Result<LocalSocketStream> {
|
CURRENT.with(|slot| slot.borrow().clone())
|
||||||
chusql_storage::endpoint::connect(pipe)
|
.map(Conn)
|
||||||
|
.ok_or_else(|| std::io::Error::other("storage not started"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试自己的库名:服务启动只建 system 库,测试显式建这一个
|
/// 测试用的库名:system 之外的库由测试自建
|
||||||
const FIXTURE_DATABASE: &str = "main";
|
const FIXTURE_DATABASE: &str = "main";
|
||||||
|
|
||||||
/// 请求里没写库名就补上测试库:服务不再自带默认库,裸表名必须先选库
|
/// 请求没写库名时补上测试库
|
||||||
fn with_default_database(line: &str) -> String {
|
fn with_default_database(line: &str) -> String {
|
||||||
match serde_json::from_str::<serde_json::Value>(line) {
|
match serde_json::from_str::<serde_json::Value>(line) {
|
||||||
Ok(serde_json::Value::Object(mut object)) => {
|
Ok(serde_json::Value::Object(mut object)) => {
|
||||||
@@ -419,36 +398,28 @@ fn with_default_database(line: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 连通后显式建测试库;重启时库已存在,报错忽略
|
/// 连通后显式建测试库;重启时库已存在,报错忽略
|
||||||
fn create_fixture_database(pipe: &str) {
|
fn create_fixture_database() {
|
||||||
if let Ok(mut stream) = connect(pipe) {
|
if let Ok(mut c) = connect() {
|
||||||
let _ = send_raw(&mut stream, &format!(r#"{{"method":"create_database","database":"{FIXTURE_DATABASE}"}}"#));
|
let _ = send_raw(&mut c, &format!(r#"{{"method":"create_database","database":"{FIXTURE_DATABASE}"}}"#));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 发一行请求读一行响应(没写库名就补测试库)
|
/// 发一行请求读一行响应(没写库名就补测试库)
|
||||||
fn send(stream: &mut LocalSocketStream, line: &str) -> String {
|
fn send(c: &mut Conn, line: &str) -> String {
|
||||||
send_raw(stream, &with_default_database(line))
|
send_raw(c, &with_default_database(line))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 原样发一行请求,不补库名——用来验证"没选库"的行为
|
/// 原样发一行请求,不补库名——用来验证"没选库"的行为
|
||||||
fn send_raw(stream: &mut LocalSocketStream, line: &str) -> String {
|
fn send_raw(c: &mut Conn, line: &str) -> String {
|
||||||
stream.write_all(line.as_bytes()).unwrap();
|
c.0.request_line(line).trim().to_string()
|
||||||
stream.write_all(b"\n").unwrap();
|
|
||||||
stream.flush().unwrap();
|
|
||||||
|
|
||||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
||||||
let mut buf = String::new();
|
|
||||||
reader.read_line(&mut buf).unwrap();
|
|
||||||
buf.trim().to_string()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ping 回 pong
|
/// 只有字典登记的表才算存在,孤儿文件不算
|
||||||
#[test]
|
#[test]
|
||||||
fn catalog_is_the_only_table_authority() -> std::io::Result<()> {
|
fn catalog_is_the_only_table_authority() -> std::io::Result<()> {
|
||||||
let pipe = unique_pipe_name();
|
let (_srv, data) = start_server();
|
||||||
let (_srv, data) = start_server(&pipe);
|
|
||||||
std::fs::write(data.path().join("databases/main/orphan.db"), b"orphan")?;
|
std::fs::write(data.path().join("databases/main/orphan.db"), b"orphan")?;
|
||||||
let mut c = connect(&pipe)?;
|
let mut c = connect()?;
|
||||||
for method in ["scan", "describe_table"] {
|
for method in ["scan", "describe_table"] {
|
||||||
let result = send(&mut c, &format!(r#"{{"method":"{method}","table":"orphan"}}"#));
|
let result = send(&mut c, &format!(r#"{{"method":"{method}","table":"orphan"}}"#));
|
||||||
assert!(result.contains("unknown table"), "{result}");
|
assert!(result.contains("unknown table"), "{result}");
|
||||||
@@ -458,11 +429,11 @@ fn catalog_is_the_only_table_authority() -> std::io::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 保留系统表拒绝所有通用操作
|
||||||
#[test]
|
#[test]
|
||||||
fn reserved_tables_reject_every_generic_operation() -> std::io::Result<()> {
|
fn reserved_tables_reject_every_generic_operation() -> std::io::Result<()> {
|
||||||
let pipe = unique_pipe_name();
|
let (_srv, _data) = start_server();
|
||||||
let (_srv, _data) = start_server(&pipe);
|
let mut c = connect()?;
|
||||||
let mut c = connect(&pipe)?;
|
|
||||||
for method in ["scan", "insert", "insert_batch", "delete_keys", "lookup_by_index",
|
for method in ["scan", "insert", "insert_batch", "delete_keys", "lookup_by_index",
|
||||||
"replace_all", "describe_table", "create_table", "drop_table", "create_index",
|
"replace_all", "describe_table", "create_table", "drop_table", "create_index",
|
||||||
"drop_index", "drop_column"] {
|
"drop_index", "drop_column"] {
|
||||||
@@ -474,11 +445,11 @@ fn reserved_tables_reject_every_generic_operation() -> std::io::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 通用接口拒绝表名与列名里的路径别名
|
||||||
#[test]
|
#[test]
|
||||||
fn generic_ipc_rejects_path_aliases() -> std::io::Result<()> {
|
fn generic_ipc_rejects_path_aliases() -> std::io::Result<()> {
|
||||||
let pipe = unique_pipe_name();
|
let (_srv, _data) = start_server();
|
||||||
let (_srv, _data) = start_server(&pipe);
|
let mut c = connect()?;
|
||||||
let mut c = connect(&pipe)?;
|
|
||||||
for table in ["../__chusql_users", "__CHUSQL_USERS", "__chusql_users."] {
|
for table in ["../__chusql_users", "__CHUSQL_USERS", "__chusql_users."] {
|
||||||
let result = send(&mut c, &serde_json::json!({"method":"scan", "table":table}).to_string());
|
let result = send(&mut c, &serde_json::json!({"method":"scan", "table":table}).to_string());
|
||||||
assert!(result.contains("error"), "{result}");
|
assert!(result.contains("error"), "{result}");
|
||||||
@@ -488,11 +459,11 @@ fn generic_ipc_rejects_path_aliases() -> std::io::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 账号增改删,且普通列表接口看不到账号表
|
||||||
#[test]
|
#[test]
|
||||||
fn account_create_reset_drop_and_hidden_from_ordinary_api() -> Result<(), Box<dyn std::error::Error>> {
|
fn account_create_reset_drop_and_hidden_from_ordinary_api() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let pipe = unique_pipe_name();
|
let (_srv, _data) = start_server();
|
||||||
let (_srv, _data) = start_server(&pipe);
|
let mut c = connect()?;
|
||||||
let mut c = connect(&pipe)?;
|
|
||||||
assert_eq!(serde_json::from_str::<serde_json::Value>(&send(&mut c, r#"{"method":"accounts_list"}"#))?["accounts"], serde_json::json!([]));
|
assert_eq!(serde_json::from_str::<serde_json::Value>(&send(&mut c, r#"{"method":"accounts_list"}"#))?["accounts"], serde_json::json!([]));
|
||||||
let create = r#"{"method":"account_create","user":"Alice","password_hash":"hash-one"}"#;
|
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))?;
|
let created: serde_json::Value = serde_json::from_str(&send(&mut c, create))?;
|
||||||
@@ -518,28 +489,19 @@ fn account_create_reset_drop_and_hidden_from_ordinary_api() -> Result<(), Box<dy
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn restart_at(pipe: &str, data: &std::path::Path) -> std::io::Result<ServerProc> {
|
/// 用同一份数据目录重开一次存储
|
||||||
let child = Command::new(env!("CARGO_BIN_EXE_chusql-storage"))
|
fn restart_at(data: &std::path::Path) -> std::io::Result<ServerProc> {
|
||||||
.arg("--config")
|
open_storage(&write_config(data));
|
||||||
.arg(write_config(data, pipe))
|
create_fixture_database();
|
||||||
.stdout(Stdio::null()).stderr(Stdio::null()).spawn()?;
|
Ok(ServerProc)
|
||||||
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"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 账号快照 WAL 重放两次不产生重复账号
|
||||||
#[test]
|
#[test]
|
||||||
fn account_snapshot_replays_twice_without_duplicating_accounts() -> Result<(), Box<dyn std::error::Error>> {
|
fn account_snapshot_replays_twice_without_duplicating_accounts() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
use chusql_storage::{protocol::Account, wal::{Wal, WalOp}};
|
use chusql_core_storage::{protocol::Account, wal::{Wal, WalOp}};
|
||||||
let pipe = unique_pipe_name();
|
let (server, data) = start_server();
|
||||||
let (server, data) = start_server(&pipe);
|
let mut c = connect()?;
|
||||||
let mut c = connect(&pipe)?;
|
|
||||||
let initial = send(&mut c, r#"{"method":"account_create","user":"alice","password_hash":"hash-one"}"#);
|
let initial = send(&mut c, r#"{"method":"account_create","user":"alice","password_hash":"hash-one"}"#);
|
||||||
assert!(initial.contains("accounts"), "{initial}");
|
assert!(initial.contains("accounts"), "{initial}");
|
||||||
let changed: serde_json::Value = serde_json::from_str(&send(&mut c,
|
let changed: serde_json::Value = serde_json::from_str(&send(&mut c,
|
||||||
@@ -554,8 +516,8 @@ fn account_snapshot_replays_twice_without_duplicating_accounts() -> Result<(), B
|
|||||||
drop(wal);
|
drop(wal);
|
||||||
std::fs::write(data.path().join("system/__chusql_users.idx"), b"torn index")?;
|
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")?;
|
std::fs::write(data.path().join("system/__chusql_users.db"), b"torn heap")?;
|
||||||
let restarted = restart_at(&pipe, data.path())?;
|
let restarted = restart_at(data.path())?;
|
||||||
let mut connection = connect(&pipe)?;
|
let mut connection = connect()?;
|
||||||
let actual: serde_json::Value = serde_json::from_str(&send(&mut connection, r#"{"method":"accounts_list"}"#))?;
|
let actual: serde_json::Value = serde_json::from_str(&send(&mut connection, r#"{"method":"accounts_list"}"#))?;
|
||||||
assert_eq!(actual, changed);
|
assert_eq!(actual, changed);
|
||||||
assert_eq!(std::fs::metadata(data.path().join("system/wal.log"))?.len(), 0);
|
assert_eq!(std::fs::metadata(data.path().join("system/wal.log"))?.len(), 0);
|
||||||
@@ -565,11 +527,11 @@ fn account_snapshot_replays_twice_without_duplicating_accounts() -> Result<(), B
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 账号操作失败时保留 WAL 并挡住写入
|
||||||
#[test]
|
#[test]
|
||||||
fn account_failure_preserves_wal_and_blocks_writes() -> Result<(), Box<dyn std::error::Error>> {
|
fn account_failure_preserves_wal_and_blocks_writes() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let pipe = unique_pipe_name();
|
let (server, data) = start_server();
|
||||||
let (server, data) = start_server(&pipe);
|
let mut c = connect()?;
|
||||||
let mut c = connect(&pipe)?;
|
|
||||||
std::fs::create_dir(data.path().join("system/catalog.json.pending"))?;
|
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"}"#);
|
let result = send(&mut c, r#"{"method":"account_create","user":"alice","password_hash":"hash-one"}"#);
|
||||||
assert!(result.contains("error"), "{result}");
|
assert!(result.contains("error"), "{result}");
|
||||||
@@ -583,30 +545,30 @@ fn account_failure_preserves_wal_and_blocks_writes() -> Result<(), Box<dyn std::
|
|||||||
drop(c);
|
drop(c);
|
||||||
drop(server);
|
drop(server);
|
||||||
std::fs::remove_dir(data.path().join("system/catalog.json.pending"))?;
|
std::fs::remove_dir(data.path().join("system/catalog.json.pending"))?;
|
||||||
let _restarted = restart_at(&pipe, data.path())?;
|
let _restarted = restart_at(data.path())?;
|
||||||
let mut c = connect(&pipe)?;
|
let mut c = connect()?;
|
||||||
let recovered = send(&mut c, r#"{"method":"accounts_list"}"#);
|
let recovered = send(&mut c, r#"{"method":"accounts_list"}"#);
|
||||||
assert!(recovered.contains("hash-one"), "{recovered}");
|
assert!(recovered.contains("hash-one"), "{recovered}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 账号文件已存在时拒绝建账号
|
||||||
#[test]
|
#[test]
|
||||||
fn account_create_rejects_reserved_file_collision() -> std::io::Result<()> {
|
fn account_create_rejects_reserved_file_collision() -> std::io::Result<()> {
|
||||||
let pipe = unique_pipe_name();
|
let (_server, data) = start_server();
|
||||||
let (_server, data) = start_server(&pipe);
|
|
||||||
std::fs::write(data.path().join("system/__chusql_users.db"), b"existing")?;
|
std::fs::write(data.path().join("system/__chusql_users.db"), b"existing")?;
|
||||||
let mut c = connect(&pipe)?;
|
let mut c = connect()?;
|
||||||
let result = send(&mut c, r#"{"method":"account_create","user":"alice","password_hash":"hash-one"}"#);
|
let result = send(&mut c, r#"{"method":"account_create","user":"alice","password_hash":"hash-one"}"#);
|
||||||
assert!(result.contains("collision"), "{result}");
|
assert!(result.contains("collision"), "{result}");
|
||||||
assert_eq!(std::fs::read(data.path().join("system/__chusql_users.db"))?, b"existing");
|
assert_eq!(std::fs::read(data.path().join("system/__chusql_users.db"))?, b"existing");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 超长密码哈希在写 WAL 之前就被拒
|
||||||
#[test]
|
#[test]
|
||||||
fn oversized_account_hash_is_rejected_before_wal() -> Result<(), Box<dyn std::error::Error>> {
|
fn oversized_account_hash_is_rejected_before_wal() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let pipe = unique_pipe_name();
|
let (_server, data) = start_server();
|
||||||
let (_server, data) = start_server(&pipe);
|
let mut c = connect()?;
|
||||||
let mut c = connect(&pipe)?;
|
|
||||||
let result = send(&mut c, &serde_json::json!({"method":"account_create", "user":"alice",
|
let result = send(&mut c, &serde_json::json!({"method":"account_create", "user":"alice",
|
||||||
"password_hash":"a".repeat(300)}).to_string());
|
"password_hash":"a".repeat(300)}).to_string());
|
||||||
assert!(result.contains("invalid account credential"), "{result}");
|
assert!(result.contains("invalid account credential"), "{result}");
|
||||||
@@ -616,16 +578,16 @@ fn oversized_account_hash_is_rejected_before_wal() -> Result<(), Box<dyn std::er
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 字典里已有同名普通表时拒绝建账号
|
||||||
#[test]
|
#[test]
|
||||||
fn account_create_rejects_existing_ordinary_catalog_entry() -> std::io::Result<()> {
|
fn account_create_rejects_existing_ordinary_catalog_entry() -> std::io::Result<()> {
|
||||||
let data = tempfile::tempdir()?;
|
let data = tempfile::tempdir()?;
|
||||||
let mut catalog = chusql_storage::catalog::Catalog::default();
|
let mut catalog = chusql_core_storage::catalog::Catalog::default();
|
||||||
catalog.create_table("__chusql_users", Vec::new())?;
|
catalog.create_table("__chusql_users", Vec::new())?;
|
||||||
std::fs::create_dir(data.path().join("system"))?;
|
std::fs::create_dir(data.path().join("system"))?;
|
||||||
catalog.save(data.path().join("system/catalog.json"))?;
|
catalog.save(data.path().join("system/catalog.json"))?;
|
||||||
let pipe = unique_pipe_name();
|
let _server = restart_at(data.path())?;
|
||||||
let _server = restart_at(&pipe, data.path())?;
|
let mut c = connect()?;
|
||||||
let mut c = connect(&pipe)?;
|
|
||||||
let result = send(&mut c, r#"{"method":"account_create","user":"alice","password_hash":"hash-one"}"#);
|
let result = send(&mut c, r#"{"method":"account_create","user":"alice","password_hash":"hash-one"}"#);
|
||||||
assert!(result.contains("collision"), "{result}");
|
assert!(result.contains("collision"), "{result}");
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -634,9 +596,8 @@ fn account_create_rejects_existing_ordinary_catalog_entry() -> std::io::Result<(
|
|||||||
/// 列出已建的表
|
/// 列出已建的表
|
||||||
#[test]
|
#[test]
|
||||||
fn list_tables_returns_created_tables() {
|
fn list_tables_returns_created_tables() {
|
||||||
let pipe = unique_pipe_name();
|
let (_srv, _data) = start_server();
|
||||||
let (_srv, _data) = start_server(&pipe);
|
let mut c = connect().unwrap();
|
||||||
let mut c = connect(&pipe).unwrap();
|
|
||||||
|
|
||||||
send(&mut c, r#"{"method":"insert","table":"users","row":{"id":1}}"#);
|
send(&mut c, r#"{"method":"insert","table":"users","row":{"id":1}}"#);
|
||||||
send(&mut c, r#"{"method":"insert","table":"orders","row":{"id":1}}"#);
|
send(&mut c, r#"{"method":"insert","table":"orders","row":{"id":1}}"#);
|
||||||
@@ -649,9 +610,8 @@ fn list_tables_returns_created_tables() {
|
|||||||
/// 整表替换只剩新行
|
/// 整表替换只剩新行
|
||||||
#[test]
|
#[test]
|
||||||
fn replace_all_replaces_rows() {
|
fn replace_all_replaces_rows() {
|
||||||
let pipe = unique_pipe_name();
|
let (_srv, _data) = start_server();
|
||||||
let (_srv, _data) = start_server(&pipe);
|
let mut c = connect().unwrap();
|
||||||
let mut c = connect(&pipe).unwrap();
|
|
||||||
|
|
||||||
send(&mut c, r#"{"method":"insert","table":"users","row":{"id":1,"name":"Alice"}}"#);
|
send(&mut c, r#"{"method":"insert","table":"users","row":{"id":1,"name":"Alice"}}"#);
|
||||||
send(&mut c, r#"{"method":"insert","table":"users","row":{"id":2,"name":"Bob"}}"#);
|
send(&mut c, r#"{"method":"insert","table":"users","row":{"id":2,"name":"Bob"}}"#);
|
||||||
@@ -670,9 +630,8 @@ fn replace_all_replaces_rows() {
|
|||||||
/// 按 id 查(列名走默认值 `id`,老客户端不用改)
|
/// 按 id 查(列名走默认值 `id`,老客户端不用改)
|
||||||
#[test]
|
#[test]
|
||||||
fn insert_then_lookup_by_index() {
|
fn insert_then_lookup_by_index() {
|
||||||
let pipe = unique_pipe_name();
|
let (_srv, _data) = start_server();
|
||||||
let (_srv, _data) = start_server(&pipe);
|
let mut c = connect().unwrap();
|
||||||
let mut c = connect(&pipe).unwrap();
|
|
||||||
|
|
||||||
for (id, name) in [(1, "Alice"), (2, "Bob"), (3, "Carol")] {
|
for (id, name) in [(1, "Alice"), (2, "Bob"), (3, "Carol")] {
|
||||||
let req = format!(
|
let req = format!(
|
||||||
@@ -693,9 +652,8 @@ fn insert_then_lookup_by_index() {
|
|||||||
/// 同一个 id 插两次要报错(索引列是唯一的)
|
/// 同一个 id 插两次要报错(索引列是唯一的)
|
||||||
#[test]
|
#[test]
|
||||||
fn duplicate_id_is_rejected() {
|
fn duplicate_id_is_rejected() {
|
||||||
let pipe = unique_pipe_name();
|
let (_srv, _data) = start_server();
|
||||||
let (_srv, _data) = start_server(&pipe);
|
let mut c = connect().unwrap();
|
||||||
let mut c = connect(&pipe).unwrap();
|
|
||||||
|
|
||||||
let req = r#"{"method":"insert","table":"dup_id","row":{"id":1,"name":"A"}}"#;
|
let req = r#"{"method":"insert","table":"dup_id","row":{"id":1,"name":"A"}}"#;
|
||||||
assert!(send(&mut c, req).contains(r#""status":"ok""#));
|
assert!(send(&mut c, req).contains(r#""status":"ok""#));
|
||||||
@@ -708,9 +666,8 @@ fn duplicate_id_is_rejected() {
|
|||||||
/// 批量插入一次全进
|
/// 批量插入一次全进
|
||||||
#[test]
|
#[test]
|
||||||
fn insert_batch_writes_all_rows() {
|
fn insert_batch_writes_all_rows() {
|
||||||
let pipe = unique_pipe_name();
|
let (_srv, _data) = start_server();
|
||||||
let (_srv, _data) = start_server(&pipe);
|
let mut c = connect().unwrap();
|
||||||
let mut c = connect(&pipe).unwrap();
|
|
||||||
|
|
||||||
let r = send(
|
let r = send(
|
||||||
&mut c,
|
&mut c,
|
||||||
@@ -728,9 +685,8 @@ fn insert_batch_writes_all_rows() {
|
|||||||
/// 行级删除:行没了、索引条目也没了、别人不受影响
|
/// 行级删除:行没了、索引条目也没了、别人不受影响
|
||||||
#[test]
|
#[test]
|
||||||
fn delete_keys_removes_row_and_index_entry() {
|
fn delete_keys_removes_row_and_index_entry() {
|
||||||
let pipe = unique_pipe_name();
|
let (_srv, _data) = start_server();
|
||||||
let (_srv, _data) = start_server(&pipe);
|
let mut c = connect().unwrap();
|
||||||
let mut c = connect(&pipe).unwrap();
|
|
||||||
|
|
||||||
for (id, name) in [(1, "Alice"), (2, "Bob"), (3, "Carol")] {
|
for (id, name) in [(1, "Alice"), (2, "Bob"), (3, "Carol")] {
|
||||||
let req = format!(
|
let req = format!(
|
||||||
@@ -757,9 +713,8 @@ fn delete_keys_removes_row_and_index_entry() {
|
|||||||
/// 给第二列建索引后能按那一列查
|
/// 给第二列建索引后能按那一列查
|
||||||
#[test]
|
#[test]
|
||||||
fn create_index_on_secondary_column() {
|
fn create_index_on_secondary_column() {
|
||||||
let pipe = unique_pipe_name();
|
let (_srv, _data) = start_server();
|
||||||
let (_srv, _data) = start_server(&pipe);
|
let mut c = connect().unwrap();
|
||||||
let mut c = connect(&pipe).unwrap();
|
|
||||||
|
|
||||||
for (id, code) in [(1, 5001), (2, 5002), (3, 5003)] {
|
for (id, code) in [(1, 5001), (2, 5002), (3, 5003)] {
|
||||||
let req = format!(
|
let req = format!(
|
||||||
@@ -794,9 +749,8 @@ fn create_index_on_secondary_column() {
|
|||||||
/// 删列同时摘掉索引与行内该格
|
/// 删列同时摘掉索引与行内该格
|
||||||
#[test]
|
#[test]
|
||||||
fn drop_column_removes_column_index_and_values() {
|
fn drop_column_removes_column_index_and_values() {
|
||||||
let pipe = unique_pipe_name();
|
let (_srv, _data) = start_server();
|
||||||
let (_srv, _data) = start_server(&pipe);
|
let mut c = connect().unwrap();
|
||||||
let mut c = connect(&pipe).unwrap();
|
|
||||||
|
|
||||||
for (id, code, note) in [(1, 5001, "a"), (2, 5002, "b")] {
|
for (id, code, note) in [(1, 5001, "a"), (2, 5002, "b")] {
|
||||||
let req = format!(
|
let req = format!(
|
||||||
@@ -835,12 +789,11 @@ fn drop_column_removes_column_index_and_values() {
|
|||||||
assert!(r.contains(r#""status":"error""#), "a missing column should error: {}", r);
|
assert!(r.contains(r#""status":"error""#), "a missing column should error: {}", r);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 重复值列允许建索引:点查把重复的行都取回来,删一行不影响另一行;id 仍然唯一
|
/// 重复值列能建索引,删一行不影响另一行;id 仍唯一
|
||||||
#[test]
|
#[test]
|
||||||
fn create_index_accepts_duplicate_values() {
|
fn create_index_accepts_duplicate_values() {
|
||||||
let pipe = unique_pipe_name();
|
let (_srv, _data) = start_server();
|
||||||
let (_srv, _data) = start_server(&pipe);
|
let mut c = connect().unwrap();
|
||||||
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":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":2,"age":30}}"#);
|
||||||
@@ -865,9 +818,8 @@ fn create_index_accepts_duplicate_values() {
|
|||||||
/// 数据字典里的统计:不同值个数
|
/// 数据字典里的统计:不同值个数
|
||||||
#[test]
|
#[test]
|
||||||
fn stats_report_distinct_values() {
|
fn stats_report_distinct_values() {
|
||||||
let pipe = unique_pipe_name();
|
let (_srv, _data) = start_server();
|
||||||
let (_srv, _data) = start_server(&pipe);
|
let mut c = connect().unwrap();
|
||||||
let mut c = connect(&pipe).unwrap();
|
|
||||||
|
|
||||||
for (id, age) in [(1, 30), (2, 30), (3, 41)] {
|
for (id, age) in [(1, 30), (2, 30), (3, 41)] {
|
||||||
let req = format!(
|
let req = format!(
|
||||||
@@ -891,24 +843,11 @@ fn stats_report_distinct_values() {
|
|||||||
/// 重开服务后索引仍在
|
/// 重开服务后索引仍在
|
||||||
#[test]
|
#[test]
|
||||||
fn lookup_survives_reopen() {
|
fn lookup_survives_reopen() {
|
||||||
let pipe = unique_pipe_name();
|
|
||||||
let data = tempfile::tempdir().unwrap();
|
let data = tempfile::tempdir().unwrap();
|
||||||
|
|
||||||
{
|
{
|
||||||
let child = Command::new(env!("CARGO_BIN_EXE_chusql-storage"))
|
let _srv = start_server_in(data.path());
|
||||||
.arg("--config")
|
let mut c = connect().unwrap();
|
||||||
.arg(write_config(data.path(), &pipe))
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.spawn()
|
|
||||||
.unwrap();
|
|
||||||
let srv = ServerProc(child);
|
|
||||||
for _ in 0..100 {
|
|
||||||
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")] {
|
for (id, name) in [(1, "Alice"), (2, "Bob"), (3, "Carol")] {
|
||||||
let req = format!(
|
let req = format!(
|
||||||
r#"{{"method":"insert","table":"persist","row":{{"id":{},"name":"{}"}},"key":{}}}"#,
|
r#"{{"method":"insert","table":"persist","row":{{"id":{},"name":"{}"}},"key":{}}}"#,
|
||||||
@@ -916,29 +855,11 @@ fn lookup_survives_reopen() {
|
|||||||
);
|
);
|
||||||
send(&mut c, &req);
|
send(&mut c, &req);
|
||||||
}
|
}
|
||||||
drop(srv);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
thread::sleep(Duration::from_millis(200));
|
let _srv = start_server_in(data.path());
|
||||||
|
|
||||||
let (_srv, _data) = {
|
let mut c = connect().unwrap();
|
||||||
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()
|
|
||||||
.unwrap();
|
|
||||||
let srv = ServerProc(child);
|
|
||||||
for _ in 0..100 {
|
|
||||||
if connect(&pipe).is_ok() { break; }
|
|
||||||
thread::sleep(Duration::from_millis(50));
|
|
||||||
}
|
|
||||||
create_fixture_database(&pipe);
|
|
||||||
(srv, data)
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut c = connect(&pipe).unwrap();
|
|
||||||
let r = send(&mut c, r#"{"method":"lookup_by_index","table":"persist","key":2}"#);
|
let r = send(&mut c, r#"{"method":"lookup_by_index","table":"persist","key":2}"#);
|
||||||
assert!(r.contains("Bob"), "persist lookup: {}", r);
|
assert!(r.contains("Bob"), "persist lookup: {}", r);
|
||||||
}
|
}
|
||||||
@@ -947,34 +868,19 @@ fn lookup_survives_reopen() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn config_file_is_used() {
|
fn config_file_is_used() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let pipe = unique_pipe_name();
|
|
||||||
let data_dir = dir.path().join("data");
|
let data_dir = dir.path().join("data");
|
||||||
let config_path = dir.path().join("chusql-storage.toml");
|
let config_path = dir.path().join("chusql-core-storage.toml");
|
||||||
|
|
||||||
let text = format!(
|
let text = format!(
|
||||||
"[server]\npipe_name = \"{}\"\n[storage]\ndata_dir = \"{}\"\n[log]\nlevel = \"debug\"\n",
|
"[storage]\ndata_dir = \"{}\"\n[log]\nlevel = \"debug\"\n",
|
||||||
pipe,
|
|
||||||
data_dir.display().to_string().replace('\\', "/")
|
data_dir.display().to_string().replace('\\', "/")
|
||||||
);
|
);
|
||||||
std::fs::write(&config_path, text).unwrap();
|
std::fs::write(&config_path, text).unwrap();
|
||||||
|
|
||||||
let child = Command::new(env!("CARGO_BIN_EXE_chusql-storage"))
|
open_storage(&config_path);
|
||||||
.arg("--config")
|
create_fixture_database();
|
||||||
.arg(&config_path)
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.spawn()
|
|
||||||
.expect("failed to spawn server");
|
|
||||||
let _srv = ServerProc(child);
|
|
||||||
for _ in 0..100 {
|
|
||||||
if connect(&pipe).is_ok() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
thread::sleep(Duration::from_millis(50));
|
|
||||||
}
|
|
||||||
create_fixture_database(&pipe);
|
|
||||||
|
|
||||||
let mut c = connect(&pipe).unwrap();
|
let mut c = connect().unwrap();
|
||||||
assert_eq!(send(&mut c, r#"{"method":"ping"}"#), r#"{"status":"pong"}"#);
|
assert_eq!(send(&mut c, r#"{"method":"ping"}"#), r#"{"status":"pong"}"#);
|
||||||
let r = send(&mut c, r#"{"method":"insert","table":"cfg","row":{"id":1}}"#);
|
let r = send(&mut c, r#"{"method":"insert","table":"cfg","row":{"id":1}}"#);
|
||||||
assert!(r.contains(r#""status":"ok""#), "insert: {}", r);
|
assert!(r.contains(r#""status":"ok""#), "insert: {}", r);
|
||||||
@@ -988,9 +894,8 @@ fn config_file_is_used() {
|
|||||||
/// 重复建表报错
|
/// 重复建表报错
|
||||||
#[test]
|
#[test]
|
||||||
fn create_table_twice_errors() {
|
fn create_table_twice_errors() {
|
||||||
let pipe = unique_pipe_name();
|
let (_srv, _data) = start_server();
|
||||||
let (_srv, _data) = start_server(&pipe);
|
let mut c = connect().unwrap();
|
||||||
let mut c = connect(&pipe).unwrap();
|
|
||||||
|
|
||||||
let req = r#"{"method":"create_table","table":"dup","columns":[{"name":"id","ty":"int"}]}"#;
|
let req = r#"{"method":"create_table","table":"dup","columns":[{"name":"id","ty":"int"}]}"#;
|
||||||
let r1 = send(&mut c, req);
|
let r1 = send(&mut c, req);
|
||||||
@@ -1004,9 +909,8 @@ fn create_table_twice_errors() {
|
|||||||
/// 新建空表能扫描
|
/// 新建空表能扫描
|
||||||
#[test]
|
#[test]
|
||||||
fn scan_empty_table_after_create() {
|
fn scan_empty_table_after_create() {
|
||||||
let pipe = unique_pipe_name();
|
let (_srv, _data) = start_server();
|
||||||
let (_srv, _data) = start_server(&pipe);
|
let mut c = connect().unwrap();
|
||||||
let mut c = connect(&pipe).unwrap();
|
|
||||||
|
|
||||||
let r = send(
|
let r = send(
|
||||||
&mut c,
|
&mut c,
|
||||||
@@ -1019,12 +923,11 @@ fn scan_empty_table_after_create() {
|
|||||||
assert!(r.contains(r#""rows":[]"#), "scan: {}", r);
|
assert!(r.contains(r#""rows":[]"#), "scan: {}", r);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 建表带上约束元数据,行里能有 null;replace_schema 整表换列定义与行
|
/// 约束元数据落盘;replace_schema 换列与行
|
||||||
#[test]
|
#[test]
|
||||||
fn replace_schema_rewrites_columns_and_rows() {
|
fn replace_schema_rewrites_columns_and_rows() {
|
||||||
let pipe = unique_pipe_name();
|
let (_srv, _data) = start_server();
|
||||||
let (_srv, _data) = start_server(&pipe);
|
let mut c = connect().unwrap();
|
||||||
let mut c = connect(&pipe).unwrap();
|
|
||||||
|
|
||||||
let created = send(
|
let created = send(
|
||||||
&mut c,
|
&mut c,
|
||||||
@@ -1061,12 +964,11 @@ fn replace_schema_rewrites_columns_and_rows() {
|
|||||||
assert!(r.contains(r#""tag":"a""#), "rewritten rows: {}", r);
|
assert!(r.contains(r#""tag":"a""#), "rewritten rows: {}", r);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 旧类型系统落盘的数据在启动时升级:缺的格补 null,老类型名并到新写法
|
/// 启动时升级旧类型字典,缺的格补 null
|
||||||
#[test]
|
#[test]
|
||||||
fn legacy_catalog_and_partial_rows_are_migrated() -> Result<(), Box<dyn std::error::Error>> {
|
fn legacy_catalog_and_partial_rows_are_migrated() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let pipe = unique_pipe_name();
|
let (server, data) = start_server();
|
||||||
let (server, data) = start_server(&pipe);
|
let mut c = connect()?;
|
||||||
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":"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"}}))?;
|
request_ok(&mut c, serde_json::json!({"method":"insert","table":"legacy_t","row":{"id":1,"name":"a"}}))?;
|
||||||
drop(c);
|
drop(c);
|
||||||
@@ -1083,8 +985,8 @@ fn legacy_catalog_and_partial_rows_are_migrated() -> Result<(), Box<dyn std::err
|
|||||||
}
|
}
|
||||||
std::fs::write(&catalog_path, serde_json::to_string_pretty(&catalog)?)?;
|
std::fs::write(&catalog_path, serde_json::to_string_pretty(&catalog)?)?;
|
||||||
|
|
||||||
let _restarted = start_server_in(&pipe, data.path());
|
let _restarted = start_server_in(data.path());
|
||||||
let mut c = connect(&pipe)?;
|
let mut c = connect()?;
|
||||||
|
|
||||||
let rows: serde_json::Value = serde_json::from_str(&send(&mut c, r#"{"method":"scan","table":"legacy_t"}"#))?;
|
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");
|
assert_eq!(rows["rows"], serde_json::json!([{"id":1,"name":"a","age":null}]), "缺的格要补 null");
|
||||||
@@ -1099,12 +1001,11 @@ fn legacy_catalog_and_partial_rows_are_migrated() -> Result<(), Box<dyn std::err
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 账号表用新类型系统落盘:id 主键、用户名索引、注册与最近登录时间
|
/// 账号表落盘类型与索引,登录盖时间戳
|
||||||
#[test]
|
#[test]
|
||||||
fn account_table_uses_typed_columns_and_stamps_login() -> Result<(), Box<dyn std::error::Error>> {
|
fn account_table_uses_typed_columns_and_stamps_login() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let pipe = unique_pipe_name();
|
let (_srv, data) = start_server();
|
||||||
let (_srv, data) = start_server(&pipe);
|
let mut c = connect()?;
|
||||||
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"}"#))?;
|
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);
|
assert_eq!(created["status"], "accounts", "{}", created);
|
||||||
@@ -1136,10 +1037,9 @@ fn account_table_uses_typed_columns_and_stamps_login() -> Result<(), Box<dyn std
|
|||||||
|
|
||||||
/// 字符串列也能建索引并按键点查
|
/// 字符串列也能建索引并按键点查
|
||||||
#[test]
|
#[test]
|
||||||
fn string_index_lookup_over_the_pipe() {
|
fn string_index_lookup_in_process() {
|
||||||
let pipe = unique_pipe_name();
|
let (_srv, _data) = start_server();
|
||||||
let (_srv, _data) = start_server(&pipe);
|
let mut c = connect().unwrap();
|
||||||
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":1,"name":"alice"}}"#);
|
||||||
send(&mut c, r#"{"method":"insert","table":"sidx","row":{"id":2,"name":"bob"}}"#);
|
send(&mut c, r#"{"method":"insert","table":"sidx","row":{"id":2,"name":"bob"}}"#);
|
||||||
@@ -1159,12 +1059,11 @@ fn string_index_lookup_over_the_pipe() {
|
|||||||
assert!(r.contains(r#""id":1"#) && r.contains(r#""id":3"#), "both rows come back: {}", r);
|
assert!(r.contains(r#""id":1"#) && r.contains(r#""id":3"#), "both rows come back: {}", r);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 范围扫描走索引叶子链,两端可开可闭,没索引回 no_index
|
/// 范围扫描走索引,两端可开可闭,没索引回 no_index
|
||||||
#[test]
|
#[test]
|
||||||
fn range_scan_over_the_pipe() {
|
fn range_scan_in_process() {
|
||||||
let pipe = unique_pipe_name();
|
let (_srv, _data) = start_server();
|
||||||
let (_srv, _data) = start_server(&pipe);
|
let mut c = connect().unwrap();
|
||||||
let mut c = connect(&pipe).unwrap();
|
|
||||||
|
|
||||||
for (id, code) in [(1, 100), (2, 200), (3, 300), (4, 400)] {
|
for (id, code) in [(1, 100), (2, 200), (3, 300), (4, 400)] {
|
||||||
let req = format!(
|
let req = format!(
|
||||||
@@ -1204,10 +1103,9 @@ fn range_scan_over_the_pipe() {
|
|||||||
|
|
||||||
/// 字符串列按字节序范围扫描
|
/// 字符串列按字节序范围扫描
|
||||||
#[test]
|
#[test]
|
||||||
fn string_range_scan_over_the_pipe() {
|
fn string_range_scan_in_process() {
|
||||||
let pipe = unique_pipe_name();
|
let (_srv, _data) = start_server();
|
||||||
let (_srv, _data) = start_server(&pipe);
|
let mut c = connect().unwrap();
|
||||||
let mut c = connect(&pipe).unwrap();
|
|
||||||
|
|
||||||
for (id, name) in [(1, "alice"), (2, "bob"), (3, "carol"), (4, "dave")] {
|
for (id, name) in [(1, "alice"), (2, "bob"), (3, "carol"), (4, "dave")] {
|
||||||
let req = format!(
|
let req = format!(
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
use chusql_storage::protocol::Row;
|
use chusql_core_storage::protocol::Row;
|
||||||
use chusql_storage::wal::{Wal, WalOp};
|
use chusql_core_storage::wal::{Wal, WalOp};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
// WAL 测试:追加、读取、清空、截断尾巴。
|
// WAL 测试:追加、读取、清空、截断尾巴。
|
||||||
|
|
||||||
|
|
||||||
|
/// 用键值对构造一行
|
||||||
fn row(pairs: &[(&str, serde_json::Value)]) -> Row {
|
fn row(pairs: &[(&str, serde_json::Value)]) -> Row {
|
||||||
let mut m = Row::new();
|
let mut m = Row::new();
|
||||||
for (k, v) in pairs {
|
for (k, v) in pairs {
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
cabal-version: 2.4
|
|
||||||
|
|
||||||
name: chusql-engine
|
|
||||||
version: 1.0.0
|
|
||||||
synopsis: ChuSQL 执行引擎:SQL 词法与语法分析、表达式求值
|
|
||||||
description: ChuSQL 服务端执行引擎。负责 SQL 词法分析与 AST 构建、
|
|
||||||
WHERE 条件表达式求值、关系代数运算与内存表管理。
|
|
||||||
build-type: Simple
|
|
||||||
|
|
||||||
library
|
|
||||||
hs-source-dirs: src
|
|
||||||
exposed-modules: ChuSQL.Algebra.Eval
|
|
||||||
ChuSQL.Algebra.Expr
|
|
||||||
ChuSQL.Algebra.Op
|
|
||||||
ChuSQL.Algebra.Optimize
|
|
||||||
ChuSQL.Algebra.Planner
|
|
||||||
ChuSQL.Algebra.Sort
|
|
||||||
ChuSQL.Engine
|
|
||||||
ChuSQL.Model
|
|
||||||
ChuSQL.Semantic
|
|
||||||
ChuSQL.Storage
|
|
||||||
ChuSQL.Storage.IPC
|
|
||||||
ChuSQL.Storage.Memory
|
|
||||||
ChuSQL.Syntax.AST
|
|
||||||
ChuSQL.Syntax.Parser
|
|
||||||
build-depends: base >= 4.14 && < 5
|
|
||||||
, aeson
|
|
||||||
, bytestring
|
|
||||||
, hashable
|
|
||||||
, megaparsec
|
|
||||||
, network
|
|
||||||
, parser-combinators
|
|
||||||
, scientific
|
|
||||||
, text
|
|
||||||
, unordered-containers
|
|
||||||
default-language: Haskell2010
|
|
||||||
ghc-options: -Wall
|
|
||||||
|
|
||||||
executable chusql-engine
|
|
||||||
main-is: Main.hs
|
|
||||||
hs-source-dirs: app
|
|
||||||
build-depends: base
|
|
||||||
, chusql-engine
|
|
||||||
default-language: Haskell2010
|
|
||||||
ghc-options: -Wall
|
|
||||||
|
|
||||||
test-suite chusql-engine-test
|
|
||||||
type: exitcode-stdio-1.0
|
|
||||||
main-is: Spec.hs
|
|
||||||
hs-source-dirs: test
|
|
||||||
build-depends: base
|
|
||||||
, chusql-engine
|
|
||||||
, directory
|
|
||||||
, filepath
|
|
||||||
, hspec
|
|
||||||
, process
|
|
||||||
default-language: Haskell2010
|
|
||||||
ghc-options: -Wall
|
|
||||||
@@ -1,665 +0,0 @@
|
|||||||
{-# LANGUAGE CPP #-}
|
|
||||||
{-# LANGUAGE ImportQualifiedPost #-}
|
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
|
||||||
|
|
||||||
module ChuSQL.Storage.IPC (
|
|
||||||
IPCStorage (..),
|
|
||||||
Request (..),
|
|
||||||
Response (..),
|
|
||||||
Account (..),
|
|
||||||
SchemaColumn (..),
|
|
||||||
TableInfo (..),
|
|
||||||
sendRequest,
|
|
||||||
closeConnection,
|
|
||||||
defaultPipeName,
|
|
||||||
setPipeName,
|
|
||||||
getPipeName,
|
|
||||||
doListTables,
|
|
||||||
doLookupByColumn,
|
|
||||||
doInsert,
|
|
||||||
doInsertMany,
|
|
||||||
doDeleteKeys,
|
|
||||||
doDescribeTable,
|
|
||||||
doDropTable,
|
|
||||||
doListCatalog,
|
|
||||||
) where
|
|
||||||
|
|
||||||
import ChuSQL.Model
|
|
||||||
import ChuSQL.Storage
|
|
||||||
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
|
|
||||||
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.Maybe (fromMaybe)
|
|
||||||
import Data.Scientific (floatingOrInteger, fromFloatDigits)
|
|
||||||
import Data.Text qualified as T
|
|
||||||
import System.IO
|
|
||||||
import System.IO.Unsafe (unsafePerformIO)
|
|
||||||
#if !defined(mingw32_HOST_OS)
|
|
||||||
import Network.Socket (Family (AF_UNIX), SockAddr (SockAddrUnix), SocketType (Stream), close, connect, defaultProtocol, socket, socketToHandle)
|
|
||||||
import System.Environment (lookupEnv)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
-- IPC 存储实现:每个存储方法翻成管道上的一条 JSON 请求。
|
|
||||||
-- 端点:Windows 是具名管道 \\.\pipe\<name>,Unix 是文件系统套接字 <dir>/<name>.sock。
|
|
||||||
|
|
||||||
#if !defined(mingw32_HOST_OS)
|
|
||||||
-- | Unix 套接字路径的最大长度(Linux 108 含结尾 NUL,BSD 104,这里留余量)
|
|
||||||
maxSocketPathLength :: Int
|
|
||||||
maxSocketPathLength = 100
|
|
||||||
#endif
|
|
||||||
|
|
||||||
newtype IPCStorage a = IPCStorage
|
|
||||||
{ runIPCStorage :: IO a
|
|
||||||
}
|
|
||||||
|
|
||||||
instance Functor IPCStorage where
|
|
||||||
fmap f (IPCStorage m) = IPCStorage (fmap f m)
|
|
||||||
|
|
||||||
instance Applicative IPCStorage where
|
|
||||||
pure = IPCStorage . pure
|
|
||||||
IPCStorage mf <*> IPCStorage ma = IPCStorage (mf <*> ma)
|
|
||||||
|
|
||||||
instance Monad IPCStorage where
|
|
||||||
IPCStorage m >>= k = IPCStorage $ do
|
|
||||||
a <- m
|
|
||||||
runIPCStorage (k a)
|
|
||||||
|
|
||||||
{-# NOINLINE connectionRef #-}
|
|
||||||
connectionRef :: MVar (Maybe (FilePath, Handle))
|
|
||||||
connectionRef = unsafePerformIO (newMVar Nothing)
|
|
||||||
|
|
||||||
-- | 打开端点连接: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 ()
|
|
||||||
closeCached Nothing = pure ()
|
|
||||||
closeCached (Just (_, h)) = hClose h
|
|
||||||
|
|
||||||
-- | 拿一个可用句柄;路径没变就复用,变了就换。
|
|
||||||
getConnection :: FilePath -> IO Handle
|
|
||||||
getConnection path = modifyMVar connectionRef $ \cached -> case cached of
|
|
||||||
Just (p, h)
|
|
||||||
| p == path -> pure (Just (p, h), h)
|
|
||||||
_ -> do
|
|
||||||
_ <- try (closeCached cached) :: IO (Either IOException ())
|
|
||||||
h <- openPipe path
|
|
||||||
pure (Just (path, h), h)
|
|
||||||
|
|
||||||
-- | 丢掉缓存的连接(出错后调用,下次会重连)
|
|
||||||
dropConnection :: IO ()
|
|
||||||
dropConnection = modifyMVar_ connectionRef $ \cached -> do
|
|
||||||
_ <- try (closeCached cached) :: IO (Either IOException ())
|
|
||||||
pure Nothing
|
|
||||||
|
|
||||||
-- | 关闭并清空连接。可选,进程退出前调用。
|
|
||||||
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 <- getPipeName
|
|
||||||
pure ("\\\\.\\pipe\\" ++ name)
|
|
||||||
#else
|
|
||||||
pipePath = do
|
|
||||||
name <- getPipeName
|
|
||||||
dir <- socketDir
|
|
||||||
pure (dir ++ "/" ++ name ++ ".sock")
|
|
||||||
#endif
|
|
||||||
|
|
||||||
-- | 发一条请求,管道不通就重连
|
|
||||||
sendRequest :: Request -> IO Response
|
|
||||||
sendRequest req = do
|
|
||||||
path <- pipePath
|
|
||||||
result <- try (roundTrip req path) :: IO (Either IOException Response)
|
|
||||||
case result of
|
|
||||||
Left e -> do
|
|
||||||
dropConnection
|
|
||||||
pure (RespError ("pipe error: " ++ show e))
|
|
||||||
Right r -> pure r
|
|
||||||
|
|
||||||
-- | 一条请求-响应往返(复用缓存里的句柄)
|
|
||||||
roundTrip :: Request -> FilePath -> IO Response
|
|
||||||
roundTrip req path = do
|
|
||||||
h <- getConnection path
|
|
||||||
BL.hPutStr h (encode req)
|
|
||||||
BSC.hPutStr h "\n"
|
|
||||||
hFlush h
|
|
||||||
line <- BSC.hGetLine h
|
|
||||||
let cleaned = BSC.dropWhileEnd (== '\r') line
|
|
||||||
case eitherDecodeStrict cleaned of
|
|
||||||
Left err -> pure (RespError ("decode: " ++ err))
|
|
||||||
Right r -> pure r
|
|
||||||
|
|
||||||
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 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"
|
|
||||||
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 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)
|
|
||||||
, "table" .= t
|
|
||||||
, "row" .= rowToJSON r
|
|
||||||
]
|
|
||||||
toJSON (ReqInsertBatch t rs) =
|
|
||||||
object
|
|
||||||
[ "method" .= ("insert_batch" :: T.Text)
|
|
||||||
, "table" .= t
|
|
||||||
, "rows" .= map rowToJSON rs
|
|
||||||
]
|
|
||||||
toJSON (ReqDeleteKeys t ks) =
|
|
||||||
object
|
|
||||||
[ "method" .= ("delete_keys" :: T.Text)
|
|
||||||
, "table" .= t
|
|
||||||
, "keys" .= ks
|
|
||||||
]
|
|
||||||
toJSON (ReqReplaceAll t rs) =
|
|
||||||
object
|
|
||||||
[ "method" .= ("replace_all" :: T.Text)
|
|
||||||
, "table" .= t
|
|
||||||
, "rows" .= map rowToJSON rs
|
|
||||||
]
|
|
||||||
toJSON ReqListTables = object ["method" .= ("list_tables" :: T.Text)]
|
|
||||||
toJSON (ReqLookupByColumn t c k) =
|
|
||||||
object
|
|
||||||
[ "method" .= ("lookup_by_index" :: T.Text)
|
|
||||||
, "table" .= t
|
|
||||||
, "column" .= c
|
|
||||||
, "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
|
|
||||||
[ "method" .= ("describe_table" :: T.Text)
|
|
||||||
, "table" .= t
|
|
||||||
]
|
|
||||||
toJSON (ReqCreateTable t cols) =
|
|
||||||
object
|
|
||||||
[ "method" .= ("create_table" :: T.Text)
|
|
||||||
, "table" .= t
|
|
||||||
, "columns" .= cols
|
|
||||||
]
|
|
||||||
toJSON (ReqDropTable t) =
|
|
||||||
object ["method" .= ("drop_table" :: T.Text), "table" .= t]
|
|
||||||
toJSON (ReqCreateIndex t c) =
|
|
||||||
object
|
|
||||||
[ "method" .= ("create_index" :: T.Text)
|
|
||||||
, "table" .= t
|
|
||||||
, "column" .= c
|
|
||||||
]
|
|
||||||
toJSON (ReqDropIndex t c) =
|
|
||||||
object
|
|
||||||
[ "method" .= ("drop_index" :: T.Text)
|
|
||||||
, "table" .= t
|
|
||||||
, "column" .= c
|
|
||||||
]
|
|
||||||
toJSON (ReqDropColumn t c) =
|
|
||||||
object
|
|
||||||
[ "method" .= ("drop_column" :: T.Text)
|
|
||||||
, "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
|
|
||||||
{ tiTable :: String
|
|
||||||
, tiColumns :: [SchemaColumn]
|
|
||||||
, tiRows :: Int
|
|
||||||
, tiIndexes :: [String]
|
|
||||||
, tiStats :: [(String, Int, Bool)]
|
|
||||||
}
|
|
||||||
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
|
|
||||||
| RespNoIndex
|
|
||||||
| RespOk
|
|
||||||
| RespError String
|
|
||||||
| RespCatalog [TableInfo]
|
|
||||||
|
|
||||||
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
|
|
||||||
"rows" -> RespRows <$> (o .: "rows" >>= mapM rowFromJSON)
|
|
||||||
"tables" -> RespTables <$> o .: "tables"
|
|
||||||
"error" -> RespError <$> o .: "message"
|
|
||||||
"schema" -> RespSchema <$> parseTable (A.Object o)
|
|
||||||
"catalog" -> do
|
|
||||||
xs <- o .: "schemas" :: Parser [A.Value]
|
|
||||||
RespCatalog <$> mapM parseTable xs
|
|
||||||
other -> fail ("unknown status: " ++ T.unpack other)
|
|
||||||
where
|
|
||||||
parseTable :: A.Value -> Parser TableInfo
|
|
||||||
parseTable = withObject "TableInfo" $ \o -> do
|
|
||||||
t <- o .: "table"
|
|
||||||
cols <- o .: "columns"
|
|
||||||
cnt <- o .: "row_count"
|
|
||||||
idx <- o .:? "indexes" .!= []
|
|
||||||
sts <- o .:? "stats" .!= []
|
|
||||||
idxCols <- mapM (\v -> withObject "IndexWire" (.: "column") v) (idx :: [A.Value])
|
|
||||||
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"
|
|
||||||
distinct <- o .: "distinct"
|
|
||||||
capped <- o .:? "capped" .!= False
|
|
||||||
pure (name, distinct, capped)
|
|
||||||
|
|
||||||
-- | 值编码成 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 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"
|
|
||||||
|
|
||||||
-- | 一行编码成 JSON
|
|
||||||
rowToJSON :: Row -> A.Value
|
|
||||||
rowToJSON r =
|
|
||||||
A.Object (KM.fromList [(K.fromString k, valueToJSON v) | (k, v) <- r])
|
|
||||||
|
|
||||||
-- | JSON 解回一行
|
|
||||||
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)
|
|
||||||
rowFromJSON _ = fail "row must be a JSON object"
|
|
||||||
|
|
||||||
-- | 发请求并把响应翻成 Either
|
|
||||||
ask :: String -> Request -> (Response -> Maybe a) -> IO (Either String a)
|
|
||||||
ask what req recognize = do
|
|
||||||
resp <- sendRequest req
|
|
||||||
pure $ case resp of
|
|
||||||
RespError e -> Left e
|
|
||||||
other -> case recognize other of
|
|
||||||
Just a -> Right a
|
|
||||||
Nothing -> Left ("unexpected response to " ++ what)
|
|
||||||
|
|
||||||
-- | 只认"成了"(多数写操作都这样)
|
|
||||||
okOnly :: Response -> Maybe ()
|
|
||||||
okOnly RespOk = Just ()
|
|
||||||
okOnly _ = Nothing
|
|
||||||
|
|
||||||
-- | 发 Scan
|
|
||||||
doScan :: String -> IO (Either String [Row])
|
|
||||||
doScan t = ask "scan" (ReqScan t) $ \resp -> case resp of
|
|
||||||
RespRows rows -> Just rows
|
|
||||||
_ -> Nothing
|
|
||||||
|
|
||||||
-- | 发 Insert(索引由存储层维护)
|
|
||||||
doInsert :: String -> Row -> IO (Either String ())
|
|
||||||
doInsert t r = ask "insert" (ReqInsert t r) okOnly
|
|
||||||
|
|
||||||
-- | 发 InsertBatch:一批行一次请求
|
|
||||||
doInsertMany :: String -> [Row] -> IO (Either String ())
|
|
||||||
doInsertMany t rs = ask "insert_batch" (ReqInsertBatch t rs) okOnly
|
|
||||||
|
|
||||||
-- | 发 DeleteKeys
|
|
||||||
doDeleteKeys :: String -> [Int] -> IO (Either String ())
|
|
||||||
doDeleteKeys t ks = ask "delete_keys" (ReqDeleteKeys t ks) okOnly
|
|
||||||
|
|
||||||
-- | 发 ReplaceAll
|
|
||||||
doReplaceAll :: String -> [Row] -> IO (Either String ())
|
|
||||||
doReplaceAll t rs = ask "replace_all" (ReqReplaceAll t rs) okOnly
|
|
||||||
|
|
||||||
-- | 发 ListTables
|
|
||||||
doListTables :: IO (Either String [String])
|
|
||||||
doListTables = ask "list_tables" ReqListTables $ \resp -> case resp of
|
|
||||||
RespTables ts -> Just ts
|
|
||||||
_ -> Nothing
|
|
||||||
|
|
||||||
-- | 一次拿全库 schema
|
|
||||||
doListCatalog :: IO (Either String [TableInfo])
|
|
||||||
doListCatalog = ask "list_catalog" ReqListCatalog $ \resp -> case resp of
|
|
||||||
RespCatalog xs -> Just xs
|
|
||||||
_ -> Nothing
|
|
||||||
|
|
||||||
-- | 按某一列的索引取一行(键可以是整数或字符串)
|
|
||||||
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 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
|
|
||||||
RespSchema info -> Just info
|
|
||||||
_ -> Nothing
|
|
||||||
|
|
||||||
-- | 给某一列建索引
|
|
||||||
doCreateIndex :: String -> String -> IO (Either String ())
|
|
||||||
doCreateIndex t c = ask "create_index" (ReqCreateIndex t c) okOnly
|
|
||||||
|
|
||||||
-- | 去掉某一列的索引
|
|
||||||
doDropIndex :: String -> String -> IO (Either String ())
|
|
||||||
doDropIndex t c = ask "drop_index" (ReqDropIndex t c) okOnly
|
|
||||||
|
|
||||||
-- | 删一列(列定义、这一列上的索引、每行里的那一格一起没)
|
|
||||||
doDropColumn :: String -> String -> IO (Either String ())
|
|
||||||
doDropColumn t c = ask "drop_column" (ReqDropColumn t c) okOnly
|
|
||||||
|
|
||||||
-- | 发 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
|
|
||||||
|
|
||||||
-- | 发 DropTable
|
|
||||||
doDropTable :: String -> IO (Either String ())
|
|
||||||
doDropTable t = ask "drop_table" (ReqDropTable t) okOnly
|
|
||||||
|
|
||||||
-- | 线上 schema 转本地列
|
|
||||||
schemaToColumns :: [SchemaColumn] -> [(String, Column)]
|
|
||||||
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)
|
|
||||||
|
|
||||||
-- \| 一批行一次请求:N 行只落一次盘
|
|
||||||
insertMany t rs = IPCStorage (doInsertMany t rs)
|
|
||||||
|
|
||||||
-- \| 按 id 批量删行
|
|
||||||
deleteKeys t ks = IPCStorage (doDeleteKeys t ks)
|
|
||||||
|
|
||||||
-- \| 发 ReplaceAll
|
|
||||||
replaceAll t rs = IPCStorage (doReplaceAll t rs)
|
|
||||||
|
|
||||||
-- \| 按某一列的索引取一行(没有索引就回 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))
|
|
||||||
|
|
||||||
-- \| ALTER:整表换列定义与全部行
|
|
||||||
replaceSchema name cols rows = IPCStorage (doReplaceSchema name (map toWire cols) rows)
|
|
||||||
|
|
||||||
-- \| 发 DropTable
|
|
||||||
dropTable name = IPCStorage (doDropTable name)
|
|
||||||
|
|
||||||
-- \| 建索引
|
|
||||||
createIndex t c = IPCStorage (doCreateIndex t c)
|
|
||||||
|
|
||||||
-- \| 删索引
|
|
||||||
dropIndex t c = IPCStorage (doDropIndex t c)
|
|
||||||
|
|
||||||
-- \| 删一列
|
|
||||||
dropColumn t c = IPCStorage (doDropColumn t c)
|
|
||||||
|
|
||||||
-- \| 列表 + 逐表扫描拼库
|
|
||||||
snapshot = IPCStorage $ do
|
|
||||||
result <- doListCatalog
|
|
||||||
case result of
|
|
||||||
Left _ -> pure []
|
|
||||||
Right xs -> mapM loadEntry xs
|
|
||||||
where
|
|
||||||
loadEntry info = do
|
|
||||||
rows <- doScan (tiTable info)
|
|
||||||
pure
|
|
||||||
( tiTable info
|
|
||||||
, Table (tiTable info) (schemaToColumns (tiColumns info)) (either (const []) id rows)
|
|
||||||
)
|
|
||||||
|
|
||||||
-- \| 只问数据字典要结构,一行数据都不拉过来
|
|
||||||
schema = IPCStorage $ do
|
|
||||||
result <- ask "all_catalogs" ReqAllCatalog $ \resp -> case resp of
|
|
||||||
RespCatalog infos -> Just infos
|
|
||||||
_ -> Nothing
|
|
||||||
pure $ case result of
|
|
||||||
Left _ -> []
|
|
||||||
Right xs ->
|
|
||||||
[ (tiTable i, Table (tiTable i) (schemaToColumns (tiColumns i)) [])
|
|
||||||
| i <- xs
|
|
||||||
]
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
cabal-version: 2.4
|
||||||
|
|
||||||
|
name: chusql-interface
|
||||||
|
version: 1.0.0
|
||||||
|
synopsis: ChuSQL 接口层:配置、口令、语句构造与 TCP 客户端
|
||||||
|
description: 引擎无关的接口层。Web 管理端与命令行客户端都只依赖这层:
|
||||||
|
读同一份 chusql.toml、用同一个线上协议连到拥有存储库的
|
||||||
|
chusql-server,然后在客户端会话上跑 SQL、看数据字典与角色。
|
||||||
|
存储引擎与存储动态库一概不在这层。
|
||||||
|
build-type: Simple
|
||||||
|
|
||||||
|
library
|
||||||
|
hs-source-dirs: src
|
||||||
|
exposed-modules: ChuSQL.Interface.AccountTable
|
||||||
|
ChuSQL.Interface.Actions
|
||||||
|
ChuSQL.Interface.Auth
|
||||||
|
ChuSQL.Interface.Config
|
||||||
|
ChuSQL.Interface.Link
|
||||||
|
ChuSQL.Interface.Policy
|
||||||
|
ChuSQL.Interface.Protocol
|
||||||
|
ChuSQL.Interface.RateLimit
|
||||||
|
ChuSQL.Interface.Session
|
||||||
|
ChuSQL.Interface.Settings
|
||||||
|
ChuSQL.Interface.TOML
|
||||||
|
build-depends: base >= 4.14 && < 5
|
||||||
|
, aeson
|
||||||
|
, bytestring
|
||||||
|
, chusql-core-model
|
||||||
|
, containers
|
||||||
|
, crypton
|
||||||
|
, directory
|
||||||
|
, filepath
|
||||||
|
, memory
|
||||||
|
, network
|
||||||
|
, scientific
|
||||||
|
, text
|
||||||
|
, time
|
||||||
|
default-language: Haskell2010
|
||||||
|
ghc-options: -Wall
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
|
module ChuSQL.Interface.AccountTable (
|
||||||
|
accountTableName,
|
||||||
|
isAccountTable,
|
||||||
|
passwordColumn,
|
||||||
|
accountColumns,
|
||||||
|
systemTableInfo,
|
||||||
|
) where
|
||||||
|
|
||||||
|
import ChuSQL.Core.Protocol (SchemaColumn (..), TableInfo (..))
|
||||||
|
import Data.Text (Text)
|
||||||
|
import qualified Data.Text as T
|
||||||
|
|
||||||
|
-- 账号表(系统表 __chusql_users)的结构定义,SQL 会话与 Web 管理端共用。
|
||||||
|
|
||||||
|
-- | 账号表的名字:网页里像普通表一样浏览与编辑,但写入走账号命令
|
||||||
|
accountTableName :: String
|
||||||
|
accountTableName = "__chusql_users"
|
||||||
|
|
||||||
|
-- | 这张表是不是账号表
|
||||||
|
isAccountTable :: Text -> Bool
|
||||||
|
isAccountTable name = T.toLower name == T.pack accountTableName
|
||||||
|
|
||||||
|
-- | 口令列:密文只在服务端产生,界面不支持排序与筛选
|
||||||
|
passwordColumn :: String
|
||||||
|
passwordColumn = "password"
|
||||||
|
|
||||||
|
-- | 账号表的列定义与约束
|
||||||
|
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
|
||||||
|
|
||||||
|
-- | 账号表的结构(排序校验用)
|
||||||
|
systemTableInfo :: TableInfo
|
||||||
|
systemTableInfo = TableInfo accountTableName accountColumns 0 [] []
|
||||||
+91
-5
@@ -1,6 +1,6 @@
|
|||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
module ChuSQL.Web.Actions (
|
module ChuSQL.Interface.Actions (
|
||||||
ColumnSpec (..),
|
ColumnSpec (..),
|
||||||
CreateTableSpec (..),
|
CreateTableSpec (..),
|
||||||
isIdentifier,
|
isIdentifier,
|
||||||
@@ -24,10 +24,16 @@ module ChuSQL.Web.Actions (
|
|||||||
createUserSql,
|
createUserSql,
|
||||||
alterUserSql,
|
alterUserSql,
|
||||||
dropUserSql,
|
dropUserSql,
|
||||||
|
createRoleSql,
|
||||||
|
dropRoleSql,
|
||||||
|
grantPrivilegesSql,
|
||||||
|
revokePrivilegesSql,
|
||||||
|
grantRoleSql,
|
||||||
|
revokeRoleSql,
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import ChuSQL.Model (Column (..), ColumnType (..), Value (..), integerType, numericType, parseColumnType, plainColumn, typeName)
|
import ChuSQL.Core.Model (Column (..), ColumnType (..), Value (..), integerType, numericType, parseColumnType, plainColumn, typeName)
|
||||||
import qualified ChuSQL.Model as M
|
import qualified ChuSQL.Core.Model as M
|
||||||
import qualified Data.Aeson as A
|
import qualified Data.Aeson as A
|
||||||
import Data.Char (isAsciiLower, isAsciiUpper, isDigit)
|
import Data.Char (isAsciiLower, isAsciiUpper, isDigit)
|
||||||
import Data.List (intercalate)
|
import Data.List (intercalate)
|
||||||
@@ -38,12 +44,14 @@ import Text.Read (readMaybe)
|
|||||||
|
|
||||||
-- 把界面上的一键操作翻译成安全 SQL:标识符过白名单,值一律转义。
|
-- 把界面上的一键操作翻译成安全 SQL:标识符过白名单,值一律转义。
|
||||||
|
|
||||||
|
-- | 界面上传来的一列:名字与类型名
|
||||||
data ColumnSpec = ColumnSpec
|
data ColumnSpec = ColumnSpec
|
||||||
{ csName :: Text
|
{ csName :: Text
|
||||||
, csType :: Text
|
, csType :: Text
|
||||||
}
|
}
|
||||||
deriving (Show, Eq)
|
deriving (Show, Eq)
|
||||||
|
|
||||||
|
-- | 建表请求:表名 + 列
|
||||||
data CreateTableSpec = CreateTableSpec
|
data CreateTableSpec = CreateTableSpec
|
||||||
{ ctsTable :: Text
|
{ ctsTable :: Text
|
||||||
, ctsColumns :: [ColumnSpec]
|
, ctsColumns :: [ColumnSpec]
|
||||||
@@ -59,6 +67,7 @@ isIdentifier t =
|
|||||||
(c : cs) -> (isAsciiLower c || isAsciiUpper c || c == '_') && all restChar cs
|
(c : cs) -> (isAsciiLower c || isAsciiUpper c || c == '_') && all restChar cs
|
||||||
[] -> False
|
[] -> False
|
||||||
where
|
where
|
||||||
|
-- | 标识符里其余字符的合法集
|
||||||
restChar c = isAsciiLower c || isAsciiUpper c || isDigit c || c == '_'
|
restChar c = isAsciiLower c || isAsciiUpper c || isDigit c || c == '_'
|
||||||
|
|
||||||
-- | 检查一个标识符,不合法就报错(带上"是表名还是列名")
|
-- | 检查一个标识符,不合法就报错(带上"是表名还是列名")
|
||||||
@@ -138,6 +147,7 @@ createTableSql spec = do
|
|||||||
defs <- mapM columnDef cols
|
defs <- mapM columnDef cols
|
||||||
pure ("CREATE TABLE " ++ T.unpack tbl ++ " (" ++ intercalate ", " defs ++ ")")
|
pure ("CREATE TABLE " ++ T.unpack tbl ++ " (" ++ intercalate ", " defs ++ ")")
|
||||||
where
|
where
|
||||||
|
-- | 列类型不认时报错
|
||||||
columnDef (ColumnSpec name ty) = do
|
columnDef (ColumnSpec name ty) = do
|
||||||
col <- checkIdent "column" name
|
col <- checkIdent "column" name
|
||||||
ctype <- maybe (Left ("unknown column type: " ++ T.unpack ty)) Right (columnTypeOf ty)
|
ctype <- maybe (Left ("unknown column type: " ++ T.unpack ty)) Right (columnTypeOf ty)
|
||||||
@@ -238,6 +248,7 @@ whereClause filters = case filters of
|
|||||||
terms <- mapM term filters
|
terms <- mapM term filters
|
||||||
pure (" WHERE " ++ intercalate " AND " terms)
|
pure (" WHERE " ++ intercalate " AND " terms)
|
||||||
where
|
where
|
||||||
|
-- | 一个等值条件
|
||||||
term (column, value) = do
|
term (column, value) = do
|
||||||
col <- checkIdent "column" column
|
col <- checkIdent "column" column
|
||||||
pure (T.unpack col ++ " = " ++ sqlLiteral value)
|
pure (T.unpack col ++ " = " ++ sqlLiteral value)
|
||||||
@@ -249,20 +260,21 @@ orderClause (Just (column, ascending)) = do
|
|||||||
col <- checkIdent "column" column
|
col <- checkIdent "column" column
|
||||||
pure (" ORDER BY " ++ T.unpack col ++ (if ascending then " ASC" else " DESC"))
|
pure (" ORDER BY " ++ T.unpack col ++ (if ascending then " ASC" else " DESC"))
|
||||||
|
|
||||||
-- | 账号名:与普通标识符同一套规则,另允许点与短横线(写成字符串形式)
|
-- | 账号名:标识符规则另允许点与短横线
|
||||||
accountName :: Text -> Either String Text
|
accountName :: Text -> Either String Text
|
||||||
accountName name
|
accountName name
|
||||||
| T.null name || T.length name > 64 = Left "account name is empty or too long"
|
| 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)
|
| not (T.all allowed name) = Left ("account name may only contain ASCII letters, digits, dot, dash or underscore: " ++ T.unpack name)
|
||||||
| otherwise = Right name
|
| otherwise = Right name
|
||||||
where
|
where
|
||||||
|
-- | 账号名允许的字符集
|
||||||
allowed c = isAsciiLower c || isAsciiUpper c || isDigit c || c `elem` ("_.-" :: String)
|
allowed c = isAsciiLower c || isAsciiUpper c || isDigit c || c `elem` ("_.-" :: String)
|
||||||
|
|
||||||
-- | 单引号字符串字面量
|
-- | 单引号字符串字面量
|
||||||
quoted :: Text -> String
|
quoted :: Text -> String
|
||||||
quoted value = "'" ++ escapeStringLiteral (T.unpack value) ++ "'"
|
quoted value = "'" ++ escapeStringLiteral (T.unpack value) ++ "'"
|
||||||
|
|
||||||
-- | 构造 CREATE USER(口令是明文,账号服务负责加盐派生)
|
-- | 构造 CREATE USER
|
||||||
createUserSql :: Text -> Text -> Either String String
|
createUserSql :: Text -> Text -> Either String String
|
||||||
createUserSql name password = do
|
createUserSql name password = do
|
||||||
user <- accountName name
|
user <- accountName name
|
||||||
@@ -283,3 +295,77 @@ dropUserSql :: Text -> Either String String
|
|||||||
dropUserSql name = do
|
dropUserSql name = do
|
||||||
user <- accountName name
|
user <- accountName name
|
||||||
pure ("DROP USER " ++ T.unpack user)
|
pure ("DROP USER " ++ T.unpack user)
|
||||||
|
|
||||||
|
-- | 角色名:与账号名同一套字符规则
|
||||||
|
roleName :: Text -> Either String Text
|
||||||
|
roleName name
|
||||||
|
| T.null name || T.length name > 64 = Left "role name is empty or too long"
|
||||||
|
| not (T.all allowed name) = Left ("role 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)
|
||||||
|
|
||||||
|
-- | 授权对象:`*`,或长度不超过 128 的裸表名/`库.表`
|
||||||
|
grantObject :: Text -> Either String Text
|
||||||
|
grantObject object
|
||||||
|
| T.strip object == "*" = Right "*"
|
||||||
|
| T.null object || T.length object > 128 = Left "object name is empty or too long"
|
||||||
|
| not (T.all allowed object) = Left ("object name may only contain ASCII letters, digits, dot, dash or underscore: " ++ T.unpack object)
|
||||||
|
| otherwise = Right object
|
||||||
|
where
|
||||||
|
-- | 授权对象允许的字符集
|
||||||
|
allowed c = isAsciiLower c || isAsciiUpper c || isDigit c || c `elem` ("_.-" :: String)
|
||||||
|
|
||||||
|
-- | 权限清单:词要纯字母,认不认由 server 定
|
||||||
|
privilegeWords :: [Text] -> Either String [Text]
|
||||||
|
privilegeWords privileges
|
||||||
|
| null privileges = Left "at least one privilege is required"
|
||||||
|
| otherwise = mapM word privileges
|
||||||
|
where
|
||||||
|
-- | 单个权限词
|
||||||
|
word one
|
||||||
|
| T.null one || not (T.all (\c -> isAsciiLower c || isAsciiUpper c) one) = Left ("not a privilege keyword: " ++ T.unpack one)
|
||||||
|
| otherwise = Right one
|
||||||
|
|
||||||
|
-- | 构造 CREATE ROLE
|
||||||
|
createRoleSql :: Text -> Either String String
|
||||||
|
createRoleSql name = do
|
||||||
|
role <- roleName name
|
||||||
|
pure ("CREATE ROLE " ++ T.unpack role)
|
||||||
|
|
||||||
|
-- | 构造 DROP ROLE
|
||||||
|
dropRoleSql :: Text -> Either String String
|
||||||
|
dropRoleSql name = do
|
||||||
|
role <- roleName name
|
||||||
|
pure ("DROP ROLE " ++ T.unpack role)
|
||||||
|
|
||||||
|
-- | 构造 GRANT 权限 ON 对象 TO 角色
|
||||||
|
grantPrivilegesSql :: [Text] -> Text -> Text -> Either String String
|
||||||
|
grantPrivilegesSql privileges object role = do
|
||||||
|
words' <- privilegeWords privileges
|
||||||
|
target <- grantObject object
|
||||||
|
who <- roleName role
|
||||||
|
pure ("GRANT " ++ T.unpack (T.intercalate ", " words') ++ " ON " ++ T.unpack target ++ " TO " ++ T.unpack who)
|
||||||
|
|
||||||
|
-- | 构造 REVOKE 权限 ON 对象 FROM 角色
|
||||||
|
revokePrivilegesSql :: [Text] -> Text -> Text -> Either String String
|
||||||
|
revokePrivilegesSql privileges object role = do
|
||||||
|
words' <- privilegeWords privileges
|
||||||
|
target <- grantObject object
|
||||||
|
who <- roleName role
|
||||||
|
pure ("REVOKE " ++ T.unpack (T.intercalate ", " words') ++ " ON " ++ T.unpack target ++ " FROM " ++ T.unpack who)
|
||||||
|
|
||||||
|
-- | 构造 GRANT <角色> TO <用户>[, ...]
|
||||||
|
grantRoleSql :: Text -> [Text] -> Either String String
|
||||||
|
grantRoleSql role users = do
|
||||||
|
who <- roleName role
|
||||||
|
members <- requireNonEmpty "users" users >>= mapM accountName
|
||||||
|
pure ("GRANT " ++ T.unpack who ++ " TO " ++ T.unpack (T.intercalate ", " members))
|
||||||
|
|
||||||
|
-- | 构造 REVOKE 角色 FROM 用户
|
||||||
|
revokeRoleSql :: Text -> [Text] -> Either String String
|
||||||
|
revokeRoleSql role users = do
|
||||||
|
who <- roleName role
|
||||||
|
members <- requireNonEmpty "users" users >>= mapM accountName
|
||||||
|
pure ("REVOKE " ++ T.unpack who ++ " FROM " ++ T.unpack (T.intercalate ", " members))
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
module ChuSQL.Web.Auth (
|
module ChuSQL.Interface.Auth (
|
||||||
Credential (..),
|
Credential (..),
|
||||||
defaultIterations,
|
defaultIterations,
|
||||||
hashPassword,
|
hashPassword,
|
||||||
@@ -19,6 +19,15 @@ module ChuSQL.Web.Auth (
|
|||||||
lookupSession,
|
lookupSession,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
sessionToken,
|
sessionToken,
|
||||||
|
PayloadStore,
|
||||||
|
newPayloadStore,
|
||||||
|
payloadPolicy,
|
||||||
|
setPayloadPolicy,
|
||||||
|
createPayloadSession,
|
||||||
|
lookupPayload,
|
||||||
|
putPayload,
|
||||||
|
dropPayload,
|
||||||
|
sweepPayloads,
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Control.Concurrent.MVar (MVar, modifyMVar, modifyMVar_, newMVar)
|
import Control.Concurrent.MVar (MVar, modifyMVar, modifyMVar_, newMVar)
|
||||||
@@ -37,8 +46,9 @@ import qualified Data.Text.Encoding as TE
|
|||||||
import Data.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime)
|
import Data.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime)
|
||||||
import Text.Read (readMaybe)
|
import Text.Read (readMaybe)
|
||||||
|
|
||||||
-- 口令与 Session:口令只存 PBKDF2 编码串,会话是服务端内存状态。
|
-- 口令哈希与会话仓库,服务端与 Web 管理端共用。
|
||||||
|
|
||||||
|
-- | 一条账号凭据:账号名 + 编码串
|
||||||
data Credential = Credential
|
data Credential = Credential
|
||||||
{ credUser :: Text
|
{ credUser :: Text
|
||||||
, credEncoded :: Text
|
, credEncoded :: Text
|
||||||
@@ -115,6 +125,7 @@ verifyPassword encoded candidate = case parseHash encoded of
|
|||||||
hashLooksValid :: Text -> Bool
|
hashLooksValid :: Text -> Bool
|
||||||
hashLooksValid = isJust . parseHash
|
hashLooksValid = isJust . parseHash
|
||||||
|
|
||||||
|
-- | 一条会话:账号、创建/最后活动时间与账号版本号
|
||||||
data Session = Session
|
data Session = Session
|
||||||
{ sessUser :: Text
|
{ sessUser :: Text
|
||||||
, sessCreated :: UTCTime
|
, sessCreated :: UTCTime
|
||||||
@@ -122,6 +133,7 @@ data Session = Session
|
|||||||
, sessRevision :: Maybe Integer
|
, sessRevision :: Maybe Integer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- | 会话过期策略:空闲上限与绝对上限
|
||||||
data SessionPolicy = SessionPolicy
|
data SessionPolicy = SessionPolicy
|
||||||
{ spIdleSeconds :: NominalDiffTime
|
{ spIdleSeconds :: NominalDiffTime
|
||||||
, spAbsoluteSeconds :: NominalDiffTime
|
, spAbsoluteSeconds :: NominalDiffTime
|
||||||
@@ -131,6 +143,7 @@ data SessionPolicy = SessionPolicy
|
|||||||
defaultSessionPolicy :: SessionPolicy
|
defaultSessionPolicy :: SessionPolicy
|
||||||
defaultSessionPolicy = SessionPolicy (8 * 3600) (24 * 3600)
|
defaultSessionPolicy = SessionPolicy (8 * 3600) (24 * 3600)
|
||||||
|
|
||||||
|
-- | 会话仓库:令牌表、时钟与策略
|
||||||
data SessionStore = SessionStore
|
data SessionStore = SessionStore
|
||||||
{ storeMap :: MVar (Map.Map Text Session)
|
{ storeMap :: MVar (Map.Map Text Session)
|
||||||
, storeClock :: IO UTCTime
|
, storeClock :: IO UTCTime
|
||||||
@@ -170,6 +183,7 @@ sessionToken = hexEncode <$> getRandomBytes 32
|
|||||||
createSession :: SessionStore -> Text -> IO Text
|
createSession :: SessionStore -> Text -> IO Text
|
||||||
createSession store user = createVersionedSession store user Nothing
|
createSession store user = createVersionedSession store user Nothing
|
||||||
|
|
||||||
|
-- | 开一条会话并记下账号版本号,返回令牌
|
||||||
createVersionedSession :: SessionStore -> Text -> Maybe Integer -> IO Text
|
createVersionedSession :: SessionStore -> Text -> Maybe Integer -> IO Text
|
||||||
createVersionedSession store user revision = do
|
createVersionedSession store user revision = do
|
||||||
token <- sessionToken
|
token <- sessionToken
|
||||||
@@ -183,6 +197,7 @@ createVersionedSession store user revision = do
|
|||||||
lookupSession :: SessionStore -> Text -> IO (Maybe Text)
|
lookupSession :: SessionStore -> Text -> IO (Maybe Text)
|
||||||
lookupSession store token = fmap (fmap fst) (lookupVersionedSession store token)
|
lookupSession store token = fmap (fmap fst) (lookupVersionedSession store token)
|
||||||
|
|
||||||
|
-- | 查令牌拿回账号名与账号版本号,并刷新最后活动时间
|
||||||
lookupVersionedSession :: SessionStore -> Text -> IO (Maybe (Text, Maybe Integer))
|
lookupVersionedSession :: SessionStore -> Text -> IO (Maybe (Text, Maybe Integer))
|
||||||
lookupVersionedSession store token = do
|
lookupVersionedSession store token = do
|
||||||
now <- storeClock store
|
now <- storeClock store
|
||||||
@@ -196,3 +211,95 @@ lookupVersionedSession store token = do
|
|||||||
-- | 删掉一条会话(退出登录)
|
-- | 删掉一条会话(退出登录)
|
||||||
deleteSession :: SessionStore -> Text -> IO ()
|
deleteSession :: SessionStore -> Text -> IO ()
|
||||||
deleteSession store token = modifyMVar_ (storeMap store) (pure . Map.delete token)
|
deleteSession store token = modifyMVar_ (storeMap store) (pure . Map.delete token)
|
||||||
|
|
||||||
|
-- | 一条带载荷的会话:账号、时间与载荷
|
||||||
|
data StoredSession a = StoredSession
|
||||||
|
{ psUser :: Text
|
||||||
|
, psCreated :: UTCTime
|
||||||
|
, psSeen :: UTCTime
|
||||||
|
, psPayload :: a
|
||||||
|
}
|
||||||
|
|
||||||
|
-- | 带载荷的会话仓库:令牌表、时钟与策略
|
||||||
|
data PayloadStore a = PayloadStore
|
||||||
|
{ psStoreMap :: MVar (Map.Map Text (StoredSession a))
|
||||||
|
, psClock :: IO UTCTime
|
||||||
|
, psPolicy :: IORef SessionPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
-- | 造一个空的带载荷仓库
|
||||||
|
newPayloadStore :: IO UTCTime -> SessionPolicy -> IO (PayloadStore a)
|
||||||
|
newPayloadStore clock policy = do
|
||||||
|
ref <- newMVar Map.empty
|
||||||
|
policyRef <- newIORef policy
|
||||||
|
pure (PayloadStore ref clock policyRef)
|
||||||
|
|
||||||
|
-- | 现在用的是哪套过期策略
|
||||||
|
payloadPolicy :: PayloadStore a -> IO SessionPolicy
|
||||||
|
payloadPolicy = readIORef . psPolicy
|
||||||
|
|
||||||
|
-- | 换一套过期策略
|
||||||
|
setPayloadPolicy :: PayloadStore a -> SessionPolicy -> IO ()
|
||||||
|
setPayloadPolicy store = writeIORef (psPolicy store)
|
||||||
|
|
||||||
|
-- | 这条带载荷的会话过期了吗
|
||||||
|
payloadExpired :: UTCTime -> SessionPolicy -> StoredSession a -> Bool
|
||||||
|
payloadExpired now pol s =
|
||||||
|
diffUTCTime now (psSeen s) > spIdleSeconds pol
|
||||||
|
|| diffUTCTime now (psCreated s) > spAbsoluteSeconds pol
|
||||||
|
|
||||||
|
-- | 清掉过期的带载荷会话
|
||||||
|
prunePayloads :: UTCTime -> SessionPolicy -> Map.Map Text (StoredSession a) -> Map.Map Text (StoredSession a)
|
||||||
|
prunePayloads now pol = Map.filter (not . payloadExpired now pol)
|
||||||
|
|
||||||
|
-- | 上线一条带载荷的会话,返回令牌
|
||||||
|
createPayloadSession :: PayloadStore a -> Text -> a -> IO Text
|
||||||
|
createPayloadSession store user payload = do
|
||||||
|
token <- sessionToken
|
||||||
|
now <- psClock store
|
||||||
|
pol <- payloadPolicy store
|
||||||
|
modifyMVar_ (psStoreMap store) $ \m ->
|
||||||
|
pure (Map.insert token (StoredSession user now now payload) (prunePayloads now pol m))
|
||||||
|
pure token
|
||||||
|
|
||||||
|
-- | 查令牌并刷新最后活动时间,拿回账号名与载荷
|
||||||
|
lookupPayload :: PayloadStore a -> Text -> IO (Maybe (Text, a))
|
||||||
|
lookupPayload store token = do
|
||||||
|
now <- psClock store
|
||||||
|
pol <- payloadPolicy store
|
||||||
|
modifyMVar (psStoreMap store) $ \m -> do
|
||||||
|
let kept = prunePayloads now pol m
|
||||||
|
pure $ case Map.lookup token kept of
|
||||||
|
Nothing -> (kept, Nothing)
|
||||||
|
Just s -> (Map.insert token s{psSeen = now} kept, Just (psUser s, psPayload s))
|
||||||
|
|
||||||
|
-- | 换掉载荷;会话已没了就返回 False
|
||||||
|
putPayload :: PayloadStore a -> Text -> a -> IO Bool
|
||||||
|
putPayload store token payload = do
|
||||||
|
now <- psClock store
|
||||||
|
pol <- payloadPolicy store
|
||||||
|
modifyMVar (psStoreMap store) $ \m -> do
|
||||||
|
let kept = prunePayloads now pol m
|
||||||
|
pure $ case Map.lookup token kept of
|
||||||
|
Nothing -> (kept, False)
|
||||||
|
Just s -> (Map.insert token s{psSeen = now, psPayload = payload} kept, True)
|
||||||
|
|
||||||
|
-- | 摘掉会话并把载荷交还
|
||||||
|
dropPayload :: PayloadStore a -> Text -> IO (Maybe a)
|
||||||
|
dropPayload store token = do
|
||||||
|
now <- psClock store
|
||||||
|
pol <- payloadPolicy store
|
||||||
|
modifyMVar (psStoreMap store) $ \m -> do
|
||||||
|
let kept = prunePayloads now pol m
|
||||||
|
pure $ case Map.lookup token kept of
|
||||||
|
Nothing -> (kept, Nothing)
|
||||||
|
Just s -> (Map.delete token kept, Just (psPayload s))
|
||||||
|
|
||||||
|
-- | 清掉过期会话并把它们的载荷交还;调用者负责关掉这些载荷
|
||||||
|
sweepPayloads :: PayloadStore a -> IO [a]
|
||||||
|
sweepPayloads store = do
|
||||||
|
now <- psClock store
|
||||||
|
pol <- payloadPolicy store
|
||||||
|
modifyMVar (psStoreMap store) $ \m -> do
|
||||||
|
let (gone, kept) = Map.partition (payloadExpired now pol) m
|
||||||
|
pure (kept, map psPayload (Map.elems gone))
|
||||||
+62
-39
@@ -1,6 +1,6 @@
|
|||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
module ChuSQL.Web.Config (
|
module ChuSQL.Interface.Config (
|
||||||
WebConfig (..),
|
WebConfig (..),
|
||||||
defaultWebConfig,
|
defaultWebConfig,
|
||||||
defaultUser,
|
defaultUser,
|
||||||
@@ -12,12 +12,14 @@ module ChuSQL.Web.Config (
|
|||||||
staticDirCandidates,
|
staticDirCandidates,
|
||||||
rootUserName,
|
rootUserName,
|
||||||
canonicalSettingKeys,
|
canonicalSettingKeys,
|
||||||
resolvePipeName,
|
resolvePlainPassword,
|
||||||
|
ServerConfig (..),
|
||||||
|
defaultServerConfig,
|
||||||
|
loadServerConfigAt,
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import ChuSQL.Storage.IPC (defaultPipeName)
|
import ChuSQL.Interface.Auth (Credential (..), hashPassword)
|
||||||
import ChuSQL.Web.Auth (Credential (..), hashPassword)
|
import ChuSQL.Interface.TOML (readSection)
|
||||||
import ChuSQL.Web.TOML (readSection)
|
|
||||||
import Data.Char (toLower)
|
import Data.Char (toLower)
|
||||||
import qualified Data.Map.Strict as Map
|
import qualified Data.Map.Strict as Map
|
||||||
import Data.Maybe (fromMaybe)
|
import Data.Maybe (fromMaybe)
|
||||||
@@ -26,11 +28,7 @@ import qualified Data.Text as T
|
|||||||
import System.Directory (doesDirectoryExist)
|
import System.Directory (doesDirectoryExist)
|
||||||
import Text.Read (readMaybe)
|
import Text.Read (readMaybe)
|
||||||
|
|
||||||
-- 运行参数:命令行 > 全局 chusql.toml 的分区 > 内置默认,不看环境变量。
|
-- Web 与 server 的运行参数:命令行 > chusql.toml 分区 > 内置默认。
|
||||||
-- 配置文件位置是固定的(安装时定下,见 ChuSQL.Web.TOML.defaultConfigFile),
|
|
||||||
-- 想读别处的文件只能在命令行用 --config 指。
|
|
||||||
-- 自己那段是 [web];管名与数据目录取自 [server] pipe_name / [storage] data_dir,
|
|
||||||
-- 因为它们是存储进程的事实,前端只当端点读。root 凭据只认 [web] 里的明文。
|
|
||||||
|
|
||||||
-- | 默认账号名
|
-- | 默认账号名
|
||||||
defaultUser :: Text
|
defaultUser :: Text
|
||||||
@@ -40,13 +38,12 @@ defaultUser = "root"
|
|||||||
defaultPassword :: Text
|
defaultPassword :: Text
|
||||||
defaultPassword = "chusql"
|
defaultPassword = "chusql"
|
||||||
|
|
||||||
|
-- | Web 管理端配置
|
||||||
data WebConfig = WebConfig
|
data WebConfig = WebConfig
|
||||||
{ wcHost :: String
|
{ wcHost :: String
|
||||||
, wcPort :: Int
|
, wcPort :: Int
|
||||||
, wcStaticDir :: FilePath
|
, wcStaticDir :: FilePath
|
||||||
, wcUser :: Text
|
, wcUser :: Text
|
||||||
, wcStorageServer :: Maybe FilePath
|
|
||||||
, wcPipeName :: Maybe String
|
|
||||||
, wcDataDir :: Maybe FilePath
|
, wcDataDir :: Maybe FilePath
|
||||||
, wcCookieSecure :: Bool
|
, wcCookieSecure :: Bool
|
||||||
, wcBodyLimit :: Int
|
, wcBodyLimit :: Int
|
||||||
@@ -62,16 +59,14 @@ data WebConfig = WebConfig
|
|||||||
}
|
}
|
||||||
deriving (Show, Eq)
|
deriving (Show, Eq)
|
||||||
|
|
||||||
-- | 内置默认
|
-- | Web 管理端参数的内置默认值
|
||||||
defaultWebConfig :: WebConfig
|
defaultWebConfig :: WebConfig
|
||||||
defaultWebConfig =
|
defaultWebConfig =
|
||||||
WebConfig
|
WebConfig
|
||||||
{ wcHost = "127.0.0.1"
|
{ wcHost = "127.0.0.1"
|
||||||
, wcPort = 7777
|
, wcPort = 7778
|
||||||
, wcStaticDir = "static"
|
, wcStaticDir = "static"
|
||||||
, wcUser = ""
|
, wcUser = ""
|
||||||
, wcStorageServer = Nothing
|
|
||||||
, wcPipeName = Nothing
|
|
||||||
, wcDataDir = Nothing
|
, wcDataDir = Nothing
|
||||||
, wcCookieSecure = False
|
, wcCookieSecure = False
|
||||||
, wcBodyLimit = 65536
|
, wcBodyLimit = 65536
|
||||||
@@ -86,12 +81,12 @@ defaultWebConfig =
|
|||||||
, wcSeedDemo = False
|
, wcSeedDemo = False
|
||||||
}
|
}
|
||||||
|
|
||||||
-- | [web] 分区里的键统一成连字符写法(scripts/chusql.toml 用的是下划线)
|
-- | 把设置键统一成连字符写法,重名时连字符优先
|
||||||
-- 两种写法同时出现时,连字符(设置页写的规范形式)优先。
|
|
||||||
canonicalSettingKeys :: Map.Map Text Text -> Map.Map Text Text
|
canonicalSettingKeys :: Map.Map Text Text -> Map.Map Text Text
|
||||||
canonicalSettingKeys saved =
|
canonicalSettingKeys saved =
|
||||||
foldr (\(key, value) acc -> Map.insert (canonicalKey key) value acc) Map.empty (Map.toList saved)
|
foldr (\(key, value) acc -> Map.insert (canonicalKey key) value acc) Map.empty (Map.toList saved)
|
||||||
where
|
where
|
||||||
|
-- | 取一个键的规范名
|
||||||
canonicalKey key = fromMaybe key (lookup key keyAliases)
|
canonicalKey key = fromMaybe key (lookup key keyAliases)
|
||||||
|
|
||||||
-- | 下划线写法到连字符写法的别名
|
-- | 下划线写法到连字符写法的别名
|
||||||
@@ -109,22 +104,20 @@ keyAliases =
|
|||||||
, ("max_sql_length", "max-sql-length")
|
, ("max_sql_length", "max-sql-length")
|
||||||
, ("password_min_length", "password-min-length")
|
, ("password_min_length", "password-min-length")
|
||||||
, ("password_classes", "password-classes")
|
, ("password_classes", "password-classes")
|
||||||
, ("storage_server", "storage-server")
|
|
||||||
]
|
]
|
||||||
|
|
||||||
-- | 这份配置用的还是内置默认口令吗。
|
-- | 这份配置用的还是内置默认口令吗
|
||||||
-- 显式留空(password = "")是"只允许管理员免密登录",算已经配置过。
|
|
||||||
usingDefaultCredentials :: Map.Map Text Text -> Bool
|
usingDefaultCredentials :: Map.Map Text Text -> Bool
|
||||||
usingDefaultCredentials saved = Map.notMember "password" saved
|
usingDefaultCredentials saved = Map.notMember "password" saved
|
||||||
|
|
||||||
-- | 设置文件里某个键的非空值
|
-- | 设置文件里某个键的非空值
|
||||||
nonEmptyValue :: Text -> Map.Map Text Text -> Maybe Text
|
nonEmptyValue :: Text -> Map.Map Text Text -> Maybe Text
|
||||||
|
-- | 查一个键
|
||||||
nonEmptyValue key saved = case T.strip <$> Map.lookup key saved of
|
nonEmptyValue key saved = case T.strip <$> Map.lookup key saved of
|
||||||
Just value | not (T.null value) -> Just value
|
Just value | not (T.null value) -> Just value
|
||||||
_ -> Nothing
|
_ -> Nothing
|
||||||
|
|
||||||
-- | root 凭据只认设置文件里的明文:给了明文就哈希它;显式留空就是免密(空编码串);
|
-- | root 凭据:明文就哈希,留空免密,缺省用演示口令
|
||||||
-- 没配这一项才回落到内置演示口令。
|
|
||||||
resolveCredential :: WebConfig -> Map.Map Text Text -> IO Credential
|
resolveCredential :: WebConfig -> Map.Map Text Text -> IO Credential
|
||||||
resolveCredential cfg saved = do
|
resolveCredential cfg saved = do
|
||||||
encoded <- case Map.lookup "password" saved of
|
encoded <- case Map.lookup "password" saved of
|
||||||
@@ -133,32 +126,29 @@ resolveCredential cfg saved = do
|
|||||||
Nothing -> hashPassword defaultPassword
|
Nothing -> hashPassword defaultPassword
|
||||||
pure (Credential (rootUserName cfg saved) encoded)
|
pure (Credential (rootUserName cfg saved) encoded)
|
||||||
|
|
||||||
-- | 管理员名字:命令行 --user > 设置文件 user > 内置默认
|
-- | 管理员名字:命令行 > 设置文件 > 内置默认
|
||||||
rootUserName :: WebConfig -> Map.Map Text Text -> Text
|
rootUserName :: WebConfig -> Map.Map Text Text -> Text
|
||||||
rootUserName cfg saved
|
rootUserName cfg saved
|
||||||
| not (T.null (T.strip (wcUser cfg))) = T.strip (wcUser cfg)
|
| not (T.null (T.strip (wcUser cfg))) = T.strip (wcUser cfg)
|
||||||
| otherwise = fromMaybe defaultUser (nonEmptyValue "user" saved)
|
| otherwise = fromMaybe defaultUser (nonEmptyValue "user" saved)
|
||||||
|
|
||||||
-- | 读配置:内置默认 < [web] 分区(端点取自 [server] / [storage])。
|
-- | 读配置:默认叠上 [web] 与 [storage]
|
||||||
-- user / password 不在这里读:它们由 rootUserName / resolveCredential 直接看设置文件。
|
|
||||||
loadWebConfigAt :: FilePath -> IO WebConfig
|
loadWebConfigAt :: FilePath -> IO WebConfig
|
||||||
loadWebConfigAt path = do
|
loadWebConfigAt path = do
|
||||||
raw <- readSection path "web"
|
raw <- readSection path "web"
|
||||||
server <- readSection path "server"
|
|
||||||
storage <- readSection path "storage"
|
storage <- readSection path "storage"
|
||||||
pure (withEndpoint server storage (webConfigFromSection (canonicalSettingKeys raw)))
|
pure (withStorage storage (webConfigFromSection (canonicalSettingKeys raw)))
|
||||||
|
|
||||||
-- | 端点:存储进程 bind 的管名与它用的数据目录,前端只读不改
|
-- | 数据目录取自 [storage] 段
|
||||||
withEndpoint :: Map.Map Text Text -> Map.Map Text Text -> WebConfig -> WebConfig
|
withStorage :: Map.Map Text Text -> WebConfig -> WebConfig
|
||||||
withEndpoint server storage cfg =
|
withStorage storage cfg =
|
||||||
cfg
|
cfg{wcDataDir = T.unpack <$> nonEmptyValue "data_dir" storage}
|
||||||
{ wcPipeName = T.unpack <$> nonEmptyValue "pipe_name" server
|
|
||||||
, wcDataDir = T.unpack <$> nonEmptyValue "data_dir" storage
|
|
||||||
}
|
|
||||||
|
|
||||||
-- | 实际要连的管名:配置里没有就用与存储层一致的默认值
|
-- | 管理员明文口令,没配回落到演示口令,显式留空回空串
|
||||||
resolvePipeName :: WebConfig -> String
|
resolvePlainPassword :: Map.Map Text Text -> Text
|
||||||
resolvePipeName cfg = fromMaybe defaultPipeName (wcPipeName cfg)
|
resolvePlainPassword saved = case Map.lookup "password" saved of
|
||||||
|
Just plain -> T.strip plain
|
||||||
|
Nothing -> defaultPassword
|
||||||
|
|
||||||
-- | [web] 分区里的启动参数,缺失或坏值一律回到内置默认
|
-- | [web] 分区里的启动参数,缺失或坏值一律回到内置默认
|
||||||
webConfigFromSection :: Map.Map Text Text -> WebConfig
|
webConfigFromSection :: Map.Map Text Text -> WebConfig
|
||||||
@@ -178,7 +168,6 @@ webConfigFromSection saved =
|
|||||||
, wcMaxRows = sectionInt "max-rows" saved (wcMaxRows defaultWebConfig)
|
, wcMaxRows = sectionInt "max-rows" saved (wcMaxRows defaultWebConfig)
|
||||||
, wcMaxSqlLength = sectionInt "max-sql-length" saved (wcMaxSqlLength defaultWebConfig)
|
, wcMaxSqlLength = sectionInt "max-sql-length" saved (wcMaxSqlLength defaultWebConfig)
|
||||||
, wcSeedDemo = sectionBool "seed" saved (wcSeedDemo defaultWebConfig)
|
, wcSeedDemo = sectionBool "seed" saved (wcSeedDemo defaultWebConfig)
|
||||||
, wcStorageServer = T.unpack <$> nonEmptyValue "storage-server" saved
|
|
||||||
}
|
}
|
||||||
|
|
||||||
-- | 分区里一个键的非空字符串值
|
-- | 分区里一个键的非空字符串值
|
||||||
@@ -204,7 +193,41 @@ staticDirCandidates given =
|
|||||||
resolveStaticDir :: FilePath -> IO (Maybe FilePath)
|
resolveStaticDir :: FilePath -> IO (Maybe FilePath)
|
||||||
resolveStaticDir given = go (staticDirCandidates given)
|
resolveStaticDir given = go (staticDirCandidates given)
|
||||||
where
|
where
|
||||||
|
-- | 逐个试
|
||||||
go [] = pure Nothing
|
go [] = pure Nothing
|
||||||
go (d : ds) = do
|
go (d : ds) = do
|
||||||
ok <- doesDirectoryExist d
|
ok <- doesDirectoryExist d
|
||||||
if ok then pure (Just d) else go ds
|
if ok then pure (Just d) else go ds
|
||||||
|
|
||||||
|
-- | 服务器监听参数
|
||||||
|
data ServerConfig = ServerConfig
|
||||||
|
{ scHost :: String
|
||||||
|
, scPort :: Int
|
||||||
|
, scMaxMessage :: Int
|
||||||
|
, scMaxRows :: Int
|
||||||
|
}
|
||||||
|
deriving (Eq, Show)
|
||||||
|
|
||||||
|
-- | 默认本机 7777,行 1 MiB,最多 1000 行
|
||||||
|
defaultServerConfig :: ServerConfig
|
||||||
|
defaultServerConfig = ServerConfig "127.0.0.1" 7777 (1024 * 1024) 1000
|
||||||
|
|
||||||
|
-- | 读 [server] 段键,缺的用默认
|
||||||
|
loadServerConfigAt :: FilePath -> IO ServerConfig
|
||||||
|
loadServerConfigAt path = do
|
||||||
|
values <- readSection path "server"
|
||||||
|
let -- | 取一个键的非空字符串值
|
||||||
|
textOf key fallback = case Map.lookup key values of
|
||||||
|
Just value | not (T.null (T.strip value)) -> T.unpack (T.strip value)
|
||||||
|
_ -> fallback
|
||||||
|
-- | 取一个键的整数值
|
||||||
|
intOf key fallback = case Map.lookup key values >>= readMaybe . T.unpack . T.strip of
|
||||||
|
Just number -> number
|
||||||
|
Nothing -> fallback
|
||||||
|
pure
|
||||||
|
defaultServerConfig
|
||||||
|
{ scHost = textOf "host" (scHost defaultServerConfig)
|
||||||
|
, scPort = intOf "port" (scPort defaultServerConfig)
|
||||||
|
, scMaxMessage = intOf "max_message" (scMaxMessage defaultServerConfig)
|
||||||
|
, scMaxRows = intOf "max_rows" (scMaxRows defaultServerConfig)
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
|
module ChuSQL.Interface.Link (
|
||||||
|
Client,
|
||||||
|
connectClient,
|
||||||
|
closeClient,
|
||||||
|
clientLogin,
|
||||||
|
clientQuery,
|
||||||
|
clientPing,
|
||||||
|
clientCatalog,
|
||||||
|
clientDatabases,
|
||||||
|
clientRoles,
|
||||||
|
clientAccounts,
|
||||||
|
clientPolicy,
|
||||||
|
clientReloadPolicy,
|
||||||
|
) where
|
||||||
|
|
||||||
|
import ChuSQL.Core.Protocol (Account, QueryResult (..), TableInfo (..))
|
||||||
|
import ChuSQL.Interface.Protocol (
|
||||||
|
ClientRequest (..),
|
||||||
|
RoleView,
|
||||||
|
ServerResponse (..),
|
||||||
|
decodeResponse,
|
||||||
|
encodeRequest,
|
||||||
|
protocolVersion,
|
||||||
|
)
|
||||||
|
import Control.Concurrent.MVar (MVar, newMVar, withMVar)
|
||||||
|
import Control.Exception (IOException, try)
|
||||||
|
import qualified Data.ByteString.Lazy as BL
|
||||||
|
import qualified Data.ByteString.Lazy.Char8 as BLC
|
||||||
|
import qualified Data.ByteString.Char8 as BSC
|
||||||
|
import Data.Text (Text)
|
||||||
|
import qualified Data.Text as T
|
||||||
|
import Network.Socket (
|
||||||
|
AddrInfo (..),
|
||||||
|
SocketType (Stream),
|
||||||
|
addrAddress,
|
||||||
|
addrFamily,
|
||||||
|
connect,
|
||||||
|
defaultHints,
|
||||||
|
defaultProtocol,
|
||||||
|
getAddrInfo,
|
||||||
|
socket,
|
||||||
|
socketToHandle,
|
||||||
|
)
|
||||||
|
import System.IO (BufferMode (LineBuffering), Handle, IOMode (ReadWriteMode), hClose, hFlush, hSetBuffering, hSetNewlineMode, noNewlineTranslation)
|
||||||
|
import System.IO.Error (isEOFError)
|
||||||
|
|
||||||
|
-- 前端到 server 的 TCP 链路:一行一条 JSON,命令行与 Web 端共用。
|
||||||
|
|
||||||
|
-- | 一条已连上、已对过协议版本的连接
|
||||||
|
data Client = Client
|
||||||
|
{ clHandle :: Handle
|
||||||
|
, clLock :: MVar ()
|
||||||
|
}
|
||||||
|
|
||||||
|
-- | 连上 server 并对协议版本,失败给人话错误
|
||||||
|
connectClient :: Text -> Int -> IO (Either Text Client)
|
||||||
|
connectClient host port = do
|
||||||
|
opened <- try (openConnection host port) :: IO (Either IOException Handle)
|
||||||
|
case opened of
|
||||||
|
Left err -> pure (Left ("cannot reach the server at " <> T.pack (address host port) <> ": " <> T.pack (describe err)))
|
||||||
|
Right handle -> do
|
||||||
|
lock <- newMVar ()
|
||||||
|
let client = Client handle lock
|
||||||
|
greeted <- request client (ReqHello protocolVersion)
|
||||||
|
case greeted of
|
||||||
|
Right (RespHello version)
|
||||||
|
| version == protocolVersion -> pure (Right client)
|
||||||
|
| otherwise ->
|
||||||
|
giveUp handle
|
||||||
|
( "server "
|
||||||
|
<> T.pack (address host port)
|
||||||
|
<> " speaks protocol "
|
||||||
|
<> T.pack (show version)
|
||||||
|
<> ", this client needs "
|
||||||
|
<> T.pack (show protocolVersion)
|
||||||
|
)
|
||||||
|
Right (RespError code message) -> giveUp handle (code <> ": " <> message)
|
||||||
|
Right _ -> giveUp handle "unexpected response to hello"
|
||||||
|
Left message -> giveUp handle message
|
||||||
|
where
|
||||||
|
-- | 对不上就关连接并回错误
|
||||||
|
giveUp handle message = do
|
||||||
|
_ <- try (hClose handle) :: IO (Either IOException ())
|
||||||
|
pure (Left message)
|
||||||
|
|
||||||
|
-- | 断开连接;重复调用不报错
|
||||||
|
closeClient :: Client -> IO ()
|
||||||
|
closeClient client = do
|
||||||
|
_ <- try (hClose (clHandle client)) :: IO (Either IOException ())
|
||||||
|
pure ()
|
||||||
|
|
||||||
|
-- | 一行请求一行响应;同一条连接上的请求串起来发
|
||||||
|
request :: Client -> ClientRequest -> IO (Either Text ServerResponse)
|
||||||
|
request client message = withMVar (clLock client) $ \_ -> do
|
||||||
|
outcome <- try (exchange (clHandle client) message) :: IO (Either IOException (Either Text ServerResponse))
|
||||||
|
pure $ case outcome of
|
||||||
|
Left err -> Left (T.pack (describe err))
|
||||||
|
Right answer -> answer
|
||||||
|
|
||||||
|
-- | 在句柄上发一行请求、读回一行响应
|
||||||
|
exchange :: Handle -> ClientRequest -> IO (Either Text ServerResponse)
|
||||||
|
exchange handle message = do
|
||||||
|
BLC.hPutStr handle (encodeRequest message)
|
||||||
|
BLC.hPutStr handle "\n"
|
||||||
|
hFlush handle
|
||||||
|
line <- BSC.hGetLine handle
|
||||||
|
pure (decodeResponse (BL.fromStrict line))
|
||||||
|
|
||||||
|
-- | 登录;成功返回是不是管理员
|
||||||
|
clientLogin :: Client -> Text -> Text -> IO (Either Text Bool)
|
||||||
|
clientLogin client user password = do
|
||||||
|
answer <- request client (ReqLogin user password)
|
||||||
|
case answer of
|
||||||
|
Left message -> pure (Left message)
|
||||||
|
Right (RespLogin _ admin) -> pure (Right admin)
|
||||||
|
Right other -> pure (failed "login" other)
|
||||||
|
|
||||||
|
-- | 跑一条语句,拿回结果集
|
||||||
|
clientQuery :: Client -> Text -> IO (Either Text QueryResult)
|
||||||
|
clientQuery client sql = do
|
||||||
|
answer <- request client (ReqQuery sql)
|
||||||
|
case answer of
|
||||||
|
Left message -> pure (Left message)
|
||||||
|
Right (RespResult result) -> pure (Right result)
|
||||||
|
Right other -> pure (failed "a query" other)
|
||||||
|
|
||||||
|
-- | 探活
|
||||||
|
clientPing :: Client -> IO (Either Text ())
|
||||||
|
clientPing client = do
|
||||||
|
answer <- request client ReqPing
|
||||||
|
case answer of
|
||||||
|
Left message -> pure (Left message)
|
||||||
|
Right RespPong -> pure (Right ())
|
||||||
|
Right other -> pure (failed "a ping" other)
|
||||||
|
|
||||||
|
-- | 数据字典(\\dt 与 \\d 用)
|
||||||
|
clientCatalog :: Client -> IO (Either Text [TableInfo])
|
||||||
|
clientCatalog client = do
|
||||||
|
answer <- request client ReqCatalog
|
||||||
|
case answer of
|
||||||
|
Left message -> pure (Left message)
|
||||||
|
Right (RespCatalog infos) -> pure (Right infos)
|
||||||
|
Right other -> pure (failed "a catalog" other)
|
||||||
|
|
||||||
|
-- | 库清单(\\l 用)
|
||||||
|
clientDatabases :: Client -> IO (Either Text [Text])
|
||||||
|
clientDatabases client = do
|
||||||
|
answer <- request client ReqDatabases
|
||||||
|
case answer of
|
||||||
|
Left message -> pure (Left message)
|
||||||
|
Right (RespDatabases names) -> pure (Right names)
|
||||||
|
Right other -> pure (failed "a database list" other)
|
||||||
|
|
||||||
|
-- | 角色总览(\\dr 用)
|
||||||
|
clientRoles :: Client -> IO (Either Text [RoleView])
|
||||||
|
clientRoles client = do
|
||||||
|
answer <- request client ReqRoles
|
||||||
|
case answer of
|
||||||
|
Left message -> pure (Left message)
|
||||||
|
Right (RespRoles roles) -> pure (Right roles)
|
||||||
|
Right other -> pure (failed "a role list" other)
|
||||||
|
|
||||||
|
-- | 账号清单(\\du 与管理页用)
|
||||||
|
clientAccounts :: Client -> IO (Either Text [Account])
|
||||||
|
clientAccounts client = do
|
||||||
|
answer <- request client ReqAccounts
|
||||||
|
case answer of
|
||||||
|
Left message -> pure (Left message)
|
||||||
|
Right (RespAccounts accounts) -> pure (Right accounts)
|
||||||
|
Right other -> pure (failed "an account list" other)
|
||||||
|
|
||||||
|
-- | 当前生效的口令策略(最短长度、至少几类字符)
|
||||||
|
clientPolicy :: Client -> IO (Either Text (Int, Int))
|
||||||
|
clientPolicy client = do
|
||||||
|
answer <- request client ReqPolicy
|
||||||
|
case answer of
|
||||||
|
Left message -> pure (Left message)
|
||||||
|
Right (RespPolicy minLength classes) -> pure (Right (minLength, classes))
|
||||||
|
Right other -> pure (failed "a password policy" other)
|
||||||
|
|
||||||
|
-- | 让 server 重读它自己的设置文件并换上新的口令策略
|
||||||
|
clientReloadPolicy :: Client -> IO (Either Text (Int, Int))
|
||||||
|
clientReloadPolicy client = do
|
||||||
|
answer <- request client ReqReloadPolicy
|
||||||
|
case answer of
|
||||||
|
Left message -> pure (Left message)
|
||||||
|
Right (RespPolicy minLength classes) -> pure (Right (minLength, classes))
|
||||||
|
Right other -> pure (failed "a password policy reload" other)
|
||||||
|
|
||||||
|
-- | 应答形状对不上时的错误,error 应答取码与说明
|
||||||
|
failed :: Text -> ServerResponse -> Either Text a
|
||||||
|
failed what response = case response of
|
||||||
|
RespError code message -> Left (code <> ": " <> message)
|
||||||
|
_ -> Left ("unexpected response to " <> what)
|
||||||
|
|
||||||
|
-- | 建连接(套接字转成文本模式的行缓冲句柄)
|
||||||
|
openConnection :: Text -> Int -> IO Handle
|
||||||
|
openConnection host port = do
|
||||||
|
let hints = defaultHints{addrSocketType = Stream}
|
||||||
|
infos <- getAddrInfo (Just hints) (Just (T.unpack host)) (Just (show port))
|
||||||
|
case infos of
|
||||||
|
[] -> ioError (userError ("cannot resolve server address: " ++ address host port))
|
||||||
|
(info : _) -> do
|
||||||
|
sock <- socket (addrFamily info) Stream defaultProtocol
|
||||||
|
connect sock (addrAddress info)
|
||||||
|
handle <- socketToHandle sock ReadWriteMode
|
||||||
|
hSetBuffering handle LineBuffering
|
||||||
|
hSetNewlineMode handle noNewlineTranslation
|
||||||
|
pure handle
|
||||||
|
|
||||||
|
-- | 把主机端口拼成地址字样
|
||||||
|
address :: Text -> Int -> String
|
||||||
|
address host port = T.unpack host ++ ":" ++ show port
|
||||||
|
|
||||||
|
-- | 断线给人的说法:EOF 就是 server 把连接关了
|
||||||
|
describe :: IOException -> String
|
||||||
|
describe err
|
||||||
|
| isEOFError err = "the server closed the connection"
|
||||||
|
| otherwise = show err
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
|
module ChuSQL.Interface.Policy (
|
||||||
|
PasswordPolicy (..),
|
||||||
|
defaultPasswordPolicy,
|
||||||
|
passwordKeys,
|
||||||
|
policyFromValues,
|
||||||
|
) where
|
||||||
|
|
||||||
|
import ChuSQL.Interface.Settings (defaultOf)
|
||||||
|
import qualified Data.Map.Strict as Map
|
||||||
|
import Data.Maybe (fromMaybe)
|
||||||
|
import Data.Text (Text)
|
||||||
|
import qualified Data.Text as T
|
||||||
|
import Text.Read (readMaybe)
|
||||||
|
|
||||||
|
-- 口令策略值与策略计算,服务端与 Web 管理端共用。
|
||||||
|
|
||||||
|
-- | 口令策略:最短长度与至少几类字符
|
||||||
|
data PasswordPolicy = PasswordPolicy
|
||||||
|
{ ppMinLength :: Int
|
||||||
|
, ppClasses :: Int
|
||||||
|
}
|
||||||
|
deriving (Show, Eq)
|
||||||
|
|
||||||
|
-- | 默认:至少 12 个字符、至少两类字符
|
||||||
|
defaultPasswordPolicy :: PasswordPolicy
|
||||||
|
defaultPasswordPolicy = PasswordPolicy 12 2
|
||||||
|
|
||||||
|
-- | 设置文件里决定口令策略的键
|
||||||
|
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)))
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
|
module ChuSQL.Interface.Protocol (
|
||||||
|
protocolVersion,
|
||||||
|
serverName,
|
||||||
|
ClientRequest (..),
|
||||||
|
ServerResponse (..),
|
||||||
|
decodeRequest,
|
||||||
|
encodeRequest,
|
||||||
|
encodeResponse,
|
||||||
|
decodeResponse,
|
||||||
|
Grant (..),
|
||||||
|
RoleView (..),
|
||||||
|
) where
|
||||||
|
|
||||||
|
import ChuSQL.Core.Protocol (Account (..), QueryResult (..), TableInfo (..), queryResultJson)
|
||||||
|
import Data.Aeson (FromJSON (..), ToJSON (..), Value, object, withObject, (.:), (.:?), (.!=), (.=))
|
||||||
|
import qualified Data.Aeson as A
|
||||||
|
import qualified Data.Aeson.Key as AK
|
||||||
|
import qualified Data.Aeson.KeyMap as KM
|
||||||
|
import qualified Data.ByteString.Char8 as BSC
|
||||||
|
import qualified Data.ByteString.Lazy as BL
|
||||||
|
import Data.Maybe (fromMaybe)
|
||||||
|
import Data.Text (Text)
|
||||||
|
import qualified Data.Text as T
|
||||||
|
|
||||||
|
-- server 线上协议的编解码:一行一条 JSON(UTF-8)。
|
||||||
|
|
||||||
|
-- | 一条角色授权:角色 + 具体权限 + 对象
|
||||||
|
data Grant = Grant
|
||||||
|
{ grantRole :: Text
|
||||||
|
, grantPrivilege :: Text
|
||||||
|
, grantObject :: Text
|
||||||
|
}
|
||||||
|
deriving (Show, Eq, Ord)
|
||||||
|
|
||||||
|
-- | 一个角色的全貌
|
||||||
|
data RoleView = RoleView
|
||||||
|
{ roleName :: Text
|
||||||
|
, roleGrants :: [Grant]
|
||||||
|
, roleMembers :: [Text]
|
||||||
|
}
|
||||||
|
deriving (Show, Eq)
|
||||||
|
|
||||||
|
-- | 一条授权转成 JSON
|
||||||
|
instance ToJSON Grant where
|
||||||
|
toJSON grant =
|
||||||
|
object
|
||||||
|
[ "role" .= grantRole grant
|
||||||
|
, "privilege" .= grantPrivilege grant
|
||||||
|
, "object" .= grantObject grant
|
||||||
|
]
|
||||||
|
|
||||||
|
-- | 从 JSON 读一条授权
|
||||||
|
instance FromJSON Grant where
|
||||||
|
parseJSON = withObject "Grant" $ \o ->
|
||||||
|
Grant <$> o .: "role" <*> o .: "privilege" <*> o .: "object"
|
||||||
|
|
||||||
|
-- | 一个角色转成 JSON
|
||||||
|
instance ToJSON RoleView where
|
||||||
|
toJSON view =
|
||||||
|
object
|
||||||
|
[ "role" .= roleName view
|
||||||
|
, "grants" .= roleGrants view
|
||||||
|
, "members" .= roleMembers view
|
||||||
|
]
|
||||||
|
|
||||||
|
-- | 从 JSON 读一个角色
|
||||||
|
instance FromJSON RoleView where
|
||||||
|
parseJSON = withObject "RoleView" $ \o ->
|
||||||
|
RoleView
|
||||||
|
<$> o .: "role"
|
||||||
|
<*> o .:? "grants" .!= []
|
||||||
|
<*> o .:? "members" .!= []
|
||||||
|
|
||||||
|
-- | 协议版本;对不上就报协议错误
|
||||||
|
protocolVersion :: Int
|
||||||
|
protocolVersion = 1
|
||||||
|
|
||||||
|
-- | server 名字,握手时回给客户端
|
||||||
|
serverName :: Text
|
||||||
|
serverName = "chusql-server"
|
||||||
|
|
||||||
|
-- | 客户端请求
|
||||||
|
data ClientRequest
|
||||||
|
= ReqHello Int
|
||||||
|
| ReqLogin Text Text
|
||||||
|
| ReqQuery Text
|
||||||
|
| ReqPing
|
||||||
|
| ReqQuit
|
||||||
|
| ReqStorage Value
|
||||||
|
| ReqCatalog
|
||||||
|
| ReqDatabases
|
||||||
|
| ReqRoles
|
||||||
|
| ReqAccounts
|
||||||
|
| ReqPolicy
|
||||||
|
| ReqReloadPolicy
|
||||||
|
deriving (Eq, Show)
|
||||||
|
|
||||||
|
-- | 线上请求的原始字段
|
||||||
|
data WireRequest = WireRequest
|
||||||
|
{ wrMethod :: Text
|
||||||
|
, wrProtocol :: Maybe Int
|
||||||
|
, wrUser :: Maybe Text
|
||||||
|
, wrPassword :: Maybe Text
|
||||||
|
, wrSql :: Maybe Text
|
||||||
|
, wrRequest :: Maybe Value
|
||||||
|
}
|
||||||
|
|
||||||
|
-- | 从 JSON 读原始请求字段
|
||||||
|
instance A.FromJSON WireRequest where
|
||||||
|
parseJSON = A.withObject "request" $ \o ->
|
||||||
|
WireRequest
|
||||||
|
<$> o A..: "method"
|
||||||
|
<*> o A..:? "protocol"
|
||||||
|
<*> o A..:? "user"
|
||||||
|
<*> o A..:? "password"
|
||||||
|
<*> o A..:? "sql"
|
||||||
|
<*> o A..:? "request"
|
||||||
|
|
||||||
|
-- | 请求解码:方法名走 method 标签
|
||||||
|
decodeRequest :: BSC.ByteString -> Either Text ClientRequest
|
||||||
|
decodeRequest raw = case A.eitherDecodeStrict raw of
|
||||||
|
Left _ -> Left "request is not valid JSON"
|
||||||
|
Right wire -> case T.toLower (T.strip (wrMethod wire)) of
|
||||||
|
"hello" -> Right (ReqHello (fromMaybe protocolVersion (wrProtocol wire)))
|
||||||
|
"login" -> case (wrUser wire, wrPassword wire) of
|
||||||
|
(Just user, Just password) -> Right (ReqLogin user password)
|
||||||
|
_ -> Left "login needs both user and password"
|
||||||
|
"query" -> maybe (Left "query needs sql") (Right . ReqQuery) (wrSql wire)
|
||||||
|
"storage" -> maybe (Left "storage needs request") (Right . ReqStorage) (wrRequest wire)
|
||||||
|
"catalog" -> Right ReqCatalog
|
||||||
|
"databases" -> Right ReqDatabases
|
||||||
|
"roles" -> Right ReqRoles
|
||||||
|
"accounts" -> Right ReqAccounts
|
||||||
|
"policy" -> Right ReqPolicy
|
||||||
|
"reload-policy" -> Right ReqReloadPolicy
|
||||||
|
"ping" -> Right ReqPing
|
||||||
|
"quit" -> Right ReqQuit
|
||||||
|
other -> Left ("unknown method: " <> other)
|
||||||
|
|
||||||
|
-- | 客户端侧的请求编码(一行一条 JSON,不带换行)
|
||||||
|
encodeRequest :: ClientRequest -> BL.ByteString
|
||||||
|
encodeRequest = A.encode . requestJson
|
||||||
|
|
||||||
|
-- | 客户端请求转 JSON;方法名走 method 标签
|
||||||
|
requestJson :: ClientRequest -> Value
|
||||||
|
requestJson request = case request of
|
||||||
|
ReqHello version -> object ["method" .= ("hello" :: Text), "protocol" .= version]
|
||||||
|
ReqLogin user password -> object ["method" .= ("login" :: Text), "user" .= user, "password" .= password]
|
||||||
|
ReqQuery sql -> object ["method" .= ("query" :: Text), "sql" .= sql]
|
||||||
|
ReqPing -> object ["method" .= ("ping" :: Text)]
|
||||||
|
ReqQuit -> object ["method" .= ("quit" :: Text)]
|
||||||
|
ReqStorage payload -> object ["method" .= ("storage" :: Text), "request" .= payload]
|
||||||
|
ReqCatalog -> object ["method" .= ("catalog" :: Text)]
|
||||||
|
ReqDatabases -> object ["method" .= ("databases" :: Text)]
|
||||||
|
ReqRoles -> object ["method" .= ("roles" :: Text)]
|
||||||
|
ReqAccounts -> object ["method" .= ("accounts" :: Text)]
|
||||||
|
ReqPolicy -> object ["method" .= ("policy" :: Text)]
|
||||||
|
ReqReloadPolicy -> object ["method" .= ("reload-policy" :: Text)]
|
||||||
|
|
||||||
|
-- | 服务端响应
|
||||||
|
data ServerResponse
|
||||||
|
= RespHello Int
|
||||||
|
| RespLogin Text Bool
|
||||||
|
| RespResult QueryResult
|
||||||
|
| RespPong
|
||||||
|
| RespBye
|
||||||
|
| RespError Text Text
|
||||||
|
| RespStorage Value
|
||||||
|
| RespCatalog [TableInfo]
|
||||||
|
| RespDatabases [Text]
|
||||||
|
| RespRoles [RoleView]
|
||||||
|
| RespAccounts [Account]
|
||||||
|
| RespPolicy Int Int
|
||||||
|
deriving (Eq, Show)
|
||||||
|
|
||||||
|
-- | 响应转 JSON;结果集只多一个 status 字段
|
||||||
|
encodeResponse :: ServerResponse -> BL.ByteString
|
||||||
|
encodeResponse = A.encode . responseJson
|
||||||
|
|
||||||
|
-- | 服务端响应转 JSON;结果集只是多一个 status 字段
|
||||||
|
responseJson :: ServerResponse -> Value
|
||||||
|
responseJson response = case response of
|
||||||
|
RespHello version -> object ["status" .= ("hello" :: Text), "protocol" .= version, "server" .= serverName]
|
||||||
|
RespLogin user admin -> object ["status" .= ("ok" :: Text), "user" .= user, "admin" .= admin]
|
||||||
|
RespPong -> object ["status" .= ("pong" :: Text)]
|
||||||
|
RespBye -> object ["status" .= ("bye" :: Text)]
|
||||||
|
RespError code message -> object ["status" .= ("error" :: Text), "code" .= code, "message" .= message]
|
||||||
|
RespStorage value -> object ["status" .= ("storage" :: Text), "response" .= value]
|
||||||
|
RespCatalog infos -> object ["status" .= ("catalog" :: Text), "schemas" .= infos]
|
||||||
|
RespDatabases names -> object ["status" .= ("databases" :: Text), "databases" .= names]
|
||||||
|
RespRoles roleList -> object ["status" .= ("roles" :: Text), "roles" .= roleList]
|
||||||
|
RespAccounts accountList -> object ["status" .= ("accounts" :: Text), "accounts" .= accountList]
|
||||||
|
RespPolicy minLength classes -> object ["status" .= ("policy" :: Text), "minLength" .= minLength, "classes" .= classes]
|
||||||
|
RespResult result -> case queryResultJson result of
|
||||||
|
A.Object fields -> A.Object (KM.insert "status" (A.String "result") fields)
|
||||||
|
other -> other
|
||||||
|
|
||||||
|
-- | 响应解码(客户端用)
|
||||||
|
decodeResponse :: BL.ByteString -> Either Text ServerResponse
|
||||||
|
decodeResponse raw = case A.eitherDecode raw of
|
||||||
|
Left _ -> Left "response is not valid JSON"
|
||||||
|
Right (A.Object fields) -> case KM.lookup "status" fields of
|
||||||
|
Just (A.String status) -> decodeStatus status fields
|
||||||
|
_ -> Left "response is missing status"
|
||||||
|
Right _ -> Left "response is not a JSON object"
|
||||||
|
where
|
||||||
|
-- | 按 status 字段分派
|
||||||
|
decodeStatus status fields = case status of
|
||||||
|
"hello" -> Right (RespHello (intOf "protocol" fields))
|
||||||
|
"ok" -> Right (RespLogin (textOf "user" fields) (boolOf "admin" fields))
|
||||||
|
"pong" -> Right RespPong
|
||||||
|
"bye" -> Right RespBye
|
||||||
|
"error" -> Right (RespError (textOf "code" fields) (textOf "message" fields))
|
||||||
|
"storage" -> case KM.lookup "response" fields of
|
||||||
|
Just value -> Right (RespStorage value)
|
||||||
|
Nothing -> Left "storage response is missing response"
|
||||||
|
"result" -> case A.fromJSON (A.Object fields) of
|
||||||
|
A.Success result -> Right (RespResult result)
|
||||||
|
A.Error message -> Left (T.pack message)
|
||||||
|
"catalog" -> fromField "schemas" RespCatalog fields
|
||||||
|
"databases" -> fromField "databases" RespDatabases fields
|
||||||
|
"roles" -> fromField "roles" RespRoles fields
|
||||||
|
"accounts" -> fromField "accounts" RespAccounts fields
|
||||||
|
"policy" -> Right (RespPolicy (intOf "minLength" fields) (intOf "classes" fields))
|
||||||
|
other -> Left ("unexpected status: " <> other)
|
||||||
|
|
||||||
|
-- | 取一个字段解成目标类型
|
||||||
|
fromField :: A.FromJSON a => Text -> (a -> ServerResponse) -> A.Object -> Either Text ServerResponse
|
||||||
|
fromField key wrap fields = case A.fromJSON (fromMaybe A.Null (KM.lookup (AK.fromText key) fields)) of
|
||||||
|
A.Success value -> Right (wrap value)
|
||||||
|
A.Error message -> Left (T.pack message)
|
||||||
|
|
||||||
|
-- | 取一个整数字段
|
||||||
|
intOf key fields = case KM.lookup key fields of
|
||||||
|
Just (A.Number number) -> truncate number
|
||||||
|
_ -> 0
|
||||||
|
|
||||||
|
-- | 取一个文本字段
|
||||||
|
textOf key fields = case KM.lookup key fields of
|
||||||
|
Just (A.String text) -> text
|
||||||
|
_ -> ""
|
||||||
|
|
||||||
|
-- | 取一个布尔字段
|
||||||
|
boolOf key fields = case KM.lookup key fields of
|
||||||
|
Just (A.Bool flag) -> flag
|
||||||
|
_ -> False
|
||||||
+4
-1
@@ -1,4 +1,4 @@
|
|||||||
module ChuSQL.Web.RateLimit (
|
module ChuSQL.Interface.RateLimit (
|
||||||
RateLimiter,
|
RateLimiter,
|
||||||
newRateLimiter,
|
newRateLimiter,
|
||||||
setRateLimit,
|
setRateLimit,
|
||||||
@@ -15,16 +15,19 @@ import Data.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime)
|
|||||||
|
|
||||||
-- 登录失败限流:按用户名数一个滑动窗口里的失败次数。
|
-- 登录失败限流:按用户名数一个滑动窗口里的失败次数。
|
||||||
|
|
||||||
|
-- | 一个键的失败计数与窗口起点
|
||||||
data Attempt = Attempt
|
data Attempt = Attempt
|
||||||
{ attCount :: Int
|
{ attCount :: Int
|
||||||
, attStart :: UTCTime
|
, attStart :: UTCTime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- | 限流阈值:次数上限与窗口长度
|
||||||
data RateLimitPolicy = RateLimitPolicy
|
data RateLimitPolicy = RateLimitPolicy
|
||||||
{ rlpMax :: Int
|
{ rlpMax :: Int
|
||||||
, rlpWindow :: NominalDiffTime
|
, rlpWindow :: NominalDiffTime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- | 限流器:计数表、阈值与时钟
|
||||||
data RateLimiter = RateLimiter
|
data RateLimiter = RateLimiter
|
||||||
{ rlMap :: MVar (Map.Map Text Attempt)
|
{ rlMap :: MVar (Map.Map Text Attempt)
|
||||||
, rlPolicy :: IORef RateLimitPolicy
|
, rlPolicy :: IORef RateLimitPolicy
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
|
module ChuSQL.Interface.Session (
|
||||||
|
Session,
|
||||||
|
newSession,
|
||||||
|
sessionIsAdmin,
|
||||||
|
sessionDatabase,
|
||||||
|
isPlainIdentifier,
|
||||||
|
authenticateSession,
|
||||||
|
switchDatabase,
|
||||||
|
runStatement,
|
||||||
|
catalog,
|
||||||
|
databases,
|
||||||
|
roleViews,
|
||||||
|
accounts,
|
||||||
|
) where
|
||||||
|
|
||||||
|
import ChuSQL.Core.Protocol (Account, QueryResult (..), TableInfo)
|
||||||
|
import ChuSQL.Interface.Link (
|
||||||
|
Client,
|
||||||
|
clientAccounts,
|
||||||
|
clientCatalog,
|
||||||
|
clientDatabases,
|
||||||
|
clientLogin,
|
||||||
|
clientQuery,
|
||||||
|
clientRoles,
|
||||||
|
)
|
||||||
|
import ChuSQL.Interface.Protocol (RoleView)
|
||||||
|
import Data.Char (isAlpha, isAlphaNum)
|
||||||
|
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
|
||||||
|
import Data.Text (Text)
|
||||||
|
import qualified Data.Text as T
|
||||||
|
|
||||||
|
-- 前端会话:本地记登录态与当前库,语句答案全部来自 server。
|
||||||
|
|
||||||
|
-- | 一个前端会话:连接、当前库与管理员标记
|
||||||
|
data Session = Session
|
||||||
|
{ ssClient :: Client
|
||||||
|
, ssCurrent :: IORef Text
|
||||||
|
, ssAdmin :: IORef Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
-- | 开一个前端会话:登录之前没有管理员身份,也没有选中的库
|
||||||
|
newSession :: Client -> IO Session
|
||||||
|
newSession client = Session client <$> newIORef "" <*> newIORef False
|
||||||
|
|
||||||
|
-- | 是不是管理员(由登录结果决定)
|
||||||
|
sessionIsAdmin :: Session -> IO Bool
|
||||||
|
sessionIsAdmin = readIORef . ssAdmin
|
||||||
|
|
||||||
|
-- | 当前库
|
||||||
|
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
|
||||||
|
|
||||||
|
-- | 登录:身份由 server 判定,这里只把管理员标记记下来
|
||||||
|
authenticateSession :: Session -> Text -> Text -> IO (Either Text ())
|
||||||
|
authenticateSession session user password = do
|
||||||
|
answer <- clientLogin (ssClient session) user password
|
||||||
|
case answer of
|
||||||
|
Left message -> pure (Left message)
|
||||||
|
Right admin -> writeIORef (ssAdmin session) admin >> pure (Right ())
|
||||||
|
|
||||||
|
-- | 换库:名字先按解析器规矩校验,由 server 执行 USE
|
||||||
|
switchDatabase :: Session -> Text -> IO (Either Text ())
|
||||||
|
switchDatabase session rawName
|
||||||
|
| not (isPlainIdentifier name) = pure (Left ("not a plain database name: " <> name))
|
||||||
|
| otherwise = do
|
||||||
|
answer <- runStatement session ("USE " <> name)
|
||||||
|
case answer of
|
||||||
|
Left message -> pure (Left message)
|
||||||
|
Right _ -> pure (Right ())
|
||||||
|
where
|
||||||
|
-- | 规范化后的库名
|
||||||
|
name = T.toLower (T.strip rawName)
|
||||||
|
|
||||||
|
-- | 跑一条语句;结果集里带回的当前库口径跟着一起更新
|
||||||
|
runStatement :: Session -> Text -> IO (Either Text QueryResult)
|
||||||
|
runStatement session sql = do
|
||||||
|
answer <- clientQuery (ssClient session) sql
|
||||||
|
case answer of
|
||||||
|
Left message -> pure (Left message)
|
||||||
|
Right result -> do
|
||||||
|
case qrDatabase result of
|
||||||
|
Just name -> writeIORef (ssCurrent session) name
|
||||||
|
Nothing -> pure ()
|
||||||
|
pure (Right result)
|
||||||
|
|
||||||
|
-- | 数据字典(\\dt 与 \\d 用)
|
||||||
|
catalog :: Session -> IO (Either Text [TableInfo])
|
||||||
|
catalog session = clientCatalog (ssClient session)
|
||||||
|
|
||||||
|
-- | 库清单(\\l 用)
|
||||||
|
databases :: Session -> IO (Either Text [Text])
|
||||||
|
databases session = clientDatabases (ssClient session)
|
||||||
|
|
||||||
|
-- | 角色总览(\\dr 用)
|
||||||
|
roleViews :: Session -> IO (Either Text [RoleView])
|
||||||
|
roleViews session = clientRoles (ssClient session)
|
||||||
|
|
||||||
|
-- | 账号清单(\\du 与管理页用)
|
||||||
|
accounts :: Session -> IO (Either Text [Account])
|
||||||
|
accounts session = clientAccounts (ssClient session)
|
||||||
+25
-19
@@ -1,6 +1,6 @@
|
|||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
module ChuSQL.Web.Settings (
|
module ChuSQL.Interface.Settings (
|
||||||
SettingItem (..),
|
SettingItem (..),
|
||||||
settingCatalogue,
|
settingCatalogue,
|
||||||
findItem,
|
findItem,
|
||||||
@@ -15,16 +15,16 @@ module ChuSQL.Web.Settings (
|
|||||||
effectiveSettings,
|
effectiveSettings,
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import ChuSQL.Web.Config (WebConfig (..), canonicalSettingKeys, defaultUser)
|
import ChuSQL.Interface.Config (WebConfig (..), canonicalSettingKeys, defaultUser)
|
||||||
import ChuSQL.Web.TOML (readSection, writeSection)
|
import ChuSQL.Interface.TOML (readSection, writeSection)
|
||||||
import Data.List (nub)
|
import Data.List (nub)
|
||||||
import qualified Data.Map.Strict as Map
|
import qualified Data.Map.Strict as Map
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
|
|
||||||
-- Web 配置项目录,与启动脚本共用一份全局 chusql.toml;
|
-- 设置目录,以及全局 chusql.toml 的读、写与校验。
|
||||||
-- 每个键只写在它所属的那一段里,web 不再复制存储层的键。
|
|
||||||
|
|
||||||
|
-- | 一个可配置项
|
||||||
data SettingItem = SettingItem
|
data SettingItem = SettingItem
|
||||||
{ siKey :: Text
|
{ siKey :: Text
|
||||||
, siLabel :: Text
|
, siLabel :: Text
|
||||||
@@ -38,10 +38,10 @@ data SettingItem = SettingItem
|
|||||||
}
|
}
|
||||||
deriving (Show, Eq)
|
deriving (Show, Eq)
|
||||||
|
|
||||||
-- | 全部可配置项(顺序即界面里的顺序)
|
-- | 全部可配置项与界面顺序
|
||||||
settingCatalogue :: [SettingItem]
|
settingCatalogue :: [SettingItem]
|
||||||
settingCatalogue =
|
settingCatalogue =
|
||||||
[ item "port" "HTTP port" "server" "int" "7777" True True
|
[ item "port" "HTTP port" "server" "int" "7778" True True
|
||||||
, item "host" "Listen address" "server" "text" "127.0.0.1" 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 "static-dir" "Static directory" "server" "text" "static" True True
|
||||||
, item "cookie-secure" "Secure cookie (behind TLS)" "server" "bool" "0" True False
|
, item "cookie-secure" "Secure cookie (behind TLS)" "server" "bool" "0" True False
|
||||||
@@ -59,16 +59,18 @@ settingCatalogue =
|
|||||||
, item "password-min-length" "口令最短长度" "auth" "int" "12" True False
|
, item "password-min-length" "口令最短长度" "auth" "int" "12" True False
|
||||||
, item "password-classes" "口令字符类别数" "auth" "int" "2" True False
|
, item "password-classes" "口令字符类别数" "auth" "int" "2" True False
|
||||||
, owned "data-dir" "Data directory" "storage" "text" "" "storage" "data_dir"
|
, 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-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-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-buffer-pool" "Storage buffer pool pages" "storage" "int" "1024" "buffer" "pool_size"
|
||||||
, owned "storage-log" "Storage log level" "storage" "text" "info" "log" "level"
|
, 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
|
, item "seed" "Seed demo data on start" "storage" "bool" "1" True True
|
||||||
|
, owned "tcp-host" "TCP listen address" "network" "text" "127.0.0.1" "server" "host"
|
||||||
|
, owned "tcp-port" "TCP port" "network" "int" "7777" "server" "port"
|
||||||
|
, owned "tcp-max-message" "TCP max request line (bytes)" "network" "int" "1048576" "server" "max_message"
|
||||||
|
, owned "tcp-max-rows" "TCP rows per result" "network" "int" "1000" "server" "max_rows"
|
||||||
]
|
]
|
||||||
where
|
where
|
||||||
-- web 自己的键:分区写死 [web],TOML 键名就是界面键名
|
-- | web 自己的键:分区写死 [web]
|
||||||
item k l g kind def rootOnly restart =
|
item k l g kind def rootOnly restart =
|
||||||
SettingItem
|
SettingItem
|
||||||
{ siKey = k
|
{ siKey = k
|
||||||
@@ -95,11 +97,11 @@ settingCatalogue =
|
|||||||
, siTomlKey = tomlKey
|
, siTomlKey = tomlKey
|
||||||
}
|
}
|
||||||
|
|
||||||
-- | web 自己那段
|
-- | web 自己的分区名
|
||||||
webSection :: Text
|
webSection :: Text
|
||||||
webSection = "web"
|
webSection = "web"
|
||||||
|
|
||||||
-- | 目录里涉及的分区(按出现顺序,去重)
|
-- | 目录里涉及的分区,去重
|
||||||
catalogueSections :: [Text]
|
catalogueSections :: [Text]
|
||||||
catalogueSections = nub [siSection i | i <- settingCatalogue]
|
catalogueSections = nub [siSection i | i <- settingCatalogue]
|
||||||
|
|
||||||
@@ -113,7 +115,7 @@ findItem key = case [i | i <- settingCatalogue, siKey i == key] of
|
|||||||
isRootOnly :: Text -> Bool
|
isRootOnly :: Text -> Bool
|
||||||
isRootOnly key = maybe False siRootOnly (findItem key)
|
isRootOnly key = maybe False siRootOnly (findItem key)
|
||||||
|
|
||||||
-- | 只读项:root 凭据只认设置文件里的明文,网页端不能改(改文件后重启生效)
|
-- | 网页端不能改的键
|
||||||
lockedKeys :: [Text]
|
lockedKeys :: [Text]
|
||||||
lockedKeys = ["user", "password"]
|
lockedKeys = ["user", "password"]
|
||||||
|
|
||||||
@@ -140,10 +142,11 @@ liveKeys =
|
|||||||
, "max-sql-length"
|
, "max-sql-length"
|
||||||
]
|
]
|
||||||
|
|
||||||
-- | 读目录里用到的每个分区,摊成界面键;只有 [web] 统一成连字符写法
|
-- | 读目录里用到的每个分区,摊成界面键
|
||||||
readSettingsFile :: FilePath -> IO (Map.Map Text Text)
|
readSettingsFile :: FilePath -> IO (Map.Map Text Text)
|
||||||
readSettingsFile path = fmap Map.unions (mapM readSectionOf catalogueSections)
|
readSettingsFile path = fmap Map.unions (mapM readSectionOf catalogueSections)
|
||||||
where
|
where
|
||||||
|
-- | 读一个分区,摊成界面键
|
||||||
readSectionOf section = do
|
readSectionOf section = do
|
||||||
raw <- readSection path section
|
raw <- readSection path section
|
||||||
let values = if section == webSection then canonicalSettingKeys raw else raw
|
let values = if section == webSection then canonicalSettingKeys raw else raw
|
||||||
@@ -156,10 +159,11 @@ readSettingsFile path = fmap Map.unions (mapM readSectionOf catalogueSections)
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
-- | 按分区写回:每个键落进自己的段,别的分区与注释原样保留
|
-- | 按分区写回,别的分区与注释原样保留
|
||||||
writeSettingsFile :: FilePath -> Map.Map Text Text -> IO (Either String ())
|
writeSettingsFile :: FilePath -> Map.Map Text Text -> IO (Either String ())
|
||||||
writeSettingsFile path values = writeSections catalogueSections
|
writeSettingsFile path values = writeSections catalogueSections
|
||||||
where
|
where
|
||||||
|
-- | 各分区依次写回
|
||||||
writeSections [] = pure (Right ())
|
writeSections [] = pure (Right ())
|
||||||
writeSections (section : rest) = do
|
writeSections (section : rest) = do
|
||||||
let entries =
|
let entries =
|
||||||
@@ -177,10 +181,11 @@ writeSettingsFile path values = writeSections catalogueSections
|
|||||||
Left err -> pure (Left err)
|
Left err -> pure (Left err)
|
||||||
Right () -> writeSections rest
|
Right () -> writeSections rest
|
||||||
|
|
||||||
-- | 把界面上传来的值合并进现有文件内容(只接受目录里有的键)
|
-- | 把界面传来的值合并进现有内容,只认目录里的键
|
||||||
applySettings :: Map.Map Text Text -> Map.Map Text Text -> Either String (Map.Map Text Text)
|
applySettings :: Map.Map Text Text -> Map.Map Text Text -> Either String (Map.Map Text Text)
|
||||||
applySettings current = go (Right current) . Map.toList
|
applySettings current = go (Right current) . Map.toList
|
||||||
where
|
where
|
||||||
|
-- | 逐项校验并合并
|
||||||
go (Left e) _ = Left e
|
go (Left e) _ = Left e
|
||||||
go (Right acc) [] = Right acc
|
go (Right acc) [] = Right acc
|
||||||
go (Right acc) ((key, value) : rest) = case findItem key of
|
go (Right acc) ((key, value) : rest) = case findItem key of
|
||||||
@@ -189,7 +194,7 @@ applySettings current = go (Right current) . Map.toList
|
|||||||
Left e -> Left e
|
Left e -> Left e
|
||||||
Right () -> go (Right (Map.insert key value acc)) rest
|
Right () -> go (Right (Map.insert key value acc)) rest
|
||||||
|
|
||||||
-- | 按类型校验一个值(空串表示"清掉",一律放行)
|
-- | 按类型校验一个值,空串一律放行
|
||||||
checkKind :: SettingItem -> Text -> Either String ()
|
checkKind :: SettingItem -> Text -> Either String ()
|
||||||
checkKind spec value
|
checkKind spec value
|
||||||
| T.null (T.strip value) = Right ()
|
| T.null (T.strip value) = Right ()
|
||||||
@@ -205,7 +210,7 @@ checkKind spec value
|
|||||||
_ -> Left (T.unpack (siKey spec) ++ " needs a boolean, got: " ++ T.unpack value)
|
_ -> Left (T.unpack (siKey spec) ++ " needs a boolean, got: " ++ T.unpack value)
|
||||||
| otherwise = Right ()
|
| otherwise = Right ()
|
||||||
|
|
||||||
-- | 一项的内置默认值(设置被清掉、也没有别处给值时用它)
|
-- | 一项的内置默认值
|
||||||
defaultOf :: Text -> Text
|
defaultOf :: Text -> Text
|
||||||
defaultOf key = maybe "" siDefault (findItem key)
|
defaultOf key = maybe "" siDefault (findItem key)
|
||||||
|
|
||||||
@@ -229,9 +234,10 @@ effectiveSettings cfg =
|
|||||||
, ("user", if T.null (T.strip (wcUser cfg)) then defaultUser else T.strip (wcUser cfg))
|
, ("user", if T.null (T.strip (wcUser cfg)) then defaultUser else T.strip (wcUser cfg))
|
||||||
, ("seed", boolText (wcSeedDemo cfg))
|
, ("seed", boolText (wcSeedDemo cfg))
|
||||||
, ("data-dir", maybe "" T.pack (wcDataDir cfg))
|
, ("data-dir", maybe "" T.pack (wcDataDir cfg))
|
||||||
, ("pipe-name", maybe "" T.pack (wcPipeName cfg))
|
|
||||||
]
|
]
|
||||||
where
|
where
|
||||||
|
-- | 数值转文本
|
||||||
tshow = T.pack . show
|
tshow = T.pack . show
|
||||||
|
-- | 布尔转 "1"/"0"
|
||||||
boolText True = "1"
|
boolText True = "1"
|
||||||
boolText False = "0"
|
boolText False = "0"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
module ChuSQL.Web.TOML
|
module ChuSQL.Interface.TOML
|
||||||
( readSection
|
( readSection
|
||||||
, writeSection
|
, writeSection
|
||||||
, defaultConfigFile
|
, defaultConfigFile
|
||||||
@@ -19,7 +19,6 @@ import System.Directory (XdgDirectory (XdgConfig), createDirectoryIfMissing, doe
|
|||||||
import System.FilePath (takeDirectory, (</>))
|
import System.FilePath (takeDirectory, (</>))
|
||||||
|
|
||||||
-- 全局 chusql.toml 的最小读写:只认 [section] 与 key = value。
|
-- 全局 chusql.toml 的最小读写:只认 [section] 与 key = value。
|
||||||
-- 读写都是一次性打开就关,不长期占着文件句柄,别的层随时能读同一份文件。
|
|
||||||
|
|
||||||
-- | 读一个分区;文件不存在或没有这个分区都当空表
|
-- | 读一个分区;文件不存在或没有这个分区都当空表
|
||||||
readSection :: FilePath -> Text -> IO (Map Text Text)
|
readSection :: FilePath -> Text -> IO (Map Text Text)
|
||||||
@@ -44,6 +43,7 @@ writeSection path section values = do
|
|||||||
written <- tryWrite (TE.encodeUtf8 updated)
|
written <- tryWrite (TE.encodeUtf8 updated)
|
||||||
pure written
|
pure written
|
||||||
where
|
where
|
||||||
|
-- | 目录不存在就先建
|
||||||
prepareDirectory = do
|
prepareDirectory = do
|
||||||
let dir = takeDirectory path
|
let dir = takeDirectory path
|
||||||
if null dir
|
if null dir
|
||||||
@@ -51,6 +51,7 @@ writeSection path section values = do
|
|||||||
else do
|
else do
|
||||||
done <- try (createDirectoryIfMissing True dir) :: IO (Either IOException ())
|
done <- try (createDirectoryIfMissing True dir) :: IO (Either IOException ())
|
||||||
pure (either (Left . show) (const (Right ())) done)
|
pure (either (Left . show) (const (Right ())) done)
|
||||||
|
-- | 写文件,异常转成错误串
|
||||||
tryWrite bytes = do
|
tryWrite bytes = do
|
||||||
done <- try (BS.writeFile path bytes) :: IO (Either IOException ())
|
done <- try (BS.writeFile path bytes) :: IO (Either IOException ())
|
||||||
pure (either (Left . show) (const (Right ())) done)
|
pure (either (Left . show) (const (Right ())) done)
|
||||||
@@ -59,6 +60,7 @@ writeSection path section values = do
|
|||||||
sectionEntries :: Text -> [Text] -> [(Text, Text)]
|
sectionEntries :: Text -> [Text] -> [(Text, Text)]
|
||||||
sectionEntries wanted = go ""
|
sectionEntries wanted = go ""
|
||||||
where
|
where
|
||||||
|
-- | 走到目标分区后收键值对
|
||||||
go _ [] = []
|
go _ [] = []
|
||||||
go current (line : rest)
|
go current (line : rest)
|
||||||
| Just name <- sectionName line = go name rest
|
| Just name <- sectionName line = go name rest
|
||||||
@@ -89,6 +91,7 @@ assignment line = case T.breakOn "=" (stripComment line) of
|
|||||||
stripComment :: Text -> Text
|
stripComment :: Text -> Text
|
||||||
stripComment = T.pack . go False . T.unpack
|
stripComment = T.pack . go False . T.unpack
|
||||||
where
|
where
|
||||||
|
-- | 引号外遇到 # 就到此为止
|
||||||
go _ [] = []
|
go _ [] = []
|
||||||
go inString ('#' : rest)
|
go inString ('#' : rest)
|
||||||
| not inString = []
|
| not inString = []
|
||||||
@@ -103,12 +106,15 @@ 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
|
| otherwise = value
|
||||||
|
|
||||||
-- | 行级重写:本分区里已有的键就地更新,空值删行,缺的键补在分区末尾
|
-- | 行级重写:已有键就地更新,空值删行,缺的补末尾
|
||||||
rewrite :: Text -> Map Text Text -> [Text] -> [Text]
|
rewrite :: Text -> Map Text Text -> [Text] -> [Text]
|
||||||
rewrite section values = addMissing . replaceInSection . dropEmpties
|
rewrite section values = addMissing . replaceInSection . dropEmpties
|
||||||
where
|
where
|
||||||
|
-- | 删掉空值的键
|
||||||
dropEmpties = filter (\line -> maybe True (not . T.null . snd) (assignment line))
|
dropEmpties = filter (\line -> maybe True (not . T.null . snd) (assignment line))
|
||||||
|
-- | 在目标分区里就地换行
|
||||||
replaceInSection = go ""
|
replaceInSection = go ""
|
||||||
|
-- | 走到目标分区后逐行替换
|
||||||
go _ [] = []
|
go _ [] = []
|
||||||
go current (line : rest)
|
go current (line : rest)
|
||||||
| Just name <- sectionName line = line : go name rest
|
| Just name <- sectionName line = line : go name rest
|
||||||
@@ -116,25 +122,30 @@ rewrite section values = addMissing . replaceInSection . dropEmpties
|
|||||||
| otherwise = case assignment line of
|
| otherwise = case assignment line of
|
||||||
Just (key, _) | Just value <- Map.lookup key values -> assignmentLine key value : go current rest
|
Just (key, _) | Just value <- Map.lookup key values -> assignmentLine key value : go current rest
|
||||||
_ -> line : go current rest
|
_ -> line : go current rest
|
||||||
|
-- | 分区里缺的键补在末尾
|
||||||
addMissing lines' =
|
addMissing lines' =
|
||||||
let present = Set.fromList (keysOf section 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)]
|
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'
|
in if null missing then lines' else appendSection section missing lines'
|
||||||
|
|
||||||
|
-- | 拼一行 key = value
|
||||||
assignmentLine :: Text -> Text -> Text
|
assignmentLine :: Text -> Text -> Text
|
||||||
assignmentLine key value = key <> " = " <> quote value
|
assignmentLine key value = key <> " = " <> quote value
|
||||||
|
|
||||||
|
-- | 需要时才加引号
|
||||||
quote :: Text -> Text
|
quote :: Text -> Text
|
||||||
quote value
|
quote value
|
||||||
| T.all allowed value && not (T.null value) = value
|
| T.all allowed value && not (T.null value) = value
|
||||||
| otherwise = "\"" <> T.replace "\"" "\\\"" value <> "\""
|
| otherwise = "\"" <> T.replace "\"" "\\\"" value <> "\""
|
||||||
where
|
where
|
||||||
|
-- | 可以裸写的字符集
|
||||||
allowed ch = ch `elem` ("0123456789.-" :: String)
|
allowed ch = ch `elem` ("0123456789.-" :: String)
|
||||||
|
|
||||||
-- | 取某个分区里出现过的键
|
-- | 取某个分区里出现过的键
|
||||||
keysOf :: Text -> [Text] -> [Text]
|
keysOf :: Text -> [Text] -> [Text]
|
||||||
keysOf wanted = go ""
|
keysOf wanted = go ""
|
||||||
where
|
where
|
||||||
|
-- | 走到目标分区后收键名
|
||||||
go _ [] = []
|
go _ [] = []
|
||||||
go current (line : rest)
|
go current (line : rest)
|
||||||
| Just name <- sectionName line = go name rest
|
| Just name <- sectionName line = go name rest
|
||||||
@@ -150,14 +161,13 @@ appendSection section entries lines' =
|
|||||||
++ (if null lines' || not (T.null (last lines')) then [""] else [])
|
++ (if null lines' || not (T.null (last lines')) then [""] else [])
|
||||||
++ ("[" <> section <> "]") : [assignmentLine key value | (key, value) <- entries]
|
++ ("[" <> section <> "]") : [assignmentLine key value | (key, value) <- entries]
|
||||||
|
|
||||||
-- | 全局配置文件的位置:安装时定下的固定路径,跟随系统惯例
|
-- | 全局配置文件的固定路径,跟随系统惯例
|
||||||
-- (Windows: %APPDATA%\ChuSQL\chusql.toml;其它平台: $XDG_CONFIG_HOME 或 ~/.config 下的 ChuSQL/chusql.toml)
|
|
||||||
defaultConfigFile :: IO FilePath
|
defaultConfigFile :: IO FilePath
|
||||||
defaultConfigFile = do
|
defaultConfigFile = do
|
||||||
dir <- getXdgDirectory XdgConfig "ChuSQL"
|
dir <- getXdgDirectory XdgConfig "ChuSQL"
|
||||||
pure (dir </> "chusql.toml")
|
pure (dir </> "chusql.toml")
|
||||||
|
|
||||||
-- | 定位配置文件:命令行 --config 指的是哪份就读哪份,否则用固定路径
|
-- | 定位配置文件:命令行 --config 优先,否则用固定路径
|
||||||
resolveConfigPath :: Maybe FilePath -> IO FilePath
|
resolveConfigPath :: Maybe FilePath -> IO FilePath
|
||||||
resolveConfigPath (Just path) = pure path
|
resolveConfigPath (Just path) = pure path
|
||||||
resolveConfigPath Nothing = defaultConfigFile
|
resolveConfigPath Nothing = defaultConfigFile
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
resolver: lts-24.59
|
||||||
|
|
||||||
|
packages:
|
||||||
|
- .
|
||||||
|
- ../chusql-core/model
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# This file was autogenerated by Stack.
|
||||||
|
# You should not edit this file by hand.
|
||||||
|
# For more information, please see the documentation at:
|
||||||
|
# https://docs.haskellstack.org/en/stable/topics/lock_files
|
||||||
|
|
||||||
|
packages: []
|
||||||
|
snapshots:
|
||||||
|
- completed:
|
||||||
|
sha256: 0f728e30e843d7f00460ad56858b995a3b227fca683659c7df9f2fc3988a3ce1
|
||||||
|
size: 732465
|
||||||
|
url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/24/59.yaml
|
||||||
|
original: lts-24.59
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
|
module Main (main) where
|
||||||
|
|
||||||
|
import ChuSQL.Core.Engine.Storage.FFI (storageVersion)
|
||||||
|
import ChuSQL.Core.Engine.Storage.IPC (closeConnection, localStorageLink, setStorageLink)
|
||||||
|
import ChuSQL.Interface.Auth (Credential (..))
|
||||||
|
import ChuSQL.Interface.Config (
|
||||||
|
WebConfig (..),
|
||||||
|
defaultPassword,
|
||||||
|
defaultUser,
|
||||||
|
loadWebConfigAt,
|
||||||
|
resolveCredential,
|
||||||
|
usingDefaultCredentials,
|
||||||
|
)
|
||||||
|
import ChuSQL.Interface.RateLimit (newRateLimiter)
|
||||||
|
import ChuSQL.Interface.Settings (readSettingsFile)
|
||||||
|
import ChuSQL.Interface.TOML (resolveConfigPath)
|
||||||
|
import ChuSQL.Server.Backend (Backend (..), ipcBackend)
|
||||||
|
import ChuSQL.Server.TCP (
|
||||||
|
ServerConfig (..),
|
||||||
|
ServerEnv,
|
||||||
|
loadServerConfigAt,
|
||||||
|
newServerEnv,
|
||||||
|
runServer,
|
||||||
|
)
|
||||||
|
import Control.Exception (IOException, bracket, try)
|
||||||
|
import qualified Data.Map.Strict as Map
|
||||||
|
import Data.Text (Text)
|
||||||
|
import qualified Data.Text as T
|
||||||
|
import Data.Time.Clock (getCurrentTime)
|
||||||
|
import System.Environment (getArgs)
|
||||||
|
import System.Exit (exitFailure)
|
||||||
|
import System.IO (BufferMode (LineBuffering), hSetBuffering, stdout)
|
||||||
|
import Text.Read (readMaybe)
|
||||||
|
|
||||||
|
-- TCP 服务器入口:解析命令行与配置,装入存储库并以一行一 JSON 的协议对外服务。
|
||||||
|
|
||||||
|
data Options = Options
|
||||||
|
{ optConfig :: Maybe FilePath
|
||||||
|
, optHost :: Maybe String
|
||||||
|
, optPort :: Maybe Int
|
||||||
|
, optUser :: Maybe Text
|
||||||
|
, optMaxMessage :: Maybe Int
|
||||||
|
, optMaxRows :: Maybe Int
|
||||||
|
, optHelp :: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
-- | 全部选项都取默认值
|
||||||
|
emptyOptions :: Options
|
||||||
|
emptyOptions =
|
||||||
|
Options
|
||||||
|
{ optConfig = Nothing
|
||||||
|
, optHost = Nothing
|
||||||
|
, optPort = Nothing
|
||||||
|
, optUser = Nothing
|
||||||
|
, optMaxMessage = Nothing
|
||||||
|
, optMaxRows = Nothing
|
||||||
|
, optHelp = False
|
||||||
|
}
|
||||||
|
|
||||||
|
-- | 解析参数
|
||||||
|
parseArgs :: [String] -> Either String Options
|
||||||
|
parseArgs = go emptyOptions
|
||||||
|
where
|
||||||
|
-- | 逐条消费参数列表
|
||||||
|
go opts [] = Right opts
|
||||||
|
go opts ("--help" : rest) = go opts{optHelp = True} rest
|
||||||
|
go opts ("-h" : rest) = go opts{optHelp = True} rest
|
||||||
|
go opts ("--config" : v : rest) = go opts{optConfig = Just v} rest
|
||||||
|
go opts ("--host" : v : rest) = go opts{optHost = Just v} rest
|
||||||
|
go opts ("--user" : v : rest) = go opts{optUser = Just (T.pack v)} rest
|
||||||
|
go opts ("--port" : v : rest) = withInt "--port" v (\n -> go opts{optPort = Just n} rest)
|
||||||
|
go opts ("--max-message" : v : rest) = withInt "--max-message" v (\n -> go opts{optMaxMessage = Just n} rest)
|
||||||
|
go opts ("--max-rows" : v : rest) = withInt "--max-rows" v (\n -> go opts{optMaxRows = Just n} 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)
|
||||||
|
|
||||||
|
-- | 打印用法说明
|
||||||
|
usage :: IO ()
|
||||||
|
usage = do
|
||||||
|
putStrLn "usage: chusql-server [options]"
|
||||||
|
putStrLn ""
|
||||||
|
putStrLn "config: chusql.toml sits at a fixed place and holds every knob;"
|
||||||
|
putStrLn " Windows: %APPDATA%\\ChuSQL\\chusql.toml"
|
||||||
|
putStrLn " other: $XDG_CONFIG_HOME/ChuSQL/chusql.toml (or ~/.config/ChuSQL/chusql.toml)"
|
||||||
|
putStrLn " [storage] data_dir; [server] host/port/max_message/max_rows;"
|
||||||
|
putStrLn " [web] user/password (administrator credentials)"
|
||||||
|
putStrLn ""
|
||||||
|
putStrLn " --config FILE read that config file instead"
|
||||||
|
putStrLn " --host H listen address ([server] host, default 127.0.0.1)"
|
||||||
|
putStrLn " --port N listen port ([server] port, default 7777)"
|
||||||
|
putStrLn " --user NAME administrator name ([web] user, default root)"
|
||||||
|
putStrLn " --max-message N max bytes per request line ([server] max_message, default 1048576)"
|
||||||
|
putStrLn " --max-rows N rows returned per query ([server] max_rows, default 1000)"
|
||||||
|
putStrLn ""
|
||||||
|
putStrLn "protocol: one JSON object per line; hello / login / query / storage / ping / quit"
|
||||||
|
putStrLn " {\"method\":\"login\",\"user\":\"root\",\"password\":\"...\"} then {\"method\":\"query\",\"sql\":\"SELECT 1\"}"
|
||||||
|
putStrLn " {\"method\":\"storage\",\"request\":{...}} forwards one storage request (Web and the CLI use this)"
|
||||||
|
putStrLn ""
|
||||||
|
putStrLn "storage: this process loads the chusql_core_storage library in-process and owns the data directory"
|
||||||
|
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)
|
||||||
|
|
||||||
|
-- | 命令行覆盖管理员
|
||||||
|
applyWebOptions :: WebConfig -> Options -> WebConfig
|
||||||
|
applyWebOptions cfg opts =
|
||||||
|
cfg
|
||||||
|
{ wcUser = orElse (optUser opts) (wcUser cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
-- | 命令行覆盖监听配置
|
||||||
|
applyServerOptions :: ServerConfig -> Options -> ServerConfig
|
||||||
|
applyServerOptions config opts =
|
||||||
|
config
|
||||||
|
{ scHost = orElse (optHost opts) (scHost config)
|
||||||
|
, scPort = orElse (optPort opts) (scPort config)
|
||||||
|
, scMaxMessage = orElse (optMaxMessage opts) (scMaxMessage config)
|
||||||
|
, scMaxRows = orElse (optMaxRows opts) (scMaxRows config)
|
||||||
|
}
|
||||||
|
|
||||||
|
-- | 有 Just 就用它,否则用兜底值
|
||||||
|
orElse :: Maybe a -> a -> a
|
||||||
|
orElse (Just x) _ = x
|
||||||
|
orElse Nothing y = y
|
||||||
|
|
||||||
|
-- | 入口
|
||||||
|
main :: IO ()
|
||||||
|
main = do
|
||||||
|
hSetBuffering stdout LineBuffering
|
||||||
|
args <- getArgs
|
||||||
|
case parseArgs args of
|
||||||
|
Left err -> do
|
||||||
|
putStrLn ("bad arguments: " ++ err)
|
||||||
|
usage
|
||||||
|
exitFailure
|
||||||
|
Right opts
|
||||||
|
| optHelp opts -> usage
|
||||||
|
| otherwise -> run opts
|
||||||
|
|
||||||
|
-- | 起服务:装入存储库并装配各服务组件
|
||||||
|
run :: Options -> IO ()
|
||||||
|
run opts = do
|
||||||
|
configPath <- resolveConfigPath (optConfig opts)
|
||||||
|
base <- loadWebConfigAt configPath
|
||||||
|
let cfg = applyWebOptions base opts
|
||||||
|
saved <- readSettingsFile configPath
|
||||||
|
cred <- resolveCredential cfg saved
|
||||||
|
config <- applyServerOptions <$> loadServerConfigAt configPath <*> pure opts
|
||||||
|
opened <- localStorageLink (Just configPath)
|
||||||
|
case opened of
|
||||||
|
Left err -> do
|
||||||
|
putStrLn ("cannot open the storage library: " ++ err)
|
||||||
|
exitFailure
|
||||||
|
Right link -> do
|
||||||
|
setStorageLink link
|
||||||
|
backend <- ipcBackend
|
||||||
|
printBanner cfg config configPath
|
||||||
|
checkStorage backend
|
||||||
|
reportCredential cred saved
|
||||||
|
limiter <-
|
||||||
|
newRateLimiter
|
||||||
|
getCurrentTime
|
||||||
|
(wcLoginMaxAttempts cfg)
|
||||||
|
(fromIntegral (wcLoginWindow cfg))
|
||||||
|
env <- newServerEnv backend cred configPath config limiter
|
||||||
|
let announce port = putStrLn ("listening: tcp://" ++ scHost config ++ ":" ++ show port)
|
||||||
|
bracket (pure link) (const closeConnection) (const (serve env announce))
|
||||||
|
|
||||||
|
-- | 起 TCP 服务并处理绑定失败
|
||||||
|
serve :: ServerEnv -> (Int -> IO ()) -> IO ()
|
||||||
|
serve env announce = do
|
||||||
|
result <- try (runServer env announce) :: IO (Either IOException ())
|
||||||
|
case result of
|
||||||
|
Right () -> pure ()
|
||||||
|
Left e -> do
|
||||||
|
putStrLn ("failed to serve TCP: " ++ show e)
|
||||||
|
putStrLn " the port is probably taken or reserved by the system; try another one: --port 7777"
|
||||||
|
exitFailure
|
||||||
|
|
||||||
|
-- | 报告口令来源,用内置默认口令时告警
|
||||||
|
reportCredential :: Credential -> Map.Map Text Text -> IO ()
|
||||||
|
reportCredential cred saved
|
||||||
|
| usingDefaultCredentials saved = do
|
||||||
|
putStrLn ""
|
||||||
|
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 ""
|
||||||
|
| 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)")
|
||||||
|
|
||||||
|
-- | 探测存储是否可用,答不上 ping 就退出
|
||||||
|
checkStorage :: Backend -> IO ()
|
||||||
|
checkStorage backend = do
|
||||||
|
up <- bePing backend
|
||||||
|
if up
|
||||||
|
then putStrLn "storage: up (the library answered ping)"
|
||||||
|
else do
|
||||||
|
putStrLn "storage: DOWN (the storage library did not answer ping)"
|
||||||
|
exitFailure
|
||||||
|
|
||||||
|
-- | 启动横幅
|
||||||
|
printBanner :: WebConfig -> ServerConfig -> FilePath -> IO ()
|
||||||
|
printBanner cfg config configPath = do
|
||||||
|
version <- storageVersion
|
||||||
|
putStrLn "================== ChuSQL Server ==================="
|
||||||
|
putStrLn ("config file: " ++ configPath)
|
||||||
|
putStrLn ("protocol: chusql 1 (one JSON object per line)")
|
||||||
|
putStrLn
|
||||||
|
( "limits: message "
|
||||||
|
++ show (scMaxMessage config)
|
||||||
|
++ "B, query rows "
|
||||||
|
++ show (scMaxRows config)
|
||||||
|
)
|
||||||
|
putStrLn ("storage: chusql_core_storage library " ++ version)
|
||||||
|
putStrLn ("data dir: " ++ orElse (wcDataDir cfg) "(from the config file, else the default)")
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
cabal-version: 2.4
|
||||||
|
|
||||||
|
name: chusql-server
|
||||||
|
version: 1.0.0
|
||||||
|
synopsis: ChuSQL 数据库服务器:TCP 端点 + 自家协议 + SQL 会话
|
||||||
|
description: ChuSQL 的数据库服务器。负责监听 TCP、一行一条 JSON 的自家协议、
|
||||||
|
登录与限流,并把用户 SQL 转交给 chusql-core-engine(存储则由进程内
|
||||||
|
的 chusql_core_storage 动态库承担)。Web 管理端与命令行客户端都只
|
||||||
|
通过这条 TCP 连接访问数据库。
|
||||||
|
build-type: Simple
|
||||||
|
|
||||||
|
library
|
||||||
|
hs-source-dirs: src
|
||||||
|
exposed-modules: ChuSQL.Server.Accounts
|
||||||
|
ChuSQL.Server.Backend
|
||||||
|
ChuSQL.Server.Policy
|
||||||
|
ChuSQL.Server.Privileges
|
||||||
|
ChuSQL.Server.Session
|
||||||
|
ChuSQL.Server.TCP
|
||||||
|
build-depends: base >= 4.14 && < 5
|
||||||
|
, aeson
|
||||||
|
, bytestring
|
||||||
|
, chusql-core-engine
|
||||||
|
, chusql-core-model
|
||||||
|
, chusql-interface
|
||||||
|
, containers
|
||||||
|
, crypton
|
||||||
|
, directory
|
||||||
|
, filepath
|
||||||
|
, memory
|
||||||
|
, network
|
||||||
|
, scientific
|
||||||
|
, text
|
||||||
|
, time
|
||||||
|
, vector
|
||||||
|
default-language: Haskell2010
|
||||||
|
ghc-options: -Wall
|
||||||
|
|
||||||
|
executable chusql-server
|
||||||
|
main-is: Main.hs
|
||||||
|
hs-source-dirs: app
|
||||||
|
build-depends: base
|
||||||
|
, chusql-core-engine
|
||||||
|
, chusql-core-model
|
||||||
|
, chusql-interface
|
||||||
|
, chusql-server
|
||||||
|
, containers
|
||||||
|
, filepath
|
||||||
|
, text
|
||||||
|
, time
|
||||||
|
default-language: Haskell2010
|
||||||
|
ghc-options: -Wall -threaded
|
||||||
|
-- 运行期找 libchusql_core_storage.so:$ORIGIN 让二进制与动态库同目录即可
|
||||||
|
if os(linux)
|
||||||
|
ld-options: -Wl,-rpath,$ORIGIN
|
||||||
|
if os(osx)
|
||||||
|
ld-options: -Wl,-rpath,@loader_path
|
||||||
|
|
||||||
|
test-suite chusql-server-test
|
||||||
|
type: exitcode-stdio-1.0
|
||||||
|
main-is: Spec.hs
|
||||||
|
hs-source-dirs: test
|
||||||
|
build-depends: base
|
||||||
|
, aeson
|
||||||
|
, bytestring
|
||||||
|
, chusql-core-engine
|
||||||
|
, chusql-core-model
|
||||||
|
, chusql-interface
|
||||||
|
, chusql-server
|
||||||
|
, containers
|
||||||
|
, directory
|
||||||
|
, filepath
|
||||||
|
, hspec
|
||||||
|
, network
|
||||||
|
, text
|
||||||
|
, time
|
||||||
|
, vector
|
||||||
|
default-language: Haskell2010
|
||||||
|
ghc-options: -Wall -threaded
|
||||||
+23
-23
@@ -1,6 +1,6 @@
|
|||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
module ChuSQL.Web.Accounts (
|
module ChuSQL.Server.Accounts (
|
||||||
Accounts, AccountError (..), Principal (..), PasswordPolicy (..), defaultPasswordPolicy,
|
Accounts, AccountError (..), Principal (..), PasswordPolicy (..), defaultPasswordPolicy,
|
||||||
newAccounts, accountsPolicy, setAccountsPolicy, passwordAllowed,
|
newAccounts, accountsPolicy, setAccountsPolicy, passwordAllowed,
|
||||||
rootUserName, principalName, principalIsRoot,
|
rootUserName, principalName, principalIsRoot,
|
||||||
@@ -8,27 +8,18 @@ module ChuSQL.Web.Accounts (
|
|||||||
listAccounts, findAccount, AccountCommand (..), accountCommand, runAccountCommand,
|
listAccounts, findAccount, AccountCommand (..), accountCommand, runAccountCommand,
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import ChuSQL.Storage.IPC (Account (..), Request (..))
|
import ChuSQL.Core.Engine.Storage.IPC (Account (..), Request (..))
|
||||||
import ChuSQL.Syntax.AST (Statement (..))
|
import ChuSQL.Core.Engine.Syntax.AST (Statement (..))
|
||||||
import ChuSQL.Web.Auth
|
import ChuSQL.Interface.Auth
|
||||||
import ChuSQL.Web.Backend (Backend (..), currentStamp)
|
import ChuSQL.Interface.Policy (PasswordPolicy (..), defaultPasswordPolicy)
|
||||||
|
import ChuSQL.Server.Backend (Backend (..), currentStamp)
|
||||||
import Control.Concurrent.MVar (MVar, newMVar, withMVar)
|
import Control.Concurrent.MVar (MVar, newMVar, withMVar)
|
||||||
import Data.Char (isAlphaNum, isAscii, isAsciiLower, isAsciiUpper, isDigit)
|
import Data.Char (isAlphaNum, isAscii, isAsciiLower, isAsciiUpper, isDigit)
|
||||||
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
|
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
|
|
||||||
-- 账号服务:root 来自配置,普通账号由 root 用 CREATE/ALTER/DROP USER 管,口令在这里做策略检查与 PBKDF2 派生。
|
-- 账号服务:root 来自配置,普通账号由 root 用 CREATE/ALTER/DROP USER 管理,口令在此校验并派生哈希。
|
||||||
|
|
||||||
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 AccountError = AccountError Text Text deriving (Show, Eq)
|
||||||
|
|
||||||
@@ -39,6 +30,7 @@ data Principal
|
|||||||
deriving (Eq)
|
deriving (Eq)
|
||||||
|
|
||||||
instance Show Principal where
|
instance Show Principal where
|
||||||
|
-- | 身份的可读形式
|
||||||
show (Root name) = "root " ++ show name
|
show (Root name) = "root " ++ show name
|
||||||
show (Ordinary a) = "account " ++ show (accountUser a)
|
show (Ordinary a) = "account " ++ show (accountUser a)
|
||||||
|
|
||||||
@@ -60,7 +52,7 @@ data Accounts = Accounts
|
|||||||
, acRoot :: Credential
|
, acRoot :: Credential
|
||||||
}
|
}
|
||||||
|
|
||||||
-- | 只记下配置根账号;root 不写系统表,所以启动时不需要任何持久化动作
|
-- | 建账号服务,记录配置根账号
|
||||||
newAccounts :: Backend -> SessionStore -> Credential -> IO Accounts
|
newAccounts :: Backend -> SessionStore -> Credential -> IO Accounts
|
||||||
newAccounts backend sessions credential = do
|
newAccounts backend sessions credential = do
|
||||||
lock <- newMVar ()
|
lock <- newMVar ()
|
||||||
@@ -79,20 +71,24 @@ rootName = normalize . credUser . acRoot
|
|||||||
rootUserName :: Accounts -> Text
|
rootUserName :: Accounts -> Text
|
||||||
rootUserName = rootName
|
rootUserName = rootName
|
||||||
|
|
||||||
|
-- | 读当前生效的口令策略
|
||||||
accountsPolicy :: Accounts -> IO PasswordPolicy
|
accountsPolicy :: Accounts -> IO PasswordPolicy
|
||||||
accountsPolicy = readIORef . acPolicy
|
accountsPolicy = readIORef . acPolicy
|
||||||
|
|
||||||
|
-- | 换上一份新口令策略
|
||||||
setAccountsPolicy :: Accounts -> PasswordPolicy -> IO ()
|
setAccountsPolicy :: Accounts -> PasswordPolicy -> IO ()
|
||||||
setAccountsPolicy service = writeIORef (acPolicy service)
|
setAccountsPolicy service = writeIORef (acPolicy service)
|
||||||
|
|
||||||
-- | 口令策略:长度 + 字符类别(历史与有效期随自助改密一起取消)
|
-- | 按策略检查口令长度与字符类别
|
||||||
passwordAllowed :: PasswordPolicy -> Text -> Either AccountError ()
|
passwordAllowed :: PasswordPolicy -> Text -> Either AccountError ()
|
||||||
passwordAllowed policy password
|
passwordAllowed policy password
|
||||||
| T.length password < ppMinLength policy || T.length password > 256 = bad "password length does not meet policy"
|
| 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"
|
| classes < ppClasses policy = bad "password needs more character classes"
|
||||||
| otherwise = Right ()
|
| otherwise = Right ()
|
||||||
where
|
where
|
||||||
|
-- | 构造一条请求错误
|
||||||
bad = Left . AccountError "bad_request"
|
bad = Left . AccountError "bad_request"
|
||||||
|
-- | 命中的字符类别数
|
||||||
classes =
|
classes =
|
||||||
length
|
length
|
||||||
( filter id
|
( filter id
|
||||||
@@ -110,9 +106,10 @@ validateUserName name
|
|||||||
Left (AccountError "bad_request" "invalid account name")
|
Left (AccountError "bad_request" "invalid account name")
|
||||||
| otherwise = Right ()
|
| otherwise = Right ()
|
||||||
where
|
where
|
||||||
|
-- | 该字符是否允许使用
|
||||||
allowed c = isAscii c && (isAlphaNum c || c `elem` ("_.-" :: String))
|
allowed c = isAscii c && (isAlphaNum c || c `elem` ("_.-" :: String))
|
||||||
|
|
||||||
-- | 新建/改口令时的额外限制:管理员的名字保留给配置里的 root
|
-- | 校验新账号名,并挡掉管理员保留名
|
||||||
validateNewName :: Accounts -> Text -> Either AccountError ()
|
validateNewName :: Accounts -> Text -> Either AccountError ()
|
||||||
validateNewName service name = do
|
validateNewName service name = do
|
||||||
validateUserName name
|
validateUserName name
|
||||||
@@ -137,7 +134,7 @@ storageResult (Left _) = Left storageFailure
|
|||||||
readAccounts :: Accounts -> IO (Either AccountError [Account])
|
readAccounts :: Accounts -> IO (Either AccountError [Account])
|
||||||
readAccounts service = fmap (either (const (Left storageFailure)) Right) (beAccounts (acBackend service) ReqAccountsList)
|
readAccounts service = fmap (either (const (Left storageFailure)) Right) (beAccounts (acBackend service) ReqAccountsList)
|
||||||
|
|
||||||
-- | 当前令牌对应的身份;会话版本与账号 revision 对不上就失效
|
-- | 按令牌解析当前身份,版本对不上即失效
|
||||||
currentUnlocked :: Accounts -> Text -> IO (Either AccountError Principal)
|
currentUnlocked :: Accounts -> Text -> IO (Either AccountError Principal)
|
||||||
currentUnlocked service token = do
|
currentUnlocked service token = do
|
||||||
session <- lookupVersionedSession (acSessions service) token
|
session <- lookupVersionedSession (acSessions service) token
|
||||||
@@ -156,11 +153,11 @@ currentUnlocked service token = do
|
|||||||
then Right (Ordinary account)
|
then Right (Ordinary account)
|
||||||
else Left unauthorized
|
else Left unauthorized
|
||||||
|
|
||||||
|
-- | 加锁解析当前身份
|
||||||
currentPrincipal :: Accounts -> Text -> IO (Either AccountError Principal)
|
currentPrincipal :: Accounts -> Text -> IO (Either AccountError Principal)
|
||||||
currentPrincipal service token = withMVar (acLock service) $ \_ -> currentUnlocked service token
|
currentPrincipal service token = withMVar (acLock service) $ \_ -> currentUnlocked service token
|
||||||
|
|
||||||
-- | 登录:先比配置里的 root,再查系统表里的普通账号(没有切换身份的入口)。
|
-- | 登录:先比 root,再查普通账号;空口令即免密
|
||||||
-- 配置里的口令留空即"免密":只有管理员能用空口令进来,普通账号一律拒绝。
|
|
||||||
authenticate :: Accounts -> Text -> Text -> IO (Either AccountError Text)
|
authenticate :: Accounts -> Text -> Text -> IO (Either AccountError Text)
|
||||||
authenticate service user password = withMVar (acLock service) $ \_ ->
|
authenticate service user password = withMVar (acLock service) $ \_ ->
|
||||||
if normalize user == rootName service
|
if normalize user == rootName service
|
||||||
@@ -170,7 +167,9 @@ authenticate service user password = withMVar (acLock service) $ \_ ->
|
|||||||
then pure (Left (AccountError "forbidden" "this server only accepts the administrator"))
|
then pure (Left (AccountError "forbidden" "this server only accepts the administrator"))
|
||||||
else signInOrdinary
|
else signInOrdinary
|
||||||
where
|
where
|
||||||
|
-- | 配置里没有口令
|
||||||
passwordless = T.null (credEncoded (acRoot service))
|
passwordless = T.null (credEncoded (acRoot service))
|
||||||
|
-- | 管理员登录
|
||||||
signInRoot
|
signInRoot
|
||||||
| passwordless =
|
| passwordless =
|
||||||
if T.null password
|
if T.null password
|
||||||
@@ -179,6 +178,7 @@ authenticate service user password = withMVar (acLock service) $ \_ ->
|
|||||||
| verifyPassword (credEncoded (acRoot service)) password =
|
| verifyPassword (credEncoded (acRoot service)) password =
|
||||||
Right <$> createVersionedSession (acSessions service) (rootName service) Nothing
|
Right <$> createVersionedSession (acSessions service) (rootName service) Nothing
|
||||||
| otherwise = pure (Left unauthorized)
|
| otherwise = pure (Left unauthorized)
|
||||||
|
-- | 普通账号登录
|
||||||
signInOrdinary = do
|
signInOrdinary = do
|
||||||
stored <- readAccounts service
|
stored <- readAccounts service
|
||||||
case stored of
|
case stored of
|
||||||
@@ -217,7 +217,7 @@ createUser service name password = withMVar (acLock service) $ \_ -> do
|
|||||||
encoded <- hashPassword password
|
encoded <- hashPassword password
|
||||||
storageResult <$> beAccounts (acBackend service) (ReqAccountCreate (normalize name) encoded)
|
storageResult <$> beAccounts (acBackend service) (ReqAccountCreate (normalize name) encoded)
|
||||||
|
|
||||||
-- | 改一个普通账号的口令(管理员重置,不受历史/有效期限制)
|
-- | 管理员重置普通账号口令
|
||||||
resetPassword :: Accounts -> Text -> Text -> IO (Either AccountError ())
|
resetPassword :: Accounts -> Text -> Text -> IO (Either AccountError ())
|
||||||
resetPassword service name password = withMVar (acLock service) $ \_ -> do
|
resetPassword service name password = withMVar (acLock service) $ \_ -> do
|
||||||
policy <- accountsPolicy service
|
policy <- accountsPolicy service
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
module ChuSQL.Web.Backend (
|
module ChuSQL.Server.Backend (
|
||||||
StatementResult (..),
|
StatementResult (..),
|
||||||
Backend (..),
|
Backend (..),
|
||||||
columnsFromStatement,
|
columnsFromStatement,
|
||||||
@@ -20,21 +20,22 @@ module ChuSQL.Web.Backend (
|
|||||||
tableRefsOf,
|
tableRefsOf,
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import ChuSQL.Engine (runStatement, runStatementM)
|
import ChuSQL.Core.Engine (runStatement, runStatementM)
|
||||||
import ChuSQL.Model
|
import ChuSQL.Core.Model
|
||||||
import ChuSQL.Semantic (prepare)
|
import ChuSQL.Core.Engine.Semantic (prepare)
|
||||||
import ChuSQL.Storage (MonadStorage (schema))
|
import ChuSQL.Core.Engine.Storage (MonadStorage (schema))
|
||||||
import ChuSQL.Storage.IPC (
|
import ChuSQL.Core.Engine.Storage.IPC (
|
||||||
Account (..),
|
Account (..),
|
||||||
IPCStorage (..),
|
IPCStorage (..),
|
||||||
Request (..),
|
Request (..),
|
||||||
Response (..),
|
Response (..),
|
||||||
SchemaColumn (..),
|
SchemaColumn (..),
|
||||||
TableInfo (..),
|
TableInfo (..),
|
||||||
|
sendRawRequest,
|
||||||
sendRequest,
|
sendRequest,
|
||||||
)
|
)
|
||||||
import ChuSQL.Syntax.AST
|
import ChuSQL.Core.Engine.Syntax.AST
|
||||||
import ChuSQL.Syntax.Parser (parseStatement)
|
import ChuSQL.Core.Engine.Syntax.Parser (parseStatement)
|
||||||
import Control.Concurrent.MVar (MVar, modifyMVar, newMVar, readMVar, withMVar)
|
import Control.Concurrent.MVar (MVar, modifyMVar, newMVar, readMVar, withMVar)
|
||||||
import Data.List (nub)
|
import Data.List (nub)
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
@@ -44,7 +45,7 @@ import qualified Data.Aeson as A
|
|||||||
import qualified Data.ByteString.Lazy.Char8 as BL
|
import qualified Data.ByteString.Lazy.Char8 as BL
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
|
|
||||||
-- 后端:跑一条语句与取数据字典,分 IPC(真存储)和内存(测试)两套。
|
-- 后端抽象:跑语句、取数据字典与账号,分 IPC(真存储)和内存(测试)两套实现。
|
||||||
|
|
||||||
data StatementResult = StatementResult
|
data StatementResult = StatementResult
|
||||||
{ srColumns :: [String]
|
{ srColumns :: [String]
|
||||||
@@ -58,6 +59,8 @@ data Backend = Backend
|
|||||||
, beAccounts :: Request -> IO (Either String [Account])
|
, beAccounts :: Request -> IO (Either String [Account])
|
||||||
, beWithDatabase :: String -> Backend
|
, beWithDatabase :: String -> Backend
|
||||||
, beDatabases :: IO (Either String [String])
|
, beDatabases :: IO (Either String [String])
|
||||||
|
-- | 原样转发一条存储请求
|
||||||
|
, beStorage :: A.Value -> IO (Either String A.Value)
|
||||||
}
|
}
|
||||||
|
|
||||||
-- | 按给定列名取列,固定列顺序
|
-- | 按给定列名取列,固定列顺序
|
||||||
@@ -67,7 +70,7 @@ columnsFromStatement db stmt = case prepare db stmt of
|
|||||||
Right (SelectExpr items _ _ _ _ _) -> map fst items
|
Right (SelectExpr items _ _ _ _ _) -> map fst items
|
||||||
_ -> []
|
_ -> []
|
||||||
|
|
||||||
-- | 走本地端点的后端(真存储:Windows 具名管道 / Unix 套接字)
|
-- | 走本地存储链路的后端
|
||||||
ipcBackend :: IO Backend
|
ipcBackend :: IO Backend
|
||||||
ipcBackend = do
|
ipcBackend = do
|
||||||
lock <- newMVar ()
|
lock <- newMVar ()
|
||||||
@@ -95,10 +98,22 @@ ipcBackend = do
|
|||||||
RespTables names -> Right names
|
RespTables names -> Right names
|
||||||
RespError err -> Left err
|
RespError err -> Left err
|
||||||
_ -> Left "unexpected database response"
|
_ -> Left "unexpected database response"
|
||||||
|
, beStorage = \payload -> withMVar lock $ \_ -> runRaw payload
|
||||||
}
|
}
|
||||||
-- 没有默认库:没选库时 current 是空串,任何请求都得先选库
|
-- 没有默认库:没选库时 current 是空串,任何请求都得先选库
|
||||||
pure (backend "")
|
pure (backend "")
|
||||||
where
|
where
|
||||||
|
-- | 转发一条存储请求并解析响应
|
||||||
|
runRaw :: A.Value -> IO (Either String A.Value)
|
||||||
|
runRaw payload = do
|
||||||
|
response <- sendRawRequest (BL.toStrict (A.encode payload))
|
||||||
|
pure $ case response of
|
||||||
|
Left err -> Left err
|
||||||
|
Right raw -> case A.eitherDecodeStrict raw of
|
||||||
|
Left err -> Left ("storage response is not valid JSON: " ++ err)
|
||||||
|
Right value -> Right value
|
||||||
|
|
||||||
|
-- | 走存储链路跑一条语句
|
||||||
runIpc :: String -> String -> IO (Either String StatementResult)
|
runIpc :: String -> String -> IO (Either String StatementResult)
|
||||||
runIpc current sql = case fmap (inDatabase current) (parseStatement sql) of
|
runIpc current sql = case fmap (inDatabase current) (parseStatement sql) of
|
||||||
Left e -> pure (Left e)
|
Left e -> pure (Left e)
|
||||||
@@ -114,12 +129,11 @@ ipcBackend = do
|
|||||||
(r : _) -> pure (map fst r)
|
(r : _) -> pure (map fst r)
|
||||||
pure (Right (StatementResult cols rows))
|
pure (Right (StatementResult cols rows))
|
||||||
|
|
||||||
-- | 内存后端(测试):状态就是一个 `Database`,名字是它代表的库
|
-- | 测试用内存后端,状态是一个 Database
|
||||||
memoryBackend :: String -> MVar Database -> Backend
|
memoryBackend :: String -> MVar Database -> Backend
|
||||||
memoryBackend = memoryBackendWith False
|
memoryBackend = memoryBackendWith False
|
||||||
|
|
||||||
-- | 同一份内存状态可以按普通库(trusted=False)或系统库(trusted=True)视角跑:
|
-- | 构造内存后端,Bool 决定普通库还是系统库视角
|
||||||
-- 系统库要能读写角色/授权表,普通库连看都不许看。
|
|
||||||
memoryBackendWith :: Bool -> String -> MVar Database -> Backend
|
memoryBackendWith :: Bool -> String -> MVar Database -> Backend
|
||||||
memoryBackendWith trusted name ref =
|
memoryBackendWith trusted name ref =
|
||||||
Backend
|
Backend
|
||||||
@@ -147,30 +161,33 @@ memoryBackendWith trusted name ref =
|
|||||||
[[("account", VStr (BL.unpack (A.encode a))) ] | a <- accounts]) : filter ((/= usersTable) . fst) db, Right accounts)
|
[[("account", VStr (BL.unpack (A.encode a))) ] | a <- accounts]) : filter ((/= usersTable) . fst) db, Right accounts)
|
||||||
, beWithDatabase = withDatabase
|
, beWithDatabase = withDatabase
|
||||||
, beDatabases = pure (Right (nub [name, systemDatabaseName]))
|
, beDatabases = pure (Right (nub [name, systemDatabaseName]))
|
||||||
|
, beStorage = \_ -> pure (Left "storage is not available in this backend")
|
||||||
}
|
}
|
||||||
where
|
where
|
||||||
|
-- | 按视角过滤库里的表
|
||||||
scoped = if trusted then filter (not . reserved . fst) else visible
|
scoped = if trusted then filter (not . reserved . fst) else visible
|
||||||
|
-- | 该视角下要隐藏的表
|
||||||
hidden = if trusted then reserved else internalTable
|
hidden = if trusted then reserved else internalTable
|
||||||
|
-- | 取结果列名,空结果回落到解析
|
||||||
colsOf db stmt rows = case rows of
|
colsOf db stmt rows = case rows of
|
||||||
[] -> columnsFromStatement db stmt
|
[] -> columnsFromStatement db stmt
|
||||||
(r : _) -> map fst r
|
(r : _) -> map fst r
|
||||||
|
|
||||||
-- 系统库视角给权限服务用;夹具自己的库走普通视角;没选库时只放行不碰表的语句
|
-- | 按目标库名挑视角
|
||||||
withDatabase target
|
withDatabase target
|
||||||
| target == systemDatabaseName = memoryBackendWith True name ref
|
| target == systemDatabaseName = memoryBackendWith True name ref
|
||||||
| target == name = memoryBackendWith False name ref
|
| target == name = memoryBackendWith False name ref
|
||||||
| null target = noDatabaseBackend (memoryBackendWith False name ref)
|
| null target = noDatabaseBackend (memoryBackendWith False name ref)
|
||||||
| otherwise = unavailableDatabase ("unknown database: " ++ target) (memoryBackendWith False name ref)
|
| otherwise = unavailableDatabase ("unknown database: " ++ target) (memoryBackendWith False name ref)
|
||||||
|
|
||||||
|
-- | 把后端的语句与字典都改成同一条错误
|
||||||
unavailableDatabase :: String -> Backend -> Backend
|
unavailableDatabase :: String -> Backend -> Backend
|
||||||
unavailableDatabase message backend = backend
|
unavailableDatabase message backend = backend
|
||||||
{ beStatement = \_ -> pure (Left message)
|
{ beStatement = \_ -> pure (Left message)
|
||||||
, beCatalog = pure (Left message)
|
, beCatalog = pure (Left message)
|
||||||
}
|
}
|
||||||
|
|
||||||
-- | 没选库时的视角:不碰表的语句(USE、建库、账号与角色)照跑,
|
-- | 未选库时的视角:只放行不碰表的语句
|
||||||
-- 一旦语句里出现裸表名就报 no database selected。
|
|
||||||
-- 与存储层口径一致:`db.table` 这种限定名不需要先选库。
|
|
||||||
noDatabaseBackend :: Backend -> Backend
|
noDatabaseBackend :: Backend -> Backend
|
||||||
noDatabaseBackend backend = backend
|
noDatabaseBackend backend = backend
|
||||||
{ beStatement = \sql -> case parseStatement sql of
|
{ beStatement = \sql -> case parseStatement sql of
|
||||||
@@ -181,11 +198,11 @@ noDatabaseBackend backend = backend
|
|||||||
, beCatalog = pure (Left "no database selected")
|
, beCatalog = pure (Left "no database selected")
|
||||||
}
|
}
|
||||||
|
|
||||||
-- | Database context is an immutable request value, never shared connection state.
|
-- | 给语句里的裸表名补上当前库前缀,子查询里的表名也一起改
|
||||||
-- 子查询里出现的表名同样要带上数据库前缀,否则会落到错误的库里。
|
|
||||||
inDatabase :: String -> Statement -> Statement
|
inDatabase :: String -> Statement -> Statement
|
||||||
inDatabase current = walk
|
inDatabase current = walk
|
||||||
where
|
where
|
||||||
|
-- | 递归改写语句里的表名
|
||||||
walk :: Statement -> Statement
|
walk :: Statement -> Statement
|
||||||
walk stmt = case stmt of
|
walk stmt = case stmt of
|
||||||
q@Select{} ->
|
q@Select{} ->
|
||||||
@@ -214,14 +231,16 @@ inDatabase current = walk
|
|||||||
AlterColumnNull t c b -> AlterColumnNull (table t) c b
|
AlterColumnNull t c b -> AlterColumnNull (table t) c b
|
||||||
other -> other
|
other -> other
|
||||||
|
|
||||||
|
-- | 给单个表名补库前缀,已限定或没选库就原样
|
||||||
table t | '.' `elem` t || null current = t
|
table t | '.' `elem` t || null current = t
|
||||||
| otherwise = current ++ "." ++ t
|
| otherwise = current ++ "." ++ t
|
||||||
|
|
||||||
|
-- | 改写 FROM 子句
|
||||||
source FromUnit = FromUnit
|
source FromUnit = FromUnit
|
||||||
source (FromTable a t) = FromTable a (table t)
|
source (FromTable a t) = FromTable a (table t)
|
||||||
source (FromJoin k l a t e) = FromJoin k (source l) a (table t) (expr e)
|
source (FromJoin k l a t e) = FromJoin k (source l) a (table t) (expr e)
|
||||||
|
|
||||||
-- 表达式里的子查询也要一起改写
|
-- | 改写表达式里的子查询
|
||||||
expr (ScalarSub sq) = ScalarSub (sub sq)
|
expr (ScalarSub sq) = ScalarSub (sub sq)
|
||||||
expr (ExistsSub sq neg) = ExistsSub (sub sq) neg
|
expr (ExistsSub sq neg) = ExistsSub (sub sq) neg
|
||||||
expr (InSub a sq neg) = InSub (expr a) (sub sq) neg
|
expr (InSub a sq neg) = InSub (expr a) (sub sq) neg
|
||||||
@@ -245,9 +264,10 @@ inDatabase current = walk
|
|||||||
expr (MaxOf a) = MaxOf (expr a)
|
expr (MaxOf a) = MaxOf (expr a)
|
||||||
expr other = other
|
expr other = other
|
||||||
|
|
||||||
|
-- | 改写一个子查询
|
||||||
sub sq = sq {subqueryStatement = walk (subqueryStatement sq)}
|
sub sq = sq {subqueryStatement = walk (subqueryStatement sq)}
|
||||||
|
|
||||||
-- | 语句里出现的表(含 JOIN、子查询、INSERT/UPDATE/DELETE 的目标)
|
-- | 语句里出现的表
|
||||||
statementTables :: Statement -> [Text]
|
statementTables :: Statement -> [Text]
|
||||||
statementTables stmt = nub (case stmt of
|
statementTables stmt = nub (case stmt of
|
||||||
Select{} ->
|
Select{} ->
|
||||||
@@ -307,6 +327,7 @@ exprTables expr = case expr of
|
|||||||
MaxOf a -> exprTables a
|
MaxOf a -> exprTables a
|
||||||
_ -> []
|
_ -> []
|
||||||
where
|
where
|
||||||
|
-- | 两侧都取表名
|
||||||
both a b = exprTables a ++ exprTables b
|
both a b = exprTables a ++ exprTables b
|
||||||
|
|
||||||
-- | 一张表的线上信息(内存实现:没有索引,统计现算)
|
-- | 一张表的线上信息(内存实现:没有索引,统计现算)
|
||||||
@@ -320,6 +341,7 @@ tableInfoOf (name, tbl) =
|
|||||||
, tiStats = [(c, distinctOf c, False) | (c, _) <- tableCols tbl]
|
, tiStats = [(c, distinctOf c, False) | (c, _) <- tableCols tbl]
|
||||||
}
|
}
|
||||||
where
|
where
|
||||||
|
-- | 一列的不同值个数
|
||||||
distinctOf c = length (nub [v | r <- tableRows tbl, Just v <- [lookup c r]])
|
distinctOf c = length (nub [v | r <- tableRows tbl, Just v <- [lookup c r]])
|
||||||
|
|
||||||
-- | 本地列类型转线上字符串(和 IPC 那边同一套写法)
|
-- | 本地列类型转线上字符串(和 IPC 那边同一套写法)
|
||||||
@@ -340,14 +362,14 @@ wireColumn name col =
|
|||||||
, scCheck = columnCheck col
|
, scCheck = columnCheck col
|
||||||
}
|
}
|
||||||
|
|
||||||
-- | 系统库:账号、角色与授权表住在这里,其中的表都是系统表,只给管理员
|
-- | 系统库名,账号、角色与授权表住在这里
|
||||||
systemDatabaseName :: String
|
systemDatabaseName :: String
|
||||||
systemDatabaseName = "system"
|
systemDatabaseName = "system"
|
||||||
|
|
||||||
usersTable :: String
|
usersTable :: String
|
||||||
usersTable = "__chusql_users"
|
usersTable = "__chusql_users"
|
||||||
|
|
||||||
-- | 角色与授权表:住在 system 库,名字不带 `__chusql_` 前缀(Rust 存储层拒收那个前缀)
|
-- | 角色与授权表的名字,不带内部前缀
|
||||||
rolesTable :: String
|
rolesTable :: String
|
||||||
rolesTable = "sys_roles"
|
rolesTable = "sys_roles"
|
||||||
|
|
||||||
@@ -360,13 +382,15 @@ membersTable = "sys_members"
|
|||||||
internalNames :: [String]
|
internalNames :: [String]
|
||||||
internalNames = [rolesTable, grantsTable, membersTable]
|
internalNames = [rolesTable, grantsTable, membersTable]
|
||||||
|
|
||||||
|
-- | 名字是否带内部表前缀
|
||||||
reserved :: String -> Bool
|
reserved :: String -> Bool
|
||||||
reserved = T.isPrefixOf "__chusql_" . T.toLower . T.pack
|
reserved = T.isPrefixOf "__chusql_" . T.toLower . T.pack
|
||||||
|
|
||||||
-- | 服务自己的内部表:目录里不出现,普通作用域读写都拒(系统作用域才放行角色/授权表)
|
-- | 是不是服务自己的内部表
|
||||||
internalTable :: String -> Bool
|
internalTable :: String -> Bool
|
||||||
internalTable name = reserved name || T.unpack (T.toLower (T.pack name)) `elem` internalNames
|
internalTable name = reserved name || T.unpack (T.toLower (T.pack name)) `elem` internalNames
|
||||||
|
|
||||||
|
-- | 去掉内部表
|
||||||
visible :: Database -> Database
|
visible :: Database -> Database
|
||||||
visible = filter (not . internalTable . fst)
|
visible = filter (not . internalTable . fst)
|
||||||
|
|
||||||
@@ -374,6 +398,7 @@ visible = filter (not . internalTable . fst)
|
|||||||
currentStamp :: IO T.Text
|
currentStamp :: IO T.Text
|
||||||
currentStamp = T.pack . formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" <$> getCurrentTime
|
currentStamp = T.pack . formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" <$> getCurrentTime
|
||||||
|
|
||||||
|
-- | 在内存账号表上执行一条账号请求
|
||||||
memoryAccounts :: Database -> T.Text -> Request -> Either String [Account]
|
memoryAccounts :: Database -> T.Text -> Request -> Either String [Account]
|
||||||
memoryAccounts db stamp req = do
|
memoryAccounts db stamp req = do
|
||||||
accounts <- case lookup usersTable db of
|
accounts <- case lookup usersTable db of
|
||||||
@@ -395,6 +420,7 @@ memoryAccounts db stamp req = do
|
|||||||
_ -> Right [x | x <- accounts, accountUser x /= T.toLower u]
|
_ -> Right [x | x <- accounts, accountUser x /= T.toLower u]
|
||||||
_ -> Left "invalid account request"
|
_ -> Left "invalid account request"
|
||||||
where
|
where
|
||||||
|
-- | 把一行解回账号
|
||||||
decodeAccount row = case lookup "account" row of
|
decodeAccount row = case lookup "account" row of
|
||||||
Just (VStr encoded) -> A.eitherDecode (BL.pack encoded)
|
Just (VStr encoded) -> A.eitherDecode (BL.pack encoded)
|
||||||
_ -> Left "invalid account record"
|
_ -> Left "invalid account record"
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
|
module ChuSQL.Server.Policy (
|
||||||
|
configurePasswordPolicy,
|
||||||
|
policyFromValues,
|
||||||
|
passwordKeys,
|
||||||
|
) where
|
||||||
|
|
||||||
|
import ChuSQL.Interface.Policy (defaultPasswordPolicy, passwordKeys, policyFromValues)
|
||||||
|
import ChuSQL.Interface.Settings (applySettings, defaultOf, readSettingsFile)
|
||||||
|
import ChuSQL.Server.Accounts (Accounts, setAccountsPolicy)
|
||||||
|
import qualified Data.Map.Strict as Map
|
||||||
|
|
||||||
|
-- 口令策略装配:从设置文件读 password-* 键算出生效策略,并装到账号服务上。
|
||||||
|
|
||||||
|
-- | 从设置文件装口令策略,坏值报错
|
||||||
|
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)
|
||||||
+36
-38
@@ -1,15 +1,6 @@
|
|||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
-- | 角色与权限服务。
|
module ChuSQL.Server.Privileges (
|
||||||
--
|
|
||||||
-- 模型:权限挂在角色上,用户通过角色成员资格继承角色权限;管理员(root)隐式全权,
|
|
||||||
-- 新账号默认零权限。语句(CREATE ROLE / DROP ROLE / GRANT / REVOKE)由引擎解析并做
|
|
||||||
-- 基本校验,真正落库在这里。
|
|
||||||
--
|
|
||||||
-- 存储:system 库里的三张普通表(`sys_roles` / `sys_grants` / `sys_members`)。
|
|
||||||
-- 起普通名字是因为 Rust 存储层拒收 `__chusql_` 前缀的表名,那条路走不通;
|
|
||||||
-- 读写都借 system 作用域后端的普通 SQL,内存后端与 IPC 后端共用一套逻辑。
|
|
||||||
module ChuSQL.Web.Privileges (
|
|
||||||
Grant (..),
|
Grant (..),
|
||||||
Membership (..),
|
Membership (..),
|
||||||
PrivilegeCommand (..),
|
PrivilegeCommand (..),
|
||||||
@@ -26,11 +17,12 @@ module ChuSQL.Web.Privileges (
|
|||||||
runPrivilegeCommand,
|
runPrivilegeCommand,
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import ChuSQL.Model (Row, Value (..))
|
import ChuSQL.Core.Model (Row, Value (..))
|
||||||
import ChuSQL.Syntax.AST
|
import ChuSQL.Core.Engine.Syntax.AST
|
||||||
import ChuSQL.Web.Accounts (Principal, principalIsRoot, principalName)
|
import ChuSQL.Server.Accounts (Principal, principalIsRoot, principalName)
|
||||||
import ChuSQL.Web.Actions (sqlLiteral)
|
import ChuSQL.Interface.Actions (sqlLiteral)
|
||||||
import ChuSQL.Web.Backend (
|
import ChuSQL.Interface.Protocol (Grant (..), RoleView (..))
|
||||||
|
import ChuSQL.Server.Backend (
|
||||||
Backend (..),
|
Backend (..),
|
||||||
StatementResult (..),
|
StatementResult (..),
|
||||||
exprTables,
|
exprTables,
|
||||||
@@ -45,18 +37,13 @@ import Data.List (intercalate, isInfixOf, nub, sort)
|
|||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
|
|
||||||
-- | 出错口径与账号服务一致,映射由 API 层统一做
|
-- 角色与权限服务:角色、授权与成员关系落在 system 库的表里,并据此判权限。
|
||||||
|
|
||||||
|
-- | 出错口径与账号服务一致
|
||||||
data PrivilegeError = PrivilegeError Text Text
|
data PrivilegeError = PrivilegeError Text Text
|
||||||
deriving (Show, Eq)
|
deriving (Show, Eq)
|
||||||
|
|
||||||
-- | 一条角色授权:角色 + 具体权限 + 对象(`*` 或 `库.表`)
|
-- 一条角色授权(定义在共享协议层)
|
||||||
data Grant = Grant
|
|
||||||
{ grantRole :: Text
|
|
||||||
, grantPrivilege :: Text
|
|
||||||
, grantObject :: Text
|
|
||||||
}
|
|
||||||
deriving (Show, Eq, Ord)
|
|
||||||
|
|
||||||
-- | 角色成员资格
|
-- | 角色成员资格
|
||||||
data Membership = Membership
|
data Membership = Membership
|
||||||
{ memberRole :: Text
|
{ memberRole :: Text
|
||||||
@@ -64,13 +51,7 @@ data Membership = Membership
|
|||||||
}
|
}
|
||||||
deriving (Show, Eq, Ord)
|
deriving (Show, Eq, Ord)
|
||||||
|
|
||||||
-- | 一个角色的全貌
|
-- | 一个角色的全貌:授权与成员
|
||||||
data RoleView = RoleView
|
|
||||||
{ roleName :: Text
|
|
||||||
, roleGrants :: [Grant]
|
|
||||||
, roleMembers :: [Text]
|
|
||||||
}
|
|
||||||
deriving (Show, Eq)
|
|
||||||
|
|
||||||
data PrivilegeCommand
|
data PrivilegeCommand
|
||||||
= CreateRoleCommand Text
|
= CreateRoleCommand Text
|
||||||
@@ -86,13 +67,13 @@ data Privileges = Privileges
|
|||||||
, pvLock :: MVar ()
|
, pvLock :: MVar ()
|
||||||
}
|
}
|
||||||
|
|
||||||
-- | 服务固定看 system 库;构造时不碰存储,第一次用才建表
|
-- | 建权限服务,固定在 system 库上
|
||||||
newPrivileges :: Backend -> IO Privileges
|
newPrivileges :: Backend -> IO Privileges
|
||||||
newPrivileges base = do
|
newPrivileges base = do
|
||||||
lock <- newMVar ()
|
lock <- newMVar ()
|
||||||
pure (Privileges (beWithDatabase base systemDatabaseName) lock)
|
pure (Privileges (beWithDatabase base systemDatabaseName) lock)
|
||||||
|
|
||||||
-- | 语句 → 管理命令(引擎只解析与基本校验,不认的语句给 Nothing)
|
-- | 语句转管理命令,不认的给 Nothing
|
||||||
privilegeCommand :: Statement -> Maybe PrivilegeCommand
|
privilegeCommand :: Statement -> Maybe PrivilegeCommand
|
||||||
privilegeCommand stmt = case stmt of
|
privilegeCommand stmt = case stmt of
|
||||||
CreateRole name -> Just (CreateRoleCommand (T.pack name))
|
CreateRole name -> Just (CreateRoleCommand (T.pack name))
|
||||||
@@ -112,13 +93,13 @@ runPrivilegeCommand service principal database command
|
|||||||
| needsDatabase = pure (Left (PrivilegeError "no_database" "no database selected"))
|
| needsDatabase = pure (Left (PrivilegeError "no_database" "no database selected"))
|
||||||
| otherwise = withTables service (applyCommand service database command)
|
| otherwise = withTables service (applyCommand service database command)
|
||||||
where
|
where
|
||||||
-- 授权到具体对象(不是 `*`)时要把裸表名补成 `库.表`,没选库就无从补全
|
-- | 授权到具体对象时需要先选库
|
||||||
needsDatabase = T.null database && case command of
|
needsDatabase = T.null database && case command of
|
||||||
GrantPrivilegesCommand _ object _ -> T.strip object /= "*"
|
GrantPrivilegesCommand _ object _ -> T.strip object /= "*"
|
||||||
RevokePrivilegesCommand _ object _ -> T.strip object /= "*"
|
RevokePrivilegesCommand _ object _ -> T.strip object /= "*"
|
||||||
_ -> False
|
_ -> False
|
||||||
|
|
||||||
-- | 语句鉴权:管理员全通;普通身份按语句需要的每一项 (表, 权限) 查
|
-- | 语句鉴权:管理员全通,普通身份按需查
|
||||||
authorize :: Privileges -> Principal -> Text -> Statement -> IO (Either PrivilegeError ())
|
authorize :: Privileges -> Principal -> Text -> Statement -> IO (Either PrivilegeError ())
|
||||||
authorize service principal database stmt
|
authorize service principal database stmt
|
||||||
| principalIsRoot principal = pure (Right ())
|
| principalIsRoot principal = pure (Right ())
|
||||||
@@ -134,7 +115,7 @@ authorizeTables service principal database needed
|
|||||||
grants <- grantsOfUser service (principalName principal)
|
grants <- grantsOfUser service (principalName principal)
|
||||||
pure (grants >>= \found -> checkAll database found needed)
|
pure (grants >>= \found -> checkAll database found needed)
|
||||||
|
|
||||||
-- | 只留下用户有 SELECT 权限的表(管理员看全部);表清单接口用
|
-- | 只留下用户有 SELECT 权的表
|
||||||
filterTables :: Privileges -> Principal -> Text -> [Text] -> IO (Either PrivilegeError [Text])
|
filterTables :: Privileges -> Principal -> Text -> [Text] -> IO (Either PrivilegeError [Text])
|
||||||
filterTables service principal database tables
|
filterTables service principal database tables
|
||||||
| principalIsRoot principal = pure (Right tables)
|
| principalIsRoot principal = pure (Right tables)
|
||||||
@@ -150,6 +131,7 @@ listRoleViews service = withTables service $ do
|
|||||||
Left err -> pure (Left err)
|
Left err -> pure (Left err)
|
||||||
Right known -> sequence <$> mapM view (sort known)
|
Right known -> sequence <$> mapM view (sort known)
|
||||||
where
|
where
|
||||||
|
-- | 组装一个角色的总览
|
||||||
view role = do
|
view role = do
|
||||||
grants <- roleGrantsOf service role
|
grants <- roleGrantsOf service role
|
||||||
members <- membersOfRole service role
|
members <- membersOfRole service role
|
||||||
@@ -162,7 +144,7 @@ normalizeObject database object
|
|||||||
| T.any (== '.') object = T.toLower (T.strip object)
|
| T.any (== '.') object = T.toLower (T.strip object)
|
||||||
| otherwise = normalizeUser database <> "." <> normalizeUser object
|
| otherwise = normalizeUser database <> "." <> normalizeUser object
|
||||||
|
|
||||||
-- | 一条语句需要哪些 (表, 权限);Nothing 表示管理员专属
|
-- | 一条语句需要哪些 (表, 权限)
|
||||||
requiredActions :: Statement -> Maybe [(Text, Text)]
|
requiredActions :: Statement -> Maybe [(Text, Text)]
|
||||||
requiredActions stmt = case stmt of
|
requiredActions stmt = case stmt of
|
||||||
Select{} -> Just (selectNeeds (selectFrom stmt) (maybe [] (: []) (selectWhere stmt)))
|
Select{} -> Just (selectNeeds (selectFrom stmt) (maybe [] (: []) (selectWhere stmt)))
|
||||||
@@ -173,7 +155,9 @@ requiredActions stmt = case stmt of
|
|||||||
Delete table cond -> Just ((T.pack table, "delete") : selectsOf (maybe [] (: []) cond))
|
Delete table cond -> Just ((T.pack table, "delete") : selectsOf (maybe [] (: []) cond))
|
||||||
_ -> Nothing
|
_ -> Nothing
|
||||||
where
|
where
|
||||||
|
-- | FROM 子句要的 SELECT 权限
|
||||||
selectNeeds source exprs = [(ref, "select") | ref <- nub (tableRefsOf source)] ++ selectsOf exprs
|
selectNeeds source exprs = [(ref, "select") | ref <- nub (tableRefsOf source)] ++ selectsOf exprs
|
||||||
|
-- | 表达式里子查询要的 SELECT 权限
|
||||||
selectsOf exprs = [(ref, "select") | ref <- nub (concatMap exprTables exprs)]
|
selectsOf exprs = [(ref, "select") | ref <- nub (concatMap exprTables exprs)]
|
||||||
|
|
||||||
-- | 每项 (表, 权限) 都要被某条授权覆盖
|
-- | 每项 (表, 权限) 都要被某条授权覆盖
|
||||||
@@ -183,6 +167,7 @@ checkAll database grants needed = case [pair | pair@(table, privilege) <- needed
|
|||||||
((table, privilege) : _) ->
|
((table, privilege) : _) ->
|
||||||
Left (PrivilegeError "forbidden" ("permission denied: " <> T.toUpper privilege <> " ON " <> table))
|
Left (PrivilegeError "forbidden" ("permission denied: " <> T.toUpper privilege <> " ON " <> table))
|
||||||
where
|
where
|
||||||
|
-- | 某一项是否被授权覆盖
|
||||||
covered table privilege =
|
covered table privilege =
|
||||||
let target = normalizeObject database table
|
let target = normalizeObject database table
|
||||||
in any
|
in any
|
||||||
@@ -301,15 +286,18 @@ withTables service action = withMVar (pvLock service) $ \_ -> do
|
|||||||
Left err -> pure (Left err)
|
Left err -> pure (Left err)
|
||||||
Right () -> action
|
Right () -> action
|
||||||
|
|
||||||
|
-- | 确保三张内部表存在
|
||||||
ensureTables :: Privileges -> IO (Either PrivilegeError ())
|
ensureTables :: Privileges -> IO (Either PrivilegeError ())
|
||||||
ensureTables service = each tableDefs (createTable service)
|
ensureTables service = each tableDefs (createTable service)
|
||||||
where
|
where
|
||||||
|
-- | 三张内部表的定义
|
||||||
tableDefs =
|
tableDefs =
|
||||||
[ (rolesTable, "(name VARCHAR(64))")
|
[ (rolesTable, "(name VARCHAR(64))")
|
||||||
, (grantsTable, "(role VARCHAR(64), privilege VARCHAR(16), object VARCHAR(128))")
|
, (grantsTable, "(role VARCHAR(64), privilege VARCHAR(16), object VARCHAR(128))")
|
||||||
, (membersTable, "(role VARCHAR(64), member VARCHAR(64))")
|
, (membersTable, "(role VARCHAR(64), member VARCHAR(64))")
|
||||||
]
|
]
|
||||||
|
|
||||||
|
-- | 建一张内部表,已存在算成功
|
||||||
createTable :: Privileges -> (String, String) -> IO (Either PrivilegeError ())
|
createTable :: Privileges -> (String, String) -> IO (Either PrivilegeError ())
|
||||||
createTable service (table, columns) = do
|
createTable service (table, columns) = do
|
||||||
result <- beStatement (pvBackend service) ("CREATE TABLE " ++ table ++ " " ++ columns)
|
result <- beStatement (pvBackend service) ("CREATE TABLE " ++ table ++ " " ++ columns)
|
||||||
@@ -341,6 +329,7 @@ cell column row = case lookup column row of
|
|||||||
Just (VStr value) -> T.pack value
|
Just (VStr value) -> T.pack value
|
||||||
_ -> ""
|
_ -> ""
|
||||||
|
|
||||||
|
-- | 一行记录转成授权
|
||||||
grantOf :: Row -> Grant
|
grantOf :: Row -> Grant
|
||||||
grantOf row =
|
grantOf row =
|
||||||
Grant
|
Grant
|
||||||
@@ -349,19 +338,22 @@ grantOf row =
|
|||||||
, grantObject = T.toLower (T.strip (cell "object" row))
|
, grantObject = T.toLower (T.strip (cell "object" row))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- | 拼一条删授权的 SQL
|
||||||
deleteGrantSql :: Text -> Text -> Text -> String
|
deleteGrantSql :: Text -> Text -> Text -> String
|
||||||
deleteGrantSql role privilege object =
|
deleteGrantSql role privilege object =
|
||||||
"DELETE FROM " ++ grantsTable ++ " WHERE role = " ++ roleLiteral role
|
"DELETE FROM " ++ grantsTable ++ " WHERE role = " ++ roleLiteral role
|
||||||
++ " AND privilege = " ++ literal privilege ++ " AND object = " ++ literal object
|
++ " AND privilege = " ++ literal privilege ++ " AND object = " ++ literal object
|
||||||
|
|
||||||
|
-- | 拼一条插入的 SQL
|
||||||
insertSql :: String -> [String] -> [Text] -> String
|
insertSql :: String -> [String] -> [Text] -> String
|
||||||
insertSql table columns values =
|
insertSql table columns values =
|
||||||
"INSERT INTO " ++ table ++ " (" ++ intercalate ", " columns ++ ") VALUES (" ++ intercalate ", " (map literal values) ++ ")"
|
"INSERT INTO " ++ table ++ " (" ++ intercalate ", " columns ++ ") VALUES (" ++ intercalate ", " (map literal values) ++ ")"
|
||||||
|
|
||||||
-- | 授权里的 ALL 展开成四种具体权限(存具体权限,鉴权不做兜底)
|
-- | 把 ALL 展开成四种具体权限
|
||||||
expandPrivileges :: [Text] -> [Text]
|
expandPrivileges :: [Text] -> [Text]
|
||||||
expandPrivileges privileges = nub (concatMap expand (map (T.toLower . T.strip) privileges))
|
expandPrivileges privileges = nub (concatMap expand (map (T.toLower . T.strip) privileges))
|
||||||
where
|
where
|
||||||
|
-- | 展开一条权限名
|
||||||
expand "all" = ["select", "insert", "update", "delete"]
|
expand "all" = ["select", "insert", "update", "delete"]
|
||||||
expand other = [other]
|
expand other = [other]
|
||||||
|
|
||||||
@@ -374,23 +366,29 @@ each (item : rest) action = do
|
|||||||
Left err -> pure (Left err)
|
Left err -> pure (Left err)
|
||||||
Right () -> each rest action
|
Right () -> each rest action
|
||||||
|
|
||||||
|
-- | 角色名的 SQL 字面量
|
||||||
roleLiteral :: Text -> String
|
roleLiteral :: Text -> String
|
||||||
roleLiteral = literal . normalizeRole
|
roleLiteral = literal . normalizeRole
|
||||||
|
|
||||||
|
-- | 文本值的 SQL 字面量
|
||||||
literal :: Text -> String
|
literal :: Text -> String
|
||||||
literal = sqlLiteral . VStr . T.unpack
|
literal = sqlLiteral . VStr . T.unpack
|
||||||
|
|
||||||
|
-- | 角色名统一小写去空白
|
||||||
normalizeRole :: Text -> Text
|
normalizeRole :: Text -> Text
|
||||||
normalizeRole = T.toLower . T.strip
|
normalizeRole = T.toLower . T.strip
|
||||||
|
|
||||||
|
-- | 用户名统一小写去空白
|
||||||
normalizeUser :: Text -> Text
|
normalizeUser :: Text -> Text
|
||||||
normalizeUser = T.toLower . T.strip
|
normalizeUser = T.toLower . T.strip
|
||||||
|
|
||||||
forbidden :: PrivilegeError
|
forbidden :: PrivilegeError
|
||||||
forbidden = PrivilegeError "forbidden" "administrator required"
|
forbidden = PrivilegeError "forbidden" "administrator required"
|
||||||
|
|
||||||
|
-- | 未知角色的错误
|
||||||
unknownRole :: Text -> PrivilegeError
|
unknownRole :: Text -> PrivilegeError
|
||||||
unknownRole role = PrivilegeError "not_found" ("unknown role: " <> role)
|
unknownRole role = PrivilegeError "not_found" ("unknown role: " <> role)
|
||||||
|
|
||||||
|
-- | 存储错误转成权限错误
|
||||||
storageError :: String -> PrivilegeError
|
storageError :: String -> PrivilegeError
|
||||||
storageError message = PrivilegeError "storage_error" ("privilege storage: " <> T.pack message)
|
storageError message = PrivilegeError "storage_error" ("privilege storage: " <> T.pack message)
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
|
module ChuSQL.Server.Session
|
||||||
|
( QueryResult (..)
|
||||||
|
, Session
|
||||||
|
, SessionError (..)
|
||||||
|
, engineErrorCode
|
||||||
|
, newSession
|
||||||
|
, sessionUser
|
||||||
|
, sessionIsAdmin
|
||||||
|
, sessionDatabase
|
||||||
|
, isPlainIdentifier
|
||||||
|
, authenticateSession
|
||||||
|
, authenticateSessionCoded
|
||||||
|
, switchDatabase
|
||||||
|
, runStatement
|
||||||
|
, runStatementCoded
|
||||||
|
, catalog
|
||||||
|
, accounts
|
||||||
|
, roleViews
|
||||||
|
, databases
|
||||||
|
, policyOf
|
||||||
|
, reloadPolicy
|
||||||
|
, queryResultJson
|
||||||
|
) where
|
||||||
|
|
||||||
|
import ChuSQL.Core.Model (Value (..))
|
||||||
|
import ChuSQL.Core.Protocol (Account, QueryResult (..), queryResultJson)
|
||||||
|
import ChuSQL.Core.Engine.Storage.IPC (TableInfo (..))
|
||||||
|
import ChuSQL.Core.Engine.Syntax.AST (Statement (..))
|
||||||
|
import ChuSQL.Core.Engine.Syntax.Parser (parseStatement)
|
||||||
|
import ChuSQL.Interface.AccountTable (systemTableInfo)
|
||||||
|
import ChuSQL.Interface.Auth (Credential, defaultSessionPolicy, newSessionStore)
|
||||||
|
import ChuSQL.Server.Accounts
|
||||||
|
import ChuSQL.Server.Backend (Backend (..), StatementResult (..))
|
||||||
|
import ChuSQL.Server.Policy (configurePasswordPolicy)
|
||||||
|
import ChuSQL.Server.Privileges (PrivilegeCommand, PrivilegeError (..), Privileges, RoleView, authorize, filterTables, listRoleViews, newPrivileges, privilegeCommand, runPrivilegeCommand)
|
||||||
|
import Control.Exception (IOException, try)
|
||||||
|
import Data.Bifunctor (first)
|
||||||
|
import Data.Char (isAlpha, isAlphaNum)
|
||||||
|
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
|
||||||
|
import Data.Text (Text)
|
||||||
|
import qualified Data.Text as T
|
||||||
|
import Data.Time.Clock (getCurrentTime)
|
||||||
|
|
||||||
|
-- SQL 会话:Web、命令行与 TCP 服务器共用的账号、权限与语句执行入口。
|
||||||
|
|
||||||
|
-- | 带错误码的失败:码与 Web REST 的错误码同一个词汇表
|
||||||
|
data SessionError = SessionError
|
||||||
|
{ sessCode :: Text
|
||||||
|
, sessMessage :: Text
|
||||||
|
}
|
||||||
|
deriving (Eq, Show)
|
||||||
|
|
||||||
|
-- | 引擎报错翻成错误码
|
||||||
|
engineErrorCode :: String -> SessionError
|
||||||
|
engineErrorCode err
|
||||||
|
| "no database selected" `T.isInfixOf` message = SessionError "no_database" message
|
||||||
|
| "unknown table" `T.isInfixOf` message = SessionError "not_found" message
|
||||||
|
| otherwise = SessionError "query_error" message
|
||||||
|
where
|
||||||
|
message = T.pack err
|
||||||
|
|
||||||
|
data Session = Session
|
||||||
|
{ ssBackend :: Backend
|
||||||
|
, ssAccounts :: Accounts
|
||||||
|
, ssPrivileges :: Privileges
|
||||||
|
, ssCurrent :: IORef Text
|
||||||
|
, ssPrincipal :: IORef (Maybe Principal)
|
||||||
|
, ssSettingsFile :: FilePath
|
||||||
|
}
|
||||||
|
|
||||||
|
-- | 开一个会话:账号与角色服务、配置策略都和 Web 端一致
|
||||||
|
newSession :: Backend -> Credential -> FilePath -> IO Session
|
||||||
|
newSession backend credential settingsFile = do
|
||||||
|
sessions <- newSessionStore getCurrentTime defaultSessionPolicy
|
||||||
|
accountsService <- newAccounts backend sessions credential
|
||||||
|
privileges <- newPrivileges backend
|
||||||
|
configurePasswordPolicy accountsService settingsFile
|
||||||
|
-- 服务启动后只有系统库:没 USE 之前不预设任何工作库
|
||||||
|
current <- newIORef ""
|
||||||
|
principal <- newIORef Nothing
|
||||||
|
pure (Session backend accountsService privileges current principal settingsFile)
|
||||||
|
|
||||||
|
-- | 当前生效的口令策略(只有管理员能看)
|
||||||
|
policyOf :: Session -> IO (Either Text PasswordPolicy)
|
||||||
|
policyOf session = do
|
||||||
|
admin <- sessionIsAdmin session
|
||||||
|
if not admin
|
||||||
|
then pure (Left "administrator required")
|
||||||
|
else Right <$> accountsPolicy (ssAccounts session)
|
||||||
|
|
||||||
|
-- | 重读设置文件里的口令策略
|
||||||
|
reloadPolicy :: Session -> IO (Either Text PasswordPolicy)
|
||||||
|
reloadPolicy session = do
|
||||||
|
admin <- sessionIsAdmin session
|
||||||
|
if not admin
|
||||||
|
then pure (Left "administrator required")
|
||||||
|
else do
|
||||||
|
outcome <- try (configurePasswordPolicy (ssAccounts session) (ssSettingsFile session)) :: IO (Either IOException ())
|
||||||
|
case outcome of
|
||||||
|
Left err -> pure (Left (T.pack (show err)))
|
||||||
|
Right () -> policyOf session
|
||||||
|
|
||||||
|
-- | 当前登录的账号名
|
||||||
|
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 比配置凭据,普通账号查系统表
|
||||||
|
authenticateSession :: Session -> Text -> Text -> IO (Either Text ())
|
||||||
|
authenticateSession session user password =
|
||||||
|
fmap (first sessMessage) (authenticateSessionCoded session user password)
|
||||||
|
|
||||||
|
-- | 登录(带错误码)
|
||||||
|
authenticateSessionCoded :: Session -> Text -> Text -> IO (Either SessionError ())
|
||||||
|
authenticateSessionCoded session user password = do
|
||||||
|
result <- authenticate (ssAccounts session) user password
|
||||||
|
case result of
|
||||||
|
Left err -> pure (Left (accountErrorOf err))
|
||||||
|
Right token -> do
|
||||||
|
who <- currentPrincipal (ssAccounts session) token
|
||||||
|
case who of
|
||||||
|
Left err -> pure (Left (accountErrorOf err))
|
||||||
|
Right principal -> do
|
||||||
|
writeIORef (ssPrincipal session) (Just principal)
|
||||||
|
pure (Right ())
|
||||||
|
|
||||||
|
-- | 换库;系统库只有 root 能进
|
||||||
|
switchDatabase :: Session -> Text -> IO (Either Text ())
|
||||||
|
switchDatabase session name =
|
||||||
|
fmap (first sessMessage) (switchDatabaseCoded session name)
|
||||||
|
|
||||||
|
-- | 换库(带错误码)
|
||||||
|
switchDatabaseCoded :: Session -> Text -> IO (Either SessionError ())
|
||||||
|
switchDatabaseCoded session rawName
|
||||||
|
| not (isPlainIdentifier name) = pure (Left (SessionError "bad_request" ("not a plain database name: " <> name)))
|
||||||
|
| name == "system" = do
|
||||||
|
admin <- sessionIsAdmin session
|
||||||
|
if admin then go else pure (Left (SessionError "forbidden" "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 (engineErrorCode err))
|
||||||
|
Right _ -> writeIORef (ssCurrent session) name >> pure (Right ())
|
||||||
|
|
||||||
|
-- | 跑一条语句:USE、账号与角色语句分流,其余交引擎
|
||||||
|
runStatement :: Session -> Text -> IO (Either Text QueryResult)
|
||||||
|
runStatement session sql = fmap (first sessMessage) (runStatementCoded session sql)
|
||||||
|
|
||||||
|
-- | 跑一条语句(带错误码)
|
||||||
|
runStatementCoded :: Session -> Text -> IO (Either SessionError QueryResult)
|
||||||
|
runStatementCoded session sql = case parseStatement (T.unpack sql) of
|
||||||
|
Right (UseDatabase name) -> do
|
||||||
|
switched <- switchDatabaseCoded 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 SessionError ())
|
||||||
|
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 . privilegeErrorOf) (const (Right ())) result)
|
||||||
|
_ -> pure (Right ())
|
||||||
|
|
||||||
|
-- | 落到引擎/存储层的一条语句
|
||||||
|
runPlain :: Session -> Text -> IO (Either SessionError QueryResult)
|
||||||
|
runPlain session sql = do
|
||||||
|
backend <- currentBackend session
|
||||||
|
result <- beStatement backend (T.unpack sql)
|
||||||
|
pure (either (Left . engineErrorCode) (Right . resultOf) result)
|
||||||
|
|
||||||
|
-- | 数据字典;普通身份只看到有 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)
|
||||||
|
visible <- 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)
|
||||||
|
case visible of
|
||||||
|
Left message -> pure (Left message)
|
||||||
|
Right tables
|
||||||
|
| current == "system" -> Right <$> withAccountInfo session tables
|
||||||
|
| otherwise -> pure (Right tables)
|
||||||
|
|
||||||
|
-- | 系统库里补上账号表
|
||||||
|
withAccountInfo :: Session -> [TableInfo] -> IO [TableInfo]
|
||||||
|
withAccountInfo session tables = do
|
||||||
|
admin <- sessionIsAdmin session
|
||||||
|
if not admin
|
||||||
|
then pure tables
|
||||||
|
else do
|
||||||
|
result <- listAccounts (ssAccounts session)
|
||||||
|
pure $ case result of
|
||||||
|
Left _ -> tables
|
||||||
|
Right rows -> systemTableInfo{tiRows = length rows} : tables
|
||||||
|
|
||||||
|
-- | 库清单(\l 用)
|
||||||
|
databases :: Session -> IO (Either Text [Text])
|
||||||
|
databases session = do
|
||||||
|
result <- beDatabases (ssBackend session)
|
||||||
|
pure (either (Left . T.pack) (Right . map T.pack) result)
|
||||||
|
|
||||||
|
-- | 角色总览,只给管理员
|
||||||
|
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)
|
||||||
|
|
||||||
|
-- | 账号清单(\du 与账号管理页用);只给管理员
|
||||||
|
accounts :: Session -> IO (Either Text [Account])
|
||||||
|
accounts session = do
|
||||||
|
admin <- sessionIsAdmin session
|
||||||
|
if not admin
|
||||||
|
then pure (Left "administrator required")
|
||||||
|
else do
|
||||||
|
result <- listAccounts (ssAccounts session)
|
||||||
|
pure (either (Left . T.pack . show) 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 SessionError QueryResult)
|
||||||
|
runAccount session command = do
|
||||||
|
who <- readIORef (ssPrincipal session)
|
||||||
|
case who of
|
||||||
|
Nothing -> pure (Left (SessionError "unauthorized" "sign in first"))
|
||||||
|
Just principal -> do
|
||||||
|
result <- runAccountCommand (ssAccounts session) principal command
|
||||||
|
pure (either (Left . accountErrorOf) (const (Right (emptyResult Nothing))) result)
|
||||||
|
|
||||||
|
-- | 角色语句走权限服务,普通账号拒绝
|
||||||
|
runPrivilege :: Session -> PrivilegeCommand -> IO (Either SessionError QueryResult)
|
||||||
|
runPrivilege session command = do
|
||||||
|
who <- readIORef (ssPrincipal session)
|
||||||
|
case who of
|
||||||
|
Nothing -> pure (Left (SessionError "unauthorized" "sign in first"))
|
||||||
|
Just principal -> do
|
||||||
|
database <- readIORef (ssCurrent session)
|
||||||
|
result <- runPrivilegeCommand (ssPrivileges session) principal database command
|
||||||
|
pure (either (Left . privilegeErrorOf) (const (Right (emptyResult Nothing))) result)
|
||||||
|
|
||||||
|
-- | 账号错误转成会话错误
|
||||||
|
accountErrorOf :: AccountError -> SessionError
|
||||||
|
accountErrorOf (AccountError code message) = SessionError code message
|
||||||
|
|
||||||
|
-- | 权限错误转成会话错误
|
||||||
|
privilegeErrorOf :: PrivilegeError -> SessionError
|
||||||
|
privilegeErrorOf (PrivilegeError code message) = SessionError code 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)
|
||||||
|
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
|
module ChuSQL.Server.TCP (
|
||||||
|
ServerConfig (..),
|
||||||
|
defaultServerConfig,
|
||||||
|
loadServerConfigAt,
|
||||||
|
ServerEnv (..),
|
||||||
|
newServerEnv,
|
||||||
|
ServerHandle (..),
|
||||||
|
startServer,
|
||||||
|
runServer,
|
||||||
|
) where
|
||||||
|
|
||||||
|
import ChuSQL.Interface.Auth (Credential)
|
||||||
|
import ChuSQL.Interface.Config (ServerConfig (..), defaultServerConfig, loadServerConfigAt)
|
||||||
|
import ChuSQL.Interface.Policy (PasswordPolicy (..))
|
||||||
|
import ChuSQL.Interface.Protocol (ClientRequest (..), ServerResponse (..), decodeRequest, encodeResponse, protocolVersion)
|
||||||
|
import ChuSQL.Interface.RateLimit (RateLimiter, rateLimitBlock, rateLimitClear, rateLimitRecord)
|
||||||
|
import ChuSQL.Server.Backend (Backend (..))
|
||||||
|
import ChuSQL.Server.Session (QueryResult (..), Session, SessionError (..), accounts, authenticateSessionCoded, catalog, databases, newSession, policyOf, reloadPolicy, roleViews, runStatementCoded, sessionIsAdmin, sessionUser)
|
||||||
|
import Control.Concurrent (forkIO)
|
||||||
|
import Control.Concurrent.MVar (MVar, modifyMVar_, newMVar, readMVar)
|
||||||
|
import Control.Exception (IOException, try)
|
||||||
|
import Control.Monad (void)
|
||||||
|
import qualified Data.ByteString.Char8 as BSC
|
||||||
|
import qualified Data.ByteString.Lazy.Char8 as BLC
|
||||||
|
import Data.Maybe (fromMaybe)
|
||||||
|
import Data.Text (Text)
|
||||||
|
import qualified Data.Text as T
|
||||||
|
import Network.Socket (
|
||||||
|
AddrInfo (..),
|
||||||
|
AddrInfoFlag (AI_PASSIVE),
|
||||||
|
SockAddr (SockAddrInet, SockAddrInet6),
|
||||||
|
Socket,
|
||||||
|
SocketOption (ReuseAddr),
|
||||||
|
SocketType (Stream),
|
||||||
|
accept,
|
||||||
|
addrAddress,
|
||||||
|
addrFamily,
|
||||||
|
bind,
|
||||||
|
close,
|
||||||
|
defaultHints,
|
||||||
|
defaultProtocol,
|
||||||
|
getAddrInfo,
|
||||||
|
getSocketName,
|
||||||
|
listen,
|
||||||
|
setSocketOption,
|
||||||
|
socket,
|
||||||
|
socketToHandle,
|
||||||
|
)
|
||||||
|
import System.IO (BufferMode (LineBuffering), IOMode (ReadWriteMode), hClose, hFlush, hPutStrLn, hSetBuffering, hSetNewlineMode, noNewlineTranslation, stderr)
|
||||||
|
import System.IO.Error (isEOFError)
|
||||||
|
|
||||||
|
-- TCP 服务器:监听端口,一连接一会话,按一行一 JSON 收发协议消息。
|
||||||
|
|
||||||
|
-- 服务器
|
||||||
|
|
||||||
|
data ServerEnv = ServerEnv
|
||||||
|
{ srvBackend :: Backend
|
||||||
|
, srvCredential :: Credential
|
||||||
|
, srvSettingsFile :: FilePath
|
||||||
|
, srvConfig :: ServerConfig
|
||||||
|
, srvLimiter :: RateLimiter
|
||||||
|
}
|
||||||
|
|
||||||
|
-- | 组装服务器环境:每个连接再各开一个会话
|
||||||
|
newServerEnv :: Backend -> Credential -> FilePath -> ServerConfig -> RateLimiter -> IO ServerEnv
|
||||||
|
newServerEnv backend credential settingsFile config limiter =
|
||||||
|
pure (ServerEnv backend credential settingsFile config limiter)
|
||||||
|
|
||||||
|
data ServerHandle = ServerHandle
|
||||||
|
{ shPort :: Int
|
||||||
|
, shStop :: IO ()
|
||||||
|
}
|
||||||
|
|
||||||
|
-- | 起监听并在线程里收连接(测试与嵌入用)
|
||||||
|
startServer :: ServerEnv -> IO ServerHandle
|
||||||
|
startServer env = do
|
||||||
|
sock <- listenSocket (scHost (srvConfig env)) (scPort (srvConfig env))
|
||||||
|
port <- actualPort sock
|
||||||
|
stopped <- newMVar False
|
||||||
|
_ <- forkIO (acceptLoop env sock stopped)
|
||||||
|
pure (ServerHandle port (stopListening sock stopped))
|
||||||
|
|
||||||
|
-- | 在调用线程里收连接(可执行文件用);绑好端口先回调一次
|
||||||
|
runServer :: ServerEnv -> (Int -> IO ()) -> IO ()
|
||||||
|
runServer env announce = do
|
||||||
|
sock <- listenSocket (scHost (srvConfig env)) (scPort (srvConfig env))
|
||||||
|
port <- actualPort sock
|
||||||
|
announce port
|
||||||
|
stopped <- newMVar False
|
||||||
|
acceptLoop env sock stopped
|
||||||
|
|
||||||
|
-- | 置停止标记并关掉监听套接字
|
||||||
|
stopListening :: Socket -> MVar Bool -> IO ()
|
||||||
|
stopListening sock stopped = do
|
||||||
|
modifyMVar_ stopped (const (pure True))
|
||||||
|
void (try (close sock) :: IO (Either IOException ()))
|
||||||
|
|
||||||
|
-- | 建监听套接字;端口 0 表示由系统分配
|
||||||
|
listenSocket :: String -> Int -> IO Socket
|
||||||
|
listenSocket host port = do
|
||||||
|
let hints = defaultHints{addrSocketType = Stream, addrFlags = [AI_PASSIVE]}
|
||||||
|
node = if null host then Nothing else Just host
|
||||||
|
infos <- getAddrInfo (Just hints) node (Just (show port))
|
||||||
|
case infos of
|
||||||
|
[] -> ioError (userError ("cannot resolve listen address: " ++ host))
|
||||||
|
(info : _) -> do
|
||||||
|
sock <- socket (addrFamily info) Stream defaultProtocol
|
||||||
|
setSocketOption sock ReuseAddr 1
|
||||||
|
bind sock (addrAddress info)
|
||||||
|
listen sock 128
|
||||||
|
pure sock
|
||||||
|
|
||||||
|
-- | 系统分配端口后读回真正的端口
|
||||||
|
actualPort :: Socket -> IO Int
|
||||||
|
actualPort sock = do
|
||||||
|
address <- getSocketName sock
|
||||||
|
pure $ case address of
|
||||||
|
SockAddrInet port _ -> fromIntegral port
|
||||||
|
SockAddrInet6 port _ _ _ -> fromIntegral port
|
||||||
|
_ -> 0
|
||||||
|
|
||||||
|
-- | 循环 accept,把每个连接交给独立线程
|
||||||
|
acceptLoop :: ServerEnv -> Socket -> MVar Bool -> IO ()
|
||||||
|
acceptLoop env sock stopped = loop
|
||||||
|
where
|
||||||
|
-- | 等下一个连接
|
||||||
|
loop = do
|
||||||
|
stop <- readMVar stopped
|
||||||
|
if stop
|
||||||
|
then pure ()
|
||||||
|
else do
|
||||||
|
accepted <- try (accept sock) :: IO (Either IOException (Socket, SockAddr))
|
||||||
|
case accepted of
|
||||||
|
Left err -> do
|
||||||
|
stopping <- readMVar stopped
|
||||||
|
if stopping then pure () else hPutStrLn stderr ("accept failed: " ++ show err)
|
||||||
|
Right (conn, _) -> do
|
||||||
|
void (forkIO (reportConnection env conn))
|
||||||
|
loop
|
||||||
|
|
||||||
|
-- | 连接线程:异常写到 stderr
|
||||||
|
reportConnection :: ServerEnv -> Socket -> IO ()
|
||||||
|
reportConnection env conn = do
|
||||||
|
outcome <- try (serveConnection env conn) :: IO (Either IOException ())
|
||||||
|
case outcome of
|
||||||
|
Left err -> hPutStrLn stderr ("connection failed: " ++ show err)
|
||||||
|
Right () -> pure ()
|
||||||
|
|
||||||
|
-- | 一个连接一个会话:一行一条 JSON,读到 EOF 就收工
|
||||||
|
serveConnection :: ServerEnv -> Socket -> IO ()
|
||||||
|
serveConnection env conn = do
|
||||||
|
h <- socketToHandle conn ReadWriteMode
|
||||||
|
hSetBuffering h LineBuffering
|
||||||
|
hSetNewlineMode h noNewlineTranslation
|
||||||
|
session <- newSession (srvBackend env) (srvCredential env) (srvSettingsFile env)
|
||||||
|
outcome <- try (loop h session) :: IO (Either IOException ())
|
||||||
|
case outcome of
|
||||||
|
Left err | not (isEOFError err) -> hPutStrLn stderr ("connection error: " ++ show err)
|
||||||
|
_ -> pure ()
|
||||||
|
void (try (hClose h) :: IO (Either IOException ()))
|
||||||
|
where
|
||||||
|
-- | 逐行读请求并应答
|
||||||
|
loop h session = do
|
||||||
|
line <- BSC.hGetLine h
|
||||||
|
let cleaned = BSC.dropWhileEnd (== '\r') line
|
||||||
|
if BSC.length cleaned > scMaxMessage (srvConfig env)
|
||||||
|
then send h (RespError "too_large" "request line too large") >> loop h session
|
||||||
|
else case decodeRequest cleaned of
|
||||||
|
Left message -> send h (RespError "bad_request" message) >> loop h session
|
||||||
|
Right request -> do
|
||||||
|
(response, keepGoing) <- dispatch env session request
|
||||||
|
send h response
|
||||||
|
if keepGoing then loop h session else pure ()
|
||||||
|
-- | 发一条响应并冲缓冲
|
||||||
|
send h response = do
|
||||||
|
BLC.hPutStr h (encodeResponse response)
|
||||||
|
BLC.hPutStr h "\n"
|
||||||
|
hFlush h
|
||||||
|
|
||||||
|
-- | 一条请求一条响应,quit 后停连接
|
||||||
|
dispatch :: ServerEnv -> Session -> ClientRequest -> IO (ServerResponse, Bool)
|
||||||
|
dispatch env session request = case request of
|
||||||
|
ReqHello version
|
||||||
|
| version == protocolVersion -> pure (RespHello protocolVersion, True)
|
||||||
|
| otherwise -> pure (RespError "bad_request" ("unsupported protocol version: " <> tshow version), True)
|
||||||
|
ReqLogin user password -> do
|
||||||
|
response <- login env session user password
|
||||||
|
pure (response, True)
|
||||||
|
ReqQuery sql -> do
|
||||||
|
who <- sessionUser session
|
||||||
|
response <- case who of
|
||||||
|
Nothing -> pure (RespError "unauthorized" "sign in first")
|
||||||
|
Just _
|
||||||
|
| T.null (T.strip sql) -> pure (RespError "bad_request" "sql must not be empty")
|
||||||
|
| otherwise -> do
|
||||||
|
result <- runStatementCoded session sql
|
||||||
|
pure $ case result of
|
||||||
|
Left err -> RespError (sessCode err) (sessMessage err)
|
||||||
|
Right queryResult -> RespResult (trimResult (scMaxRows (srvConfig env)) queryResult)
|
||||||
|
pure (response, True)
|
||||||
|
ReqStorage payload -> do
|
||||||
|
who <- sessionUser session
|
||||||
|
response <- case who of
|
||||||
|
Nothing -> pure (RespError "unauthorized" "sign in first")
|
||||||
|
Just _ -> do
|
||||||
|
admin <- sessionIsAdmin session
|
||||||
|
if not admin
|
||||||
|
then pure (RespError "forbidden" "administrator required")
|
||||||
|
else do
|
||||||
|
result <- beStorage (srvBackend env) payload
|
||||||
|
pure $ case result of
|
||||||
|
Left err -> RespError "storage_error" (T.pack err)
|
||||||
|
Right value -> RespStorage value
|
||||||
|
pure (response, True)
|
||||||
|
ReqCatalog -> do
|
||||||
|
response <- overSession session (catalog session) RespCatalog
|
||||||
|
pure (response, True)
|
||||||
|
ReqDatabases -> do
|
||||||
|
response <- overSession session (databases session) RespDatabases
|
||||||
|
pure (response, True)
|
||||||
|
ReqRoles -> do
|
||||||
|
response <- overSession session (roleViews session) RespRoles
|
||||||
|
pure (response, True)
|
||||||
|
ReqAccounts -> do
|
||||||
|
response <- overSession session (accounts session) RespAccounts
|
||||||
|
pure (response, True)
|
||||||
|
ReqPolicy -> do
|
||||||
|
response <- overSession session (policyOf session) policyResponse
|
||||||
|
pure (response, True)
|
||||||
|
ReqReloadPolicy -> do
|
||||||
|
response <- overSession session (reloadPolicy session) policyResponse
|
||||||
|
pure (response, True)
|
||||||
|
ReqPing -> pure (RespPong, True)
|
||||||
|
ReqQuit -> pure (RespBye, False)
|
||||||
|
|
||||||
|
-- | 口令策略在线上只有两个数字:最短长度与至少几类字符
|
||||||
|
policyResponse :: PasswordPolicy -> ServerResponse
|
||||||
|
policyResponse (PasswordPolicy minLength classes) = RespPolicy minLength classes
|
||||||
|
|
||||||
|
-- | 会话方法统一口径:没登录先挡,其余失败按码表落到响应
|
||||||
|
overSession :: Session -> IO (Either Text a) -> (a -> ServerResponse) -> IO ServerResponse
|
||||||
|
overSession session action wrap = do
|
||||||
|
who <- sessionUser session
|
||||||
|
case who of
|
||||||
|
Nothing -> pure (RespError "unauthorized" "sign in first")
|
||||||
|
Just _ -> do
|
||||||
|
result <- action
|
||||||
|
pure $ case result of
|
||||||
|
Left message -> RespError (sessionCode message) message
|
||||||
|
Right value -> wrap value
|
||||||
|
|
||||||
|
-- | 会话层的失败文案归一到协议错误码
|
||||||
|
sessionCode :: Text -> Text
|
||||||
|
sessionCode message
|
||||||
|
| message == "no database selected" = "no_database"
|
||||||
|
| message == "administrator required" = "forbidden"
|
||||||
|
| otherwise = "query_error"
|
||||||
|
|
||||||
|
-- | 登录:按账号名限流,成功清账
|
||||||
|
login :: ServerEnv -> Session -> Text -> Text -> IO ServerResponse
|
||||||
|
login env session user password = do
|
||||||
|
let key = T.toLower (T.strip user)
|
||||||
|
blocked <- rateLimitBlock (srvLimiter env) key
|
||||||
|
case blocked of
|
||||||
|
Just seconds -> pure (RespError "too_many_attempts" ("too many failed logins, retry in " <> tshow (ceiling seconds :: Integer) <> "s"))
|
||||||
|
Nothing -> do
|
||||||
|
result <- authenticateSessionCoded session key password
|
||||||
|
case result of
|
||||||
|
Left err -> do
|
||||||
|
rateLimitRecord (srvLimiter env) key
|
||||||
|
pure (RespError (sessCode err) (sessMessage err))
|
||||||
|
Right () -> do
|
||||||
|
rateLimitClear (srvLimiter env) key
|
||||||
|
who <- sessionUser session
|
||||||
|
admin <- sessionIsAdmin session
|
||||||
|
pure (RespLogin (fromMaybe key who) admin)
|
||||||
|
|
||||||
|
-- | 按上限截行,总行数保持不变
|
||||||
|
trimResult :: Int -> QueryResult -> QueryResult
|
||||||
|
trimResult limit result
|
||||||
|
| length (qrRows result) > limit = result{qrRows = take limit (qrRows result), qrTruncated = True}
|
||||||
|
| otherwise = result
|
||||||
|
|
||||||
|
-- | show 成 Text
|
||||||
|
tshow :: Show a => a -> Text
|
||||||
|
tshow = T.pack . show
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
resolver: lts-24.59
|
||||||
|
|
||||||
|
extra-lib-dirs:
|
||||||
|
- ../chusql-core/storage/target/release
|
||||||
|
|
||||||
|
packages:
|
||||||
|
- .
|
||||||
|
- ../chusql-core/engine
|
||||||
|
- ../chusql-core/model
|
||||||
|
- ../chusql-interface
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# This file was autogenerated by Stack.
|
||||||
|
# You should not edit this file by hand.
|
||||||
|
# For more information, please see the documentation at:
|
||||||
|
# https://docs.haskellstack.org/en/stable/topics/lock_files
|
||||||
|
|
||||||
|
packages: []
|
||||||
|
snapshots:
|
||||||
|
- completed:
|
||||||
|
sha256: 0f728e30e843d7f00460ad56858b995a3b227fca683659c7df9f2fc3988a3ce1
|
||||||
|
size: 732465
|
||||||
|
url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/24/59.yaml
|
||||||
|
original: lts-24.59
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
{-# LANGUAGE PatternSynonyms #-}
|
||||||
|
|
||||||
|
module Main (main) where
|
||||||
|
|
||||||
|
import ChuSQL.Core.Model (Database, Table (..), Value (..), pattern TInt, pattern TStr)
|
||||||
|
import ChuSQL.Interface.Auth (Credential (..), hashPasswordWith)
|
||||||
|
import ChuSQL.Interface.Protocol (ClientRequest (..), ServerResponse (..), decodeRequest, encodeResponse)
|
||||||
|
import ChuSQL.Interface.RateLimit (newRateLimiter)
|
||||||
|
import ChuSQL.Server.Backend (memoryBackend)
|
||||||
|
import ChuSQL.Server.Session (QueryResult (..))
|
||||||
|
import ChuSQL.Server.TCP (
|
||||||
|
ServerConfig (..),
|
||||||
|
ServerEnv,
|
||||||
|
ServerHandle (..),
|
||||||
|
defaultServerConfig,
|
||||||
|
loadServerConfigAt,
|
||||||
|
newServerEnv,
|
||||||
|
startServer,
|
||||||
|
)
|
||||||
|
import Control.Concurrent (threadDelay)
|
||||||
|
import Control.Concurrent.MVar (newMVar)
|
||||||
|
import Control.Exception (IOException, bracket, finally, try)
|
||||||
|
import Control.Monad (void)
|
||||||
|
import Data.Aeson (decode)
|
||||||
|
import qualified Data.Aeson as A
|
||||||
|
import qualified Data.Aeson.Key as K
|
||||||
|
import qualified Data.Aeson.KeyMap as KM
|
||||||
|
import qualified Data.ByteString as BS
|
||||||
|
import qualified Data.ByteString.Char8 as BSC
|
||||||
|
import qualified Data.ByteString.Lazy as BL
|
||||||
|
import Data.Maybe (fromMaybe)
|
||||||
|
import Data.Text (Text)
|
||||||
|
import qualified Data.Text as T
|
||||||
|
import qualified Data.Vector as V
|
||||||
|
import Data.Time.Clock (getCurrentTime)
|
||||||
|
import Network.Socket (
|
||||||
|
AddrInfo (..),
|
||||||
|
SocketType (Stream),
|
||||||
|
addrAddress,
|
||||||
|
addrFamily,
|
||||||
|
connect,
|
||||||
|
defaultHints,
|
||||||
|
defaultProtocol,
|
||||||
|
getAddrInfo,
|
||||||
|
socket,
|
||||||
|
socketToHandle,
|
||||||
|
withSocketsDo,
|
||||||
|
)
|
||||||
|
import System.Directory (doesFileExist, getTemporaryDirectory, removeFile)
|
||||||
|
import System.FilePath ((</>))
|
||||||
|
import System.IO (
|
||||||
|
BufferMode (LineBuffering),
|
||||||
|
Handle,
|
||||||
|
IOMode (ReadWriteMode),
|
||||||
|
hClose,
|
||||||
|
hFlush,
|
||||||
|
hIsEOF,
|
||||||
|
hSetBuffering,
|
||||||
|
hSetNewlineMode,
|
||||||
|
noNewlineTranslation,
|
||||||
|
)
|
||||||
|
import System.Timeout (timeout)
|
||||||
|
import Test.Hspec
|
||||||
|
|
||||||
|
-- TCP 服务器测试:协议编解码、配置读取与真连服务器的端到端用例。
|
||||||
|
|
||||||
|
-- | 测试入口
|
||||||
|
main :: IO ()
|
||||||
|
main = hspec spec
|
||||||
|
|
||||||
|
-- | 全部用例
|
||||||
|
spec :: Spec
|
||||||
|
spec = do
|
||||||
|
tcpSpec
|
||||||
|
tcpServerSpec
|
||||||
|
|
||||||
|
-- 夹具:内存后端装在 MVar 里,跟 Web 测试同一套表
|
||||||
|
|
||||||
|
-- | 夹具库名
|
||||||
|
testDatabaseName :: String
|
||||||
|
testDatabaseName = "test"
|
||||||
|
|
||||||
|
-- | users 5 行 + orders 1 行
|
||||||
|
testDb :: Database
|
||||||
|
testDb =
|
||||||
|
[ ( "users"
|
||||||
|
, Table
|
||||||
|
"users"
|
||||||
|
[("id", TInt), ("name", TStr), ("age", TInt)]
|
||||||
|
[ [("id", VInt i), ("name", VStr ("user" ++ show i)), ("age", VInt (20 + i))]
|
||||||
|
| i <- [1 .. 5]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
, ( "orders"
|
||||||
|
, Table
|
||||||
|
"orders"
|
||||||
|
[("id", TInt), ("user_id", TInt)]
|
||||||
|
[[("id", VInt 1), ("user_id", VInt 1)]]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
-- | 测试账号:迭代次数压低(跑得快),口令 `s3cret`
|
||||||
|
testCredential :: Credential
|
||||||
|
testCredential = Credential "admin" (hashPasswordWith 1000 (BS.replicate 16 7) "s3cret")
|
||||||
|
|
||||||
|
-- 小工具
|
||||||
|
|
||||||
|
-- | 取字段
|
||||||
|
field :: Text -> A.Value -> Maybe A.Value
|
||||||
|
field name (A.Object o) = KM.lookup (K.fromText name) o
|
||||||
|
field _ _ = Nothing
|
||||||
|
|
||||||
|
-- | 取字段(没有给 Null)
|
||||||
|
at :: Text -> A.Value -> A.Value
|
||||||
|
at name value = fromMaybe A.Null (field name value)
|
||||||
|
|
||||||
|
-- | 当数组看
|
||||||
|
items :: A.Value -> [A.Value]
|
||||||
|
items (A.Array xs) = V.toList xs
|
||||||
|
items _ = []
|
||||||
|
|
||||||
|
-- | 当文本看
|
||||||
|
asText :: A.Value -> Text
|
||||||
|
asText (A.String t) = t
|
||||||
|
asText _ = ""
|
||||||
|
|
||||||
|
-- | 当整数看
|
||||||
|
asInt :: A.Value -> Int
|
||||||
|
asInt (A.Number n) = round n
|
||||||
|
asInt _ = -1
|
||||||
|
|
||||||
|
-- | JSON 当布尔看
|
||||||
|
asBool :: A.Value -> Bool
|
||||||
|
asBool (A.Bool b) = b
|
||||||
|
asBool _ = False
|
||||||
|
|
||||||
|
-- | 第 i 个元素(越界给 Nothing,不用 `!!`)
|
||||||
|
nth :: Int -> [a] -> Maybe a
|
||||||
|
nth i xs = case drop i xs of
|
||||||
|
(x : _) -> Just x
|
||||||
|
[] -> Nothing
|
||||||
|
|
||||||
|
-- | 临时 TOML
|
||||||
|
tcpConfigPath :: String -> IO FilePath
|
||||||
|
tcpConfigPath name = do
|
||||||
|
tmp <- getTemporaryDirectory
|
||||||
|
pure (tmp </> ("chusql-server-test-" ++ name ++ ".toml"))
|
||||||
|
|
||||||
|
-- | 临时设置文件(新会话读口令策略用)
|
||||||
|
tempSettingsPath :: String -> IO FilePath
|
||||||
|
tempSettingsPath name = do
|
||||||
|
tmp <- getTemporaryDirectory
|
||||||
|
pure (tmp </> ("chusql-server-test-" ++ name ++ "-settings.json"))
|
||||||
|
|
||||||
|
-- | 文件不存在也算成功
|
||||||
|
removeIfExists :: FilePath -> IO ()
|
||||||
|
removeIfExists path = do
|
||||||
|
there <- doesFileExist path
|
||||||
|
if there then void (try (removeFile path) :: IO (Either IOException ())) else pure ()
|
||||||
|
|
||||||
|
-- | 起一台内存后端的测试服务器
|
||||||
|
withTcpServer :: ServerConfig -> (ServerEnv -> ServerHandle -> IO a) -> IO a
|
||||||
|
withTcpServer config body = do
|
||||||
|
db <- newMVar testDb
|
||||||
|
limiter <- newRateLimiter getCurrentTime 50 300
|
||||||
|
path <- tempSettingsPath "tcp-server"
|
||||||
|
removeIfExists path
|
||||||
|
env <- newServerEnv (memoryBackend testDatabaseName db) testCredential path config{scPort = 0} limiter
|
||||||
|
handle <- startServer env
|
||||||
|
body env handle `finally` shStop handle
|
||||||
|
|
||||||
|
-- | 连上服务器并配成行缓冲句柄
|
||||||
|
withConnection :: ServerHandle -> (Handle -> IO a) -> IO a
|
||||||
|
withConnection handle body = bracket open closeQuietly body
|
||||||
|
where
|
||||||
|
-- | 关连接,忽略异常
|
||||||
|
closeQuietly h = do
|
||||||
|
_ <- try (hClose h) :: IO (Either IOException ())
|
||||||
|
pure ()
|
||||||
|
-- | 建连接并配成行缓冲句柄
|
||||||
|
open = withSocketsDo $ do
|
||||||
|
infos <- getAddrInfo (Just defaultHints{addrSocketType = Stream}) (Just "127.0.0.1") (Just (show (shPort handle)))
|
||||||
|
info <- case infos of
|
||||||
|
(i : _) -> pure i
|
||||||
|
[] -> ioError (userError "cannot resolve the test server address")
|
||||||
|
sock <- socket (addrFamily info) Stream defaultProtocol
|
||||||
|
connect sock (addrAddress info)
|
||||||
|
h <- socketToHandle sock ReadWriteMode
|
||||||
|
hSetBuffering h LineBuffering
|
||||||
|
hSetNewlineMode h noNewlineTranslation
|
||||||
|
pure h
|
||||||
|
|
||||||
|
-- | 发一行收一行;服务器不回话就判失败,别把用例挂死
|
||||||
|
tcpTalk :: Handle -> BS.ByteString -> IO A.Value
|
||||||
|
tcpTalk h raw = do
|
||||||
|
BSC.hPutStr h raw
|
||||||
|
BSC.hPutStr h "\n"
|
||||||
|
hFlush h
|
||||||
|
line <- timeout (5 * 1000000) (BSC.hGetLine h)
|
||||||
|
case line of
|
||||||
|
Just got -> pure (fromMaybe A.Null (decode (BL.fromStrict got)))
|
||||||
|
Nothing -> do
|
||||||
|
expectationFailure "the TCP server did not answer in time"
|
||||||
|
pure A.Null
|
||||||
|
|
||||||
|
-- | 登录一行
|
||||||
|
tcpLogin :: Handle -> Text -> Text -> IO A.Value
|
||||||
|
tcpLogin h user password =
|
||||||
|
tcpTalk h (BSC.pack ("{\"method\":\"login\",\"user\":\"" ++ T.unpack user ++ "\",\"password\":\"" ++ T.unpack password ++ "\"}"))
|
||||||
|
|
||||||
|
-- | 查询一行
|
||||||
|
tcpQuery :: Handle -> Text -> IO A.Value
|
||||||
|
tcpQuery h sql = tcpTalk h (BSC.pack ("{\"method\":\"query\",\"sql\":\"" ++ T.unpack sql ++ "\"}"))
|
||||||
|
|
||||||
|
-- | 断言对端已经收工(读到 EOF)
|
||||||
|
tcpClosed :: Handle -> IO ()
|
||||||
|
tcpClosed h = do
|
||||||
|
done <- timeout (5 * 1000000) (hIsEOF h)
|
||||||
|
done `shouldBe` Just True
|
||||||
|
|
||||||
|
-- 用例
|
||||||
|
|
||||||
|
-- | 协议与配置的用例
|
||||||
|
tcpSpec :: Spec
|
||||||
|
tcpSpec = do
|
||||||
|
describe "TCP protocol: decoding a request line" $ do
|
||||||
|
it "hello without a protocol number takes the current version" $
|
||||||
|
decodeRequest "{\"method\":\"hello\"}" `shouldBe` Right (ReqHello 1)
|
||||||
|
it "hello keeps the protocol number the client asked for" $
|
||||||
|
decodeRequest "{\"method\":\"hello\",\"protocol\":2}" `shouldBe` Right (ReqHello 2)
|
||||||
|
it "method names are case-insensitive" $
|
||||||
|
decodeRequest "{\"method\":\"PING\"}" `shouldBe` Right ReqPing
|
||||||
|
it "unknown extra fields are ignored" $
|
||||||
|
decodeRequest "{\"method\":\"quit\",\"line\":7}" `shouldBe` Right ReqQuit
|
||||||
|
it "login without a password is refused" $
|
||||||
|
decodeRequest "{\"method\":\"login\",\"user\":\"admin\"}" `shouldBe` Left "login needs both user and password"
|
||||||
|
it "query without sql is refused" $
|
||||||
|
decodeRequest "{\"method\":\"query\"}" `shouldBe` Left "query needs sql"
|
||||||
|
it "an unknown method is named in the error" $
|
||||||
|
decodeRequest "{\"method\":\"frobnicate\"}" `shouldBe` Left "unknown method: frobnicate"
|
||||||
|
it "a line that is not JSON is a bad request, not a crash" $
|
||||||
|
decodeRequest "not json" `shouldBe` Left "request is not valid JSON"
|
||||||
|
|
||||||
|
describe "TCP protocol: encoding a response" $ do
|
||||||
|
it "hello carries the protocol version and the server name" $ do
|
||||||
|
let value = fromMaybe A.Null (decode (encodeResponse (RespHello 1)))
|
||||||
|
asText (at "status" value) `shouldBe` "hello"
|
||||||
|
asText (at "server" value) `shouldBe` "chusql-server"
|
||||||
|
asInt (at "protocol" value) `shouldBe` 1
|
||||||
|
it "login reports the account and whether it is the administrator" $ do
|
||||||
|
let value = fromMaybe A.Null (decode (encodeResponse (RespLogin "admin" True)))
|
||||||
|
asText (at "user" value) `shouldBe` "admin"
|
||||||
|
asBool (at "admin" value) `shouldBe` True
|
||||||
|
it "an error carries a code and a message" $ do
|
||||||
|
let value = fromMaybe A.Null (decode (encodeResponse (RespError "no_database" "no database selected")))
|
||||||
|
asText (at "status" value) `shouldBe` "error"
|
||||||
|
asText (at "code" value) `shouldBe` "no_database"
|
||||||
|
asText (at "message" value) `shouldBe` "no database selected"
|
||||||
|
it "a result set is the command line JSON with a status field added" $ do
|
||||||
|
let sample = QueryResult ["id"] [[VInt 1]] 1 False (Just "test")
|
||||||
|
value = fromMaybe A.Null (decode (encodeResponse (RespResult sample)))
|
||||||
|
asText (at "status" value) `shouldBe` "result"
|
||||||
|
map asText (items (at "columns" value)) `shouldBe` ["id"]
|
||||||
|
asInt (at "rowCount" value) `shouldBe` 1
|
||||||
|
asBool (at "truncated" value) `shouldBe` False
|
||||||
|
asText (at "database" value) `shouldBe` "test"
|
||||||
|
|
||||||
|
describe "server config ([server] in chusql.toml)" $ do
|
||||||
|
it "falls back to the defaults when the file has no server section" $ do
|
||||||
|
path <- tcpConfigPath "server-default"
|
||||||
|
removeIfExists path
|
||||||
|
loadServerConfigAt path `shouldReturn` defaultServerConfig
|
||||||
|
it "reads host, port, max_message and max_rows" $ do
|
||||||
|
path <- tcpConfigPath "server-keys"
|
||||||
|
BS.writeFile path "[server]\nhost = \"0.0.0.0\"\nport = 7777\nmax_message = 4096\nmax_rows = 7\n"
|
||||||
|
config <- loadServerConfigAt path
|
||||||
|
scHost config `shouldBe` "0.0.0.0"
|
||||||
|
scPort config `shouldBe` 7777
|
||||||
|
scMaxMessage config `shouldBe` 4096
|
||||||
|
scMaxRows config `shouldBe` 7
|
||||||
|
it "keeps the HTTP, storage and page keys out of the server config" $ do
|
||||||
|
path <- tcpConfigPath "server-other-keys"
|
||||||
|
BS.writeFile path "[web]\nport = 8888\n\n[storage]\ndata_dir = \"/tmp/chusql\"\n\n[page]\nsize = 8192\n"
|
||||||
|
config <- loadServerConfigAt path
|
||||||
|
scPort config `shouldBe` scPort defaultServerConfig
|
||||||
|
scMaxRows config `shouldBe` scMaxRows defaultServerConfig
|
||||||
|
|
||||||
|
-- | 真连 TCP 服务器的用例
|
||||||
|
tcpServerSpec :: Spec
|
||||||
|
tcpServerSpec = do
|
||||||
|
describe "TCP server: handshake and login" $ do
|
||||||
|
it "binds a real port when asked for port 0" $
|
||||||
|
withTcpServer defaultServerConfig $ \_ handle ->
|
||||||
|
shPort handle `shouldSatisfy` (> 0)
|
||||||
|
it "answers hello with the same protocol version" $
|
||||||
|
withTcpServer defaultServerConfig $ \_ handle ->
|
||||||
|
withConnection handle $ \h -> do
|
||||||
|
reply <- tcpTalk h "{\"method\":\"hello\",\"protocol\":1}"
|
||||||
|
asText (at "status" reply) `shouldBe` "hello"
|
||||||
|
asInt (at "protocol" reply) `shouldBe` 1
|
||||||
|
it "still answers a client that connects long after startup" $
|
||||||
|
-- 回归点:早先 accept 外面套 timeout 轮询,超时异常会在 accept
|
||||||
|
-- 返回后把刚拿到的连接丢掉,只有延迟连接的客户端能撞上
|
||||||
|
withTcpServer defaultServerConfig $ \_ handle -> do
|
||||||
|
threadDelay 400000
|
||||||
|
withConnection handle $ \h -> do
|
||||||
|
reply <- tcpTalk h "{\"method\":\"hello\",\"protocol\":1}"
|
||||||
|
asText (at "status" reply) `shouldBe` "hello"
|
||||||
|
asText (at "server" reply) `shouldBe` "chusql-server"
|
||||||
|
it "refuses a protocol version it does not speak" $
|
||||||
|
withTcpServer defaultServerConfig $ \_ handle ->
|
||||||
|
withConnection handle $ \h -> do
|
||||||
|
reply <- tcpTalk h "{\"method\":\"hello\",\"protocol\":9}"
|
||||||
|
asText (at "code" reply) `shouldBe` "bad_request"
|
||||||
|
asText (at "message" reply) `shouldBe` "unsupported protocol version: 9"
|
||||||
|
it "refuses a query before login" $
|
||||||
|
withTcpServer defaultServerConfig $ \_ handle ->
|
||||||
|
withConnection handle $ \h -> do
|
||||||
|
reply <- tcpQuery h "select * from users"
|
||||||
|
asText (at "status" reply) `shouldBe` "error"
|
||||||
|
asText (at "code" reply) `shouldBe` "unauthorized"
|
||||||
|
asText (at "message" reply) `shouldBe` "sign in first"
|
||||||
|
it "refuses a wrong password and then accepts the right one" $
|
||||||
|
withTcpServer defaultServerConfig $ \_ handle ->
|
||||||
|
withConnection handle $ \h -> do
|
||||||
|
bad <- tcpLogin h "admin" "wrong"
|
||||||
|
asText (at "status" bad) `shouldBe` "error"
|
||||||
|
asText (at "code" bad) `shouldBe` "unauthorized"
|
||||||
|
good <- tcpLogin h "admin" "s3cret"
|
||||||
|
asText (at "status" good) `shouldBe` "ok"
|
||||||
|
asText (at "user" good) `shouldBe` "admin"
|
||||||
|
asBool (at "admin" good) `shouldBe` True
|
||||||
|
|
||||||
|
describe "TCP server: SQL over the wire" $ do
|
||||||
|
it "USE picks a database and the result reports it" $
|
||||||
|
withTcpServer defaultServerConfig $ \_ handle ->
|
||||||
|
withConnection handle $ \h -> do
|
||||||
|
_ <- tcpLogin h "admin" "s3cret"
|
||||||
|
reply <- tcpQuery h "use test"
|
||||||
|
asText (at "status" reply) `shouldBe` "result"
|
||||||
|
asText (at "database" reply) `shouldBe` "test"
|
||||||
|
it "SELECT returns the fixture rows" $
|
||||||
|
withTcpServer defaultServerConfig $ \_ handle ->
|
||||||
|
withConnection handle $ \h -> do
|
||||||
|
_ <- tcpLogin h "admin" "s3cret"
|
||||||
|
_ <- tcpQuery h "use test"
|
||||||
|
reply <- tcpQuery h "select * from users"
|
||||||
|
asText (at "status" reply) `shouldBe` "result"
|
||||||
|
map asText (items (at "columns" reply)) `shouldBe` ["id", "name", "age"]
|
||||||
|
asInt (at "rowCount" reply) `shouldBe` 5
|
||||||
|
let first = case map items (items (at "rows" reply)) of
|
||||||
|
(r : _) -> r
|
||||||
|
[] -> []
|
||||||
|
length first `shouldBe` 3
|
||||||
|
asInt (fromMaybe A.Null (nth 0 first)) `shouldBe` 1
|
||||||
|
asText (fromMaybe A.Null (nth 1 first)) `shouldBe` "user1"
|
||||||
|
it "an unknown table maps to not_found" $
|
||||||
|
withTcpServer defaultServerConfig $ \_ handle ->
|
||||||
|
withConnection handle $ \h -> do
|
||||||
|
_ <- tcpLogin h "admin" "s3cret"
|
||||||
|
_ <- tcpQuery h "use test"
|
||||||
|
reply <- tcpQuery h "select * from nope"
|
||||||
|
asText (at "code" reply) `shouldBe` "not_found"
|
||||||
|
it "a query without a selected database maps to no_database" $
|
||||||
|
withTcpServer defaultServerConfig $ \_ handle ->
|
||||||
|
withConnection handle $ \h -> do
|
||||||
|
_ <- tcpLogin h "admin" "s3cret"
|
||||||
|
reply <- tcpQuery h "select * from users"
|
||||||
|
asText (at "code" reply) `shouldBe` "no_database"
|
||||||
|
it "an empty sql is a bad request" $
|
||||||
|
withTcpServer defaultServerConfig $ \_ handle ->
|
||||||
|
withConnection handle $ \h -> do
|
||||||
|
_ <- tcpLogin h "admin" "s3cret"
|
||||||
|
reply <- tcpTalk h "{\"method\":\"query\",\"sql\":\" \"}"
|
||||||
|
asText (at "code" reply) `shouldBe` "bad_request"
|
||||||
|
asText (at "message" reply) `shouldBe` "sql must not be empty"
|
||||||
|
it "the row limit truncates the rows but keeps the total count" $
|
||||||
|
withTcpServer defaultServerConfig{scMaxRows = 2} $ \_ handle ->
|
||||||
|
withConnection handle $ \h -> do
|
||||||
|
_ <- tcpLogin h "admin" "s3cret"
|
||||||
|
_ <- tcpQuery h "use test"
|
||||||
|
reply <- tcpQuery h "select * from users"
|
||||||
|
length (items (at "rows" reply)) `shouldBe` 2
|
||||||
|
asInt (at "rowCount" reply) `shouldBe` 5
|
||||||
|
asBool (at "truncated" reply) `shouldBe` True
|
||||||
|
|
||||||
|
describe "TCP server: framing and lifecycle" $ do
|
||||||
|
it "a bad JSON line does not kill the connection" $
|
||||||
|
withTcpServer defaultServerConfig $ \_ handle ->
|
||||||
|
withConnection handle $ \h -> do
|
||||||
|
bad <- tcpTalk h "not json"
|
||||||
|
asText (at "code" bad) `shouldBe` "bad_request"
|
||||||
|
pong <- tcpTalk h "{\"method\":\"ping\"}"
|
||||||
|
asText (at "status" pong) `shouldBe` "pong"
|
||||||
|
it "a line over the message limit is refused as too_large" $
|
||||||
|
withTcpServer defaultServerConfig{scMaxMessage = 32} $ \_ handle ->
|
||||||
|
withConnection handle $ \h -> do
|
||||||
|
reply <- tcpTalk h (BSC.pack ("{\"method\":\"query\",\"sql\":\"" ++ replicate 40 'a' ++ "\"}"))
|
||||||
|
asText (at "code" reply) `shouldBe` "too_large"
|
||||||
|
it "ping answers pong and quit closes the connection" $
|
||||||
|
withTcpServer defaultServerConfig $ \_ handle ->
|
||||||
|
withConnection handle $ \h -> do
|
||||||
|
pong <- tcpTalk h "{\"method\":\"ping\"}"
|
||||||
|
asText (at "status" pong) `shouldBe` "pong"
|
||||||
|
bye <- tcpTalk h "{\"method\":\"quit\"}"
|
||||||
|
asText (at "status" bye) `shouldBe` "bye"
|
||||||
|
tcpClosed h
|
||||||
|
it "one connection is one session" $
|
||||||
|
withTcpServer defaultServerConfig $ \_ handle ->
|
||||||
|
withConnection handle $ \h -> do
|
||||||
|
reply <- tcpLogin h "admin" "s3cret"
|
||||||
|
asText (at "status" reply) `shouldBe` "ok"
|
||||||
|
withConnection handle $ \other -> do
|
||||||
|
blocked <- tcpQuery other "select * from users"
|
||||||
|
asText (at "code" blocked) `shouldBe` "unauthorized"
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
// 本地端点:Windows 上是具名管道 `\\.\pipe\<name>`,Unix/macOS 上是文件系统套接字
|
|
||||||
// `<socket_dir>/<name>.sock`。存储进程、Haskell 前端(见 chusql-engine 的 Storage.IPC)
|
|
||||||
// 和集成测试都用同一条规则,保证对同一个 `[server] pipe_name` 算出同一个端点。
|
|
||||||
//
|
|
||||||
// 用文件系统套接字而不是 Linux 抽象命名空间:抽象命名空间没有文件、跨平台不存在,
|
|
||||||
// 而带路径的套接字两个方向都能连,Haskell 那边也能用常规 socket 连上。
|
|
||||||
|
|
||||||
use interprocess::local_socket::{prelude::*, ListenerOptions};
|
|
||||||
#[cfg(unix)]
|
|
||||||
use interprocess::local_socket::GenericFilePath;
|
|
||||||
#[cfg(windows)]
|
|
||||||
use interprocess::local_socket::GenericNamespaced;
|
|
||||||
#[cfg(unix)]
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
/// 端点的可读形式:Windows 是 `\\.\pipe\<name>`,Unix 是套接字文件路径。
|
|
||||||
pub fn display(pipe_name: &str) -> String {
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
format!("\\\\.\\pipe\\{}", pipe_name)
|
|
||||||
}
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
socket_path(pipe_name).display().to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unix 端点路径:`$XDG_RUNTIME_DIR` → `$TMPDIR` → `/tmp`,文件名固定 `<pipe_name>.sock`。
|
|
||||||
#[cfg(unix)]
|
|
||||||
pub fn socket_path(pipe_name: &str) -> PathBuf {
|
|
||||||
let dir = std::env::var_os("XDG_RUNTIME_DIR")
|
|
||||||
.filter(|v| !v.is_empty())
|
|
||||||
.or_else(|| std::env::var_os("TMPDIR").filter(|v| !v.is_empty()))
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|| PathBuf::from("/tmp"));
|
|
||||||
dir.join(format!("{}.sock", pipe_name))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 连到正在监听的存储进程。
|
|
||||||
pub fn connect(pipe_name: &str) -> std::io::Result<LocalSocketStream> {
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
let name = pipe_name
|
|
||||||
.to_ns_name::<GenericNamespaced>()
|
|
||||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
|
||||||
LocalSocketStream::connect(name)
|
|
||||||
}
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
let path = socket_path(pipe_name);
|
|
||||||
let name = path
|
|
||||||
.to_fs_name::<GenericFilePath>()
|
|
||||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
|
||||||
LocalSocketStream::connect(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 监听端点:Windows 直接建具名管道;Unix 先探活,再清掉上次崩溃留下的残留套接字文件。
|
|
||||||
pub fn listen(pipe_name: &str) -> std::io::Result<LocalSocketListener> {
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
let name = pipe_name
|
|
||||||
.to_ns_name::<GenericNamespaced>()
|
|
||||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
|
||||||
ListenerOptions::new().name(name).create_sync()
|
|
||||||
}
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
let path = socket_path(pipe_name);
|
|
||||||
prepare_socket_path(&path)?;
|
|
||||||
let name = path
|
|
||||||
.to_fs_name::<GenericFilePath>()
|
|
||||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
|
||||||
ListenerOptions::new().name(name).create_sync()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unix:先探活的监听者,再清掉残留的套接字文件。
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn prepare_socket_path(path: &Path) -> std::io::Result<()> {
|
|
||||||
match std::os::unix::net::UnixStream::connect(path) {
|
|
||||||
Ok(_) => Err(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::AddrInUse,
|
|
||||||
format!(
|
|
||||||
"another storage server already listens on {}",
|
|
||||||
path.display()
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
Err(_) => {
|
|
||||||
if path.exists() {
|
|
||||||
std::fs::remove_file(path)?;
|
|
||||||
}
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
std::fs::create_dir_all(parent)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+76
-91
@@ -3,32 +3,28 @@
|
|||||||
module Main (main) where
|
module Main (main) where
|
||||||
|
|
||||||
import ChuSQL.Web.API (AppEnv (..), Live (..), newAppEnvAt, setLive, webApp)
|
import ChuSQL.Web.API (AppEnv (..), Live (..), newAppEnvAt, setLive, webApp)
|
||||||
import ChuSQL.Web.Auth (Credential (..), SessionPolicy (..), newSessionStore)
|
import ChuSQL.Interface.Auth (Credential (..), SessionPolicy (..), setPayloadPolicy)
|
||||||
import ChuSQL.Web.Backend (Backend, ipcBackend)
|
import ChuSQL.Interface.Config (
|
||||||
import ChuSQL.Web.Demo (SeedReport (..), seedDemo)
|
ServerConfig (..),
|
||||||
import ChuSQL.Web.Config (
|
|
||||||
WebConfig (..),
|
WebConfig (..),
|
||||||
defaultPassword,
|
defaultPassword,
|
||||||
defaultUser,
|
defaultUser,
|
||||||
|
loadServerConfigAt,
|
||||||
loadWebConfigAt,
|
loadWebConfigAt,
|
||||||
resolveCredential,
|
resolveCredential,
|
||||||
resolvePipeName,
|
resolvePlainPassword,
|
||||||
resolveStaticDir,
|
resolveStaticDir,
|
||||||
|
rootUserName,
|
||||||
staticDirCandidates,
|
staticDirCandidates,
|
||||||
usingDefaultCredentials,
|
usingDefaultCredentials,
|
||||||
)
|
)
|
||||||
import ChuSQL.Web.RateLimit (newRateLimiter)
|
import ChuSQL.Interface.Link (clientPing, closeClient, connectClient)
|
||||||
import ChuSQL.Storage.IPC (setPipeName)
|
import ChuSQL.Interface.RateLimit (newRateLimiter)
|
||||||
import ChuSQL.Web.Settings (effectiveSettings, readSettingsFile)
|
import ChuSQL.Interface.Session (authenticateSession, newSession)
|
||||||
import ChuSQL.Web.TOML (resolveConfigPath)
|
import ChuSQL.Interface.Settings (effectiveSettings, readSettingsFile)
|
||||||
import ChuSQL.Web.StorageProcess (
|
import ChuSQL.Interface.TOML (resolveConfigPath)
|
||||||
StorageProcess (..),
|
import ChuSQL.Web.Demo (SeedReport (..), seedDemo)
|
||||||
findStorageServer,
|
import Control.Exception (IOException, try)
|
||||||
startStorageProcess,
|
|
||||||
stopStorageProcess,
|
|
||||||
waitForStorage,
|
|
||||||
)
|
|
||||||
import Control.Exception (IOException, bracket, onException, try)
|
|
||||||
import Data.Char (toLower)
|
import Data.Char (toLower)
|
||||||
import qualified Data.Map.Strict as Map
|
import qualified Data.Map.Strict as Map
|
||||||
import Data.String (fromString)
|
import Data.String (fromString)
|
||||||
@@ -43,14 +39,13 @@ import System.Exit (exitFailure)
|
|||||||
import System.IO (BufferMode (LineBuffering), hSetBuffering, stdout)
|
import System.IO (BufferMode (LineBuffering), hSetBuffering, stdout)
|
||||||
import Text.Read (readMaybe)
|
import Text.Read (readMaybe)
|
||||||
|
|
||||||
-- Web 管理端入口:读配置、备凭据、拉起存储进程,最后起 Warp。
|
-- Web 管理端入口:读配置,把请求转给拥有存储的 server,最后起 Warp。
|
||||||
|
|
||||||
data Options = Options
|
data Options = Options
|
||||||
{ optConfig :: Maybe FilePath
|
{ optConfig :: Maybe FilePath
|
||||||
, optHost :: Maybe String
|
, optHost :: Maybe String
|
||||||
, optPort :: Maybe Int
|
, optPort :: Maybe Int
|
||||||
, optStatic :: Maybe FilePath
|
, optStatic :: Maybe FilePath
|
||||||
, optStorage :: Maybe FilePath
|
|
||||||
, optUser :: Maybe Text
|
, optUser :: Maybe Text
|
||||||
, optCookieSecure :: Maybe Bool
|
, optCookieSecure :: Maybe Bool
|
||||||
, optBodyLimit :: Maybe Int
|
, optBodyLimit :: Maybe Int
|
||||||
@@ -74,7 +69,6 @@ emptyOptions =
|
|||||||
, optHost = Nothing
|
, optHost = Nothing
|
||||||
, optPort = Nothing
|
, optPort = Nothing
|
||||||
, optStatic = Nothing
|
, optStatic = Nothing
|
||||||
, optStorage = Nothing
|
|
||||||
, optUser = Nothing
|
, optUser = Nothing
|
||||||
, optCookieSecure = Nothing
|
, optCookieSecure = Nothing
|
||||||
, optBodyLimit = Nothing
|
, optBodyLimit = Nothing
|
||||||
@@ -94,13 +88,13 @@ emptyOptions =
|
|||||||
parseArgs :: [String] -> Either String Options
|
parseArgs :: [String] -> Either String Options
|
||||||
parseArgs = go emptyOptions
|
parseArgs = go emptyOptions
|
||||||
where
|
where
|
||||||
|
-- | 逐个参数累加进 Options
|
||||||
go opts [] = Right opts
|
go opts [] = Right opts
|
||||||
go opts ("--help" : rest) = go opts{optHelp = True} rest
|
go opts ("--help" : rest) = go opts{optHelp = True} rest
|
||||||
go opts ("-h" : rest) = go opts{optHelp = True} rest
|
go opts ("-h" : rest) = go opts{optHelp = True} rest
|
||||||
go opts ("--config" : v : rest) = go opts{optConfig = Just v} rest
|
go opts ("--config" : v : rest) = go opts{optConfig = Just v} rest
|
||||||
go opts ("--host" : v : rest) = go opts{optHost = Just v} rest
|
go opts ("--host" : v : rest) = go opts{optHost = Just v} rest
|
||||||
go opts ("--static" : v : rest) = go opts{optStatic = Just v} rest
|
go opts ("--static" : v : rest) = go opts{optStatic = Just v} rest
|
||||||
go opts ("--storage" : v : rest) = go opts{optStorage = Just v} rest
|
|
||||||
go opts ("--user" : v : rest) = go opts{optUser = Just (T.pack v)} rest
|
go opts ("--user" : v : rest) = go opts{optUser = Just (T.pack v)} rest
|
||||||
go opts ("--port" : v : rest) = withInt "--port" v (\n -> go opts{optPort = Just n} rest)
|
go opts ("--port" : v : rest) = withInt "--port" v (\n -> go opts{optPort = Just n} rest)
|
||||||
go opts ("--body-limit" : v : rest) = withInt "--body-limit" v (\n -> go opts{optBodyLimit = Just n} rest)
|
go opts ("--body-limit" : v : rest) = withInt "--body-limit" v (\n -> go opts{optBodyLimit = Just n} rest)
|
||||||
@@ -116,13 +110,15 @@ parseArgs = go emptyOptions
|
|||||||
go opts ("--cookie-secure" : v : rest) = go opts{optCookieSecure = Just (isTrue v)} rest
|
go opts ("--cookie-secure" : v : rest) = go opts{optCookieSecure = Just (isTrue v)} rest
|
||||||
go _ (a : _) = Left ("unrecognized argument or missing value: " ++ a)
|
go _ (a : _) = Left ("unrecognized argument or missing value: " ++ a)
|
||||||
|
|
||||||
|
-- | 解析一个整数参数
|
||||||
withInt :: String -> String -> (Int -> Either String Options) -> Either String Options
|
withInt :: String -> String -> (Int -> Either String Options) -> Either String Options
|
||||||
withInt name v k = maybe (Left (name ++ " needs an integer, got: " ++ v)) k (readMaybe v)
|
withInt name v k = maybe (Left (name ++ " needs an integer, got: " ++ v)) k (readMaybe v)
|
||||||
|
|
||||||
|
-- | 把 1/true/yes/on 认成真
|
||||||
isTrue :: String -> Bool
|
isTrue :: String -> Bool
|
||||||
isTrue s = map toLower s `elem` ["1", "true", "yes", "on"]
|
isTrue s = map toLower s `elem` ["1", "true", "yes", "on"]
|
||||||
|
|
||||||
-- | 帮助
|
-- | 打印命令行用法
|
||||||
usage :: IO ()
|
usage :: IO ()
|
||||||
usage = do
|
usage = do
|
||||||
putStrLn "usage: chusql-web [options]"
|
putStrLn "usage: chusql-web [options]"
|
||||||
@@ -130,15 +126,14 @@ usage = do
|
|||||||
putStrLn "config: chusql.toml sits at a fixed place and its sections hold every knob below;"
|
putStrLn "config: chusql.toml sits at a fixed place and its sections hold every knob below;"
|
||||||
putStrLn " Windows: %APPDATA%\\ChuSQL\\chusql.toml"
|
putStrLn " Windows: %APPDATA%\\ChuSQL\\chusql.toml"
|
||||||
putStrLn " other: $XDG_CONFIG_HOME/ChuSQL/chusql.toml (or ~/.config/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 " [web] host/port/static_dir/user/password and the limits;"
|
||||||
putStrLn " [storage] data_dir; [page] size; [btree] order; [buffer] pool_size; [log] level"
|
putStrLn " [storage] data_dir; [page] size; [btree] order; [buffer] pool_size; [log] level"
|
||||||
putStrLn ""
|
putStrLn ""
|
||||||
putStrLn " --config FILE read that config file instead ([web] host ...)"
|
putStrLn " --config FILE read that config file instead ([web] host ...)"
|
||||||
putStrLn " --host H listen address ([web] host, default 127.0.0.1)"
|
putStrLn " --host H listen address ([web] host, default 127.0.0.1)"
|
||||||
putStrLn " --port N listen port ([web] port, default 7777)"
|
putStrLn " --port N listen port ([web] port, default 7778)"
|
||||||
putStrLn " --static DIR static asset directory ([web] static_dir, default static)"
|
putStrLn " --static DIR static asset directory ([web] static_dir, default static)"
|
||||||
putStrLn " --user NAME administrator name ([web] user, default root)"
|
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 " --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 " --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-idle N session idle timeout, sec ([web] session_idle, default 28800)"
|
||||||
@@ -155,8 +150,10 @@ usage = do
|
|||||||
putStrLn " an empty password means the administrator signs in with no password; ordinary accounts are then refused"
|
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 ("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 "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: the server owns the data directory and loads the chusql_core_storage library;"
|
||||||
putStrLn " [storage] data_dir, [page] size, [btree] order, [buffer] pool_size and [log] level are its own"
|
putStrLn " this process is a plain TCP client: every statement, lookup and account request goes to it"
|
||||||
|
putStrLn " [server] host / port says where it listens; [storage] data_dir, [page] size, [btree] order,"
|
||||||
|
putStrLn " [buffer] pool_size and [log] level are the server's own"
|
||||||
|
|
||||||
-- | 命令行覆盖配置
|
-- | 命令行覆盖配置
|
||||||
applyOptions :: WebConfig -> Options -> WebConfig
|
applyOptions :: WebConfig -> Options -> WebConfig
|
||||||
@@ -165,9 +162,6 @@ applyOptions cfg opts =
|
|||||||
{ wcHost = orElse (optHost opts) (wcHost cfg)
|
{ wcHost = orElse (optHost opts) (wcHost cfg)
|
||||||
, wcPort = orElse (optPort opts) (wcPort cfg)
|
, wcPort = orElse (optPort opts) (wcPort cfg)
|
||||||
, wcStaticDir = orElse (optStatic opts) (wcStaticDir cfg)
|
, wcStaticDir = orElse (optStatic opts) (wcStaticDir cfg)
|
||||||
, wcStorageServer = case optStorage opts of
|
|
||||||
Just p -> Just p
|
|
||||||
Nothing -> wcStorageServer cfg
|
|
||||||
, wcUser = orElse (optUser opts) (wcUser cfg)
|
, wcUser = orElse (optUser opts) (wcUser cfg)
|
||||||
, wcCookieSecure = orElse (optCookieSecure opts) (wcCookieSecure cfg)
|
, wcCookieSecure = orElse (optCookieSecure opts) (wcCookieSecure cfg)
|
||||||
, wcBodyLimit = orElse (optBodyLimit opts) (wcBodyLimit cfg)
|
, wcBodyLimit = orElse (optBodyLimit opts) (wcBodyLimit cfg)
|
||||||
@@ -182,21 +176,12 @@ applyOptions cfg opts =
|
|||||||
, wcSeedDemo = orElse (optSeedDemo opts) (wcSeedDemo cfg)
|
, wcSeedDemo = orElse (optSeedDemo opts) (wcSeedDemo cfg)
|
||||||
}
|
}
|
||||||
where
|
where
|
||||||
|
-- | 有就用新的,没有留旧值
|
||||||
orElse :: Maybe a -> a -> a
|
orElse :: Maybe a -> a -> a
|
||||||
orElse (Just x) _ = x
|
orElse (Just x) _ = x
|
||||||
orElse Nothing y = y
|
orElse Nothing y = y
|
||||||
|
|
||||||
-- | 配了存储进程路径就拉起来,没配就假设外面已经跑着
|
-- | 程序入口
|
||||||
startStorageIfWanted :: WebConfig -> 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 configPath)
|
|
||||||
|
|
||||||
-- | 入口
|
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main = do
|
main = do
|
||||||
hSetBuffering stdout LineBuffering
|
hSetBuffering stdout LineBuffering
|
||||||
@@ -210,13 +195,12 @@ main = do
|
|||||||
| optHelp opts -> usage
|
| optHelp opts -> usage
|
||||||
| otherwise -> run opts
|
| otherwise -> run opts
|
||||||
|
|
||||||
-- | 起服务
|
-- | 读配置装配环境,检查 server,起 Warp
|
||||||
run :: Options -> IO ()
|
run :: Options -> IO ()
|
||||||
run opts = do
|
run opts = do
|
||||||
configPath <- resolveConfigPath (optConfig opts)
|
configPath <- resolveConfigPath (optConfig opts)
|
||||||
base <- loadWebConfigAt configPath
|
base <- loadWebConfigAt configPath
|
||||||
let cfg = applyOptions base opts
|
let cfg = applyOptions base opts
|
||||||
setPipeName (resolvePipeName cfg)
|
|
||||||
foundStatic <- resolveStaticDir (wcStaticDir cfg)
|
foundStatic <- resolveStaticDir (wcStaticDir cfg)
|
||||||
case foundStatic of
|
case foundStatic of
|
||||||
Nothing -> do
|
Nothing -> do
|
||||||
@@ -224,44 +208,31 @@ run opts = do
|
|||||||
exitFailure
|
exitFailure
|
||||||
Just staticDir -> do
|
Just staticDir -> do
|
||||||
let settingsFile = configPath
|
let settingsFile = configPath
|
||||||
|
policy = SessionPolicy (fromIntegral (wcSessionIdle cfg)) (fromIntegral (wcSessionMax cfg))
|
||||||
saved <- readSettingsFile settingsFile
|
saved <- readSettingsFile settingsFile
|
||||||
cred <- resolveCredential cfg saved
|
config <- loadServerConfigAt settingsFile
|
||||||
storage <- startStorageIfWanted cfg configPath
|
|
||||||
case storage of
|
|
||||||
Left err -> do
|
|
||||||
putStrLn err
|
|
||||||
exitFailure
|
|
||||||
Right maybeStorage -> do
|
|
||||||
backend <- ipcBackend
|
|
||||||
let policy =
|
|
||||||
SessionPolicy
|
|
||||||
(fromIntegral (wcSessionIdle cfg))
|
|
||||||
(fromIntegral (wcSessionMax cfg))
|
|
||||||
sessions <- newSessionStore getCurrentTime policy
|
|
||||||
limiter <-
|
limiter <-
|
||||||
newRateLimiter
|
newRateLimiter
|
||||||
getCurrentTime
|
getCurrentTime
|
||||||
(wcLoginMaxAttempts cfg)
|
(wcLoginMaxAttempts cfg)
|
||||||
(fromIntegral (wcLoginWindow cfg))
|
(fromIntegral (wcLoginWindow cfg))
|
||||||
env0 <- newAppEnvAt settingsFile backend sessions cred limiter staticDir
|
env0 <- newAppEnvAt settingsFile (T.pack (scHost config)) (scPort config) limiter staticDir
|
||||||
`onException` maybe (pure ()) stopStorageProcess maybeStorage
|
|
||||||
let env =
|
let env =
|
||||||
env0
|
env0
|
||||||
{ aeCookieSecure = wcCookieSecure cfg
|
{ aeCookieSecure = wcCookieSecure cfg
|
||||||
, aeEffective = effectiveSettings cfg
|
, aeEffective = effectiveSettings cfg
|
||||||
, aeLog = TIO.putStrLn
|
, aeLog = TIO.putStrLn
|
||||||
}
|
}
|
||||||
|
setPayloadPolicy (aeSessions env) policy
|
||||||
setLive env (liveFromConfig cfg)
|
setLive env (liveFromConfig cfg)
|
||||||
app <- webApp env
|
app <- webApp env
|
||||||
printBanner cfg configPath staticDir maybeStorage
|
printBanner cfg configPath staticDir
|
||||||
reportCredential cred saved
|
reportCredential cfg saved
|
||||||
waitForStorageReport
|
checkServer (aeServerHost env) (aeServerPort env)
|
||||||
seedIfWanted cfg backend
|
seedIfWanted cfg saved (aeServerHost env) (aeServerPort env)
|
||||||
let settings =
|
let settings =
|
||||||
Warp.setPort (wcPort cfg) (Warp.setHost (fromString (wcHost cfg)) Warp.defaultSettings)
|
Warp.setPort (wcPort cfg) (Warp.setHost (fromString (wcHost cfg)) Warp.defaultSettings)
|
||||||
case maybeStorage of
|
serve settings app
|
||||||
Nothing -> serve settings app
|
|
||||||
Just sp -> bracket (pure sp) stopStorageProcess (const (serve settings app))
|
|
||||||
|
|
||||||
-- | 本次启动的配置摊成热改参数
|
-- | 本次启动的配置摊成热改参数
|
||||||
liveFromConfig :: WebConfig -> Live
|
liveFromConfig :: WebConfig -> Live
|
||||||
@@ -291,26 +262,38 @@ serve settings app = do
|
|||||||
exitFailure
|
exitFailure
|
||||||
|
|
||||||
-- | 口令是从哪来的(用了内置默认口令就明确警告)
|
-- | 口令是从哪来的(用了内置默认口令就明确警告)
|
||||||
reportCredential :: Credential -> Map.Map Text Text -> IO ()
|
reportCredential :: WebConfig -> Map.Map Text Text -> IO ()
|
||||||
reportCredential cred saved
|
reportCredential cfg saved = do
|
||||||
| usingDefaultCredentials saved = do
|
credential <- resolveCredential cfg saved
|
||||||
|
if usingDefaultCredentials saved
|
||||||
|
then do
|
||||||
putStrLn ""
|
putStrLn ""
|
||||||
putStrLn " !! Administrator credentials are not configured; the built-in demo password is in use:"
|
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 (" !! user " ++ T.unpack (credUser credential) ++ " / password " ++ T.unpack defaultPassword)
|
||||||
putStrLn " !! set user / password in the [web] section of chusql.toml and restart"
|
putStrLn " !! set user / password in the [web] section of chusql.toml and restart"
|
||||||
putStrLn ""
|
putStrLn ""
|
||||||
| T.null (credEncoded cred) =
|
else
|
||||||
putStrLn ("administrator: " ++ T.unpack (credUser cred) ++ " (passwordless: only the administrator may sign in)")
|
if T.null (credEncoded credential)
|
||||||
| otherwise = putStrLn ("administrator: " ++ T.unpack (credUser cred) ++ " (credentials come from the settings file only)")
|
then putStrLn ("administrator: " ++ T.unpack (rootUserName cfg saved) ++ " (passwordless: only the administrator may sign in)")
|
||||||
|
else putStrLn ("administrator: " ++ T.unpack (rootUserName cfg saved) ++ " (credentials come from the settings file only)")
|
||||||
|
|
||||||
-- | 配了 seed 就补一份演示数据
|
-- | 配了 seed 就以管理员身份补演示数据
|
||||||
seedIfWanted :: WebConfig -> Backend -> IO ()
|
seedIfWanted :: WebConfig -> Map.Map Text Text -> Text -> Int -> IO ()
|
||||||
seedIfWanted cfg backend
|
seedIfWanted cfg saved host port
|
||||||
| not (wcSeedDemo cfg) = pure ()
|
| not (wcSeedDemo cfg) = pure ()
|
||||||
| otherwise = do
|
| otherwise = do
|
||||||
result <- seedDemo backend
|
opened <- connectClient host port
|
||||||
|
case opened of
|
||||||
|
Left message -> TIO.putStrLn ("demo data skipped: " <> message)
|
||||||
|
Right client -> do
|
||||||
|
session <- newSession client
|
||||||
|
signed <- authenticateSession session (rootUserName cfg saved) (resolvePlainPassword saved)
|
||||||
|
case signed of
|
||||||
|
Left message -> TIO.putStrLn ("demo data skipped: " <> message)
|
||||||
|
Right () -> do
|
||||||
|
result <- seedDemo session
|
||||||
case result of
|
case result of
|
||||||
Left e -> TIO.putStrLn ("demo data skipped: " <> T.pack e)
|
Left message -> TIO.putStrLn ("demo data skipped: " <> message)
|
||||||
Right report ->
|
Right report ->
|
||||||
TIO.putStrLn
|
TIO.putStrLn
|
||||||
( "demo data: "
|
( "demo data: "
|
||||||
@@ -318,20 +301,24 @@ seedIfWanted cfg backend
|
|||||||
<> (if null (srSkipped report) then "" else ", kept " <> T.pack (unwords (srSkipped report)))
|
<> (if null (srSkipped report) then "" else ", kept " <> T.pack (unwords (srSkipped report)))
|
||||||
<> (if null (srIndexes report) then "" else ", index on " <> T.pack (unwords (srIndexes report)))
|
<> (if null (srIndexes report) then "" else ", index on " <> T.pack (unwords (srIndexes report)))
|
||||||
)
|
)
|
||||||
|
closeClient client
|
||||||
|
|
||||||
-- | 报一下存储进程通不通
|
-- | ping 一次 server,报告它通不通
|
||||||
waitForStorageReport :: IO ()
|
checkServer :: Text -> Int -> IO ()
|
||||||
waitForStorageReport = do
|
checkServer host port = do
|
||||||
up <- waitForStorage 20
|
opened <- connectClient host port
|
||||||
putStrLn
|
case opened of
|
||||||
( if up
|
Left message -> putStrLn ("storage: DOWN (" ++ T.unpack message ++ ")")
|
||||||
then "storage: up (endpoint answered ping)"
|
Right client -> do
|
||||||
else "storage: DOWN (no ping reply; start the Rust storage process or pass --storage)"
|
probe <- clientPing client
|
||||||
)
|
closeClient client
|
||||||
|
case probe of
|
||||||
|
Right () -> putStrLn "storage: up (the server answered ping)"
|
||||||
|
Left message -> putStrLn ("storage: DOWN (" ++ T.unpack message ++ ")")
|
||||||
|
|
||||||
-- | 启动横幅
|
-- | 启动横幅
|
||||||
printBanner :: WebConfig -> FilePath -> FilePath -> Maybe StorageProcess -> IO ()
|
printBanner :: WebConfig -> FilePath -> FilePath -> IO ()
|
||||||
printBanner cfg configPath staticDir maybeStorage = do
|
printBanner cfg configPath staticDir = do
|
||||||
putStrLn "==================== ChuSQL Web ===================="
|
putStrLn "==================== ChuSQL Web ===================="
|
||||||
putStrLn ("url: http://" ++ wcHost cfg ++ ":" ++ show (wcPort cfg) ++ "/")
|
putStrLn ("url: http://" ++ wcHost cfg ++ ":" ++ show (wcPort cfg) ++ "/")
|
||||||
putStrLn ("config file: " ++ configPath)
|
putStrLn ("config file: " ++ configPath)
|
||||||
@@ -360,6 +347,4 @@ printBanner cfg configPath staticDir maybeStorage = do
|
|||||||
++ show (wcLoginWindow cfg)
|
++ show (wcLoginWindow cfg)
|
||||||
++ "s"
|
++ "s"
|
||||||
)
|
)
|
||||||
case maybeStorage of
|
putStrLn "storage: through the server that owns the data directory"
|
||||||
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)"
|
|
||||||
+17
-25
@@ -4,37 +4,27 @@ name: chusql-web
|
|||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
synopsis: ChuSQL Web 管理端:Scotty + REST API + 静态页面
|
synopsis: ChuSQL Web 管理端:Scotty + REST API + 静态页面
|
||||||
description: ChuSQL 的浏览器管理端。负责 HTTP 交付、鉴权、静态文件,
|
description: ChuSQL 的浏览器管理端。负责 HTTP 交付、鉴权、静态文件,
|
||||||
以及把用户 SQL 转交给 chusql-engine(再由 IPC 打到 Rust 存储进程)。
|
并把用户的 SQL 经 TCP 转交给 chusql-server(存储由服务器进程里的
|
||||||
|
chusql_core_storage 动态库承担)。本进程不链引擎,也不碰存储。
|
||||||
|
数据库服务器与 TCP 协议在 chusql-server 包里,这里只做 HTTP 这一面。
|
||||||
build-type: Simple
|
build-type: Simple
|
||||||
|
|
||||||
library
|
library
|
||||||
hs-source-dirs: src
|
hs-source-dirs: src
|
||||||
exposed-modules: ChuSQL.Web.Actions
|
exposed-modules: ChuSQL.Web.API
|
||||||
ChuSQL.Web.Accounts
|
|
||||||
ChuSQL.Web.API
|
|
||||||
ChuSQL.Web.Auth
|
|
||||||
ChuSQL.Web.Backend
|
|
||||||
ChuSQL.Web.Config
|
|
||||||
ChuSQL.Web.Demo
|
ChuSQL.Web.Demo
|
||||||
ChuSQL.Web.Privileges
|
|
||||||
ChuSQL.Web.RateLimit
|
|
||||||
ChuSQL.Web.Secure
|
ChuSQL.Web.Secure
|
||||||
ChuSQL.Web.Settings
|
|
||||||
ChuSQL.Web.Static
|
ChuSQL.Web.Static
|
||||||
ChuSQL.Web.StorageProcess
|
|
||||||
ChuSQL.Web.TOML
|
|
||||||
ChuSQL.Web.UISettings
|
ChuSQL.Web.UISettings
|
||||||
build-depends: base >= 4.14 && < 5
|
build-depends: base >= 4.14 && < 5
|
||||||
, aeson
|
, aeson
|
||||||
, bytestring
|
, bytestring
|
||||||
, chusql-engine
|
, chusql-core-model
|
||||||
|
, chusql-interface
|
||||||
, containers
|
, containers
|
||||||
, crypton
|
|
||||||
, directory
|
, directory
|
||||||
, filepath
|
, filepath
|
||||||
, http-types
|
, http-types
|
||||||
, memory
|
|
||||||
, process
|
|
||||||
, scientific
|
, scientific
|
||||||
, scotty
|
, scotty
|
||||||
, text
|
, text
|
||||||
@@ -48,30 +38,32 @@ executable chusql-web
|
|||||||
main-is: Main.hs
|
main-is: Main.hs
|
||||||
hs-source-dirs: app
|
hs-source-dirs: app
|
||||||
build-depends: base
|
build-depends: base
|
||||||
, bytestring
|
, chusql-interface
|
||||||
, chusql-engine
|
|
||||||
, chusql-web
|
, chusql-web
|
||||||
, containers
|
, containers
|
||||||
, crypton
|
|
||||||
, directory
|
|
||||||
, filepath
|
|
||||||
, http-types
|
|
||||||
, memory
|
|
||||||
, text
|
, text
|
||||||
, time
|
, time
|
||||||
, wai
|
, wai
|
||||||
, warp
|
, warp
|
||||||
default-language: Haskell2010
|
default-language: Haskell2010
|
||||||
ghc-options: -Wall
|
ghc-options: -Wall
|
||||||
|
-- 运行期找 libchusql_core_storage.so:$ORIGIN 让二进制与动态库同目录即可
|
||||||
|
if os(linux)
|
||||||
|
ld-options: -Wl,-rpath,$ORIGIN
|
||||||
|
if os(osx)
|
||||||
|
ld-options: -Wl,-rpath,@loader_path
|
||||||
|
|
||||||
test-suite chusql-web-test
|
test-suite chusql-web-test
|
||||||
type: exitcode-stdio-1.0
|
type: exitcode-stdio-1.0
|
||||||
main-is: Spec.hs
|
main-is: Spec.hs
|
||||||
hs-source-dirs: test
|
hs-source-dirs: test
|
||||||
|
other-modules: Gates
|
||||||
|
build-tool-depends: chusql-server:chusql-server
|
||||||
build-depends: base
|
build-depends: base
|
||||||
, aeson
|
, aeson
|
||||||
, bytestring
|
, bytestring
|
||||||
, chusql-engine
|
, chusql-core-model
|
||||||
|
, chusql-interface
|
||||||
, chusql-web
|
, chusql-web
|
||||||
, containers
|
, containers
|
||||||
, directory
|
, directory
|
||||||
@@ -88,4 +80,4 @@ test-suite chusql-web-test
|
|||||||
, wai-extra
|
, wai-extra
|
||||||
, warp
|
, warp
|
||||||
default-language: Haskell2010
|
default-language: Haskell2010
|
||||||
ghc-options: -Wall
|
ghc-options: -Wall -threaded
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -10,16 +10,17 @@ module ChuSQL.Web.Demo (
|
|||||||
seedDemo,
|
seedDemo,
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import ChuSQL.Model (Value (..))
|
import ChuSQL.Core.Model (Value (..))
|
||||||
import ChuSQL.Storage.IPC (TableInfo (..))
|
import ChuSQL.Core.Protocol (TableInfo (..))
|
||||||
import ChuSQL.Web.Actions (
|
import ChuSQL.Interface.Actions (
|
||||||
ColumnSpec (..),
|
ColumnSpec (..),
|
||||||
CreateTableSpec (..),
|
CreateTableSpec (..),
|
||||||
createIndexSql,
|
createIndexSql,
|
||||||
createTableSql,
|
createTableSql,
|
||||||
insertRowsSql,
|
insertRowsSql,
|
||||||
)
|
)
|
||||||
import ChuSQL.Web.Backend (Backend (..))
|
import ChuSQL.Interface.Session (Session, catalog, runStatement)
|
||||||
|
import Data.Text (Text)
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
|
|
||||||
-- 演示数据:三张表加一个二级索引,语句与界面走同一套拼 SQL 的代码。
|
-- 演示数据:三张表加一个二级索引,语句与界面走同一套拼 SQL 的代码。
|
||||||
@@ -75,12 +76,12 @@ data SeedReport = SeedReport
|
|||||||
}
|
}
|
||||||
deriving (Show, Eq)
|
deriving (Show, Eq)
|
||||||
|
|
||||||
-- | 缺什么补什么,可反复点不重复灌
|
-- | 按需灌演示数据:缺表补表、缺索引补索引
|
||||||
seedDemo :: Backend -> IO (Either String SeedReport)
|
seedDemo :: Session -> IO (Either Text SeedReport)
|
||||||
seedDemo backend = do
|
seedDemo session = do
|
||||||
catalog <- beCatalog backend
|
catalogResult <- catalog session
|
||||||
case catalog of
|
case catalogResult of
|
||||||
Left e -> pure (Left e)
|
Left message -> pure (Left message)
|
||||||
Right infos -> do
|
Right infos -> do
|
||||||
let existingTables = map tiTable infos
|
let existingTables = map tiTable infos
|
||||||
missing = [spec | spec <- demoTableSpecs, T.unpack (ctsTable spec) `notElem` existingTables]
|
missing = [spec | spec <- demoTableSpecs, T.unpack (ctsTable spec) `notElem` existingTables]
|
||||||
@@ -93,34 +94,33 @@ seedDemo backend = do
|
|||||||
]
|
]
|
||||||
created <- runAll (map createTableSql missing)
|
created <- runAll (map createTableSql missing)
|
||||||
case created of
|
case created of
|
||||||
Left e -> pure (Left e)
|
Left message -> pure (Left message)
|
||||||
Right () -> do
|
Right () -> do
|
||||||
rowsDone <- runAll [insertRowsSql t rs | (t, rs) <- demoRows, t `elem` missingNames]
|
rowsDone <- runAll [insertRowsSql t rs | (t, rs) <- demoRows, t `elem` missingNames]
|
||||||
case rowsDone of
|
case rowsDone of
|
||||||
Left e -> pure (Left e)
|
Left message -> pure (Left message)
|
||||||
Right () -> do
|
Right () -> do
|
||||||
indexes <-
|
indexes <- runAll [createIndexSql t c | (t, c) <- wantedIndexes]
|
||||||
runAll [createIndexSql t c | (t, c) <- wantedIndexes]
|
pure $ fmap (const (SeedReport (map T.unpack missingNames) skipped (map (T.unpack . snd) wantedIndexes))) indexes
|
||||||
pure $
|
|
||||||
fmap
|
|
||||||
(const (SeedReport (map T.unpack missingNames) skipped (map (T.unpack . snd) wantedIndexes)))
|
|
||||||
indexes
|
|
||||||
where
|
where
|
||||||
runAll :: [Either String String] -> IO (Either String ())
|
-- | 顺序跑完一串语句,首错即停
|
||||||
|
runAll :: [Either String String] -> IO (Either Text ())
|
||||||
runAll [] = pure (Right ())
|
runAll [] = pure (Right ())
|
||||||
runAll (sql : rest) = case sql of
|
runAll (sql : rest) = case sql of
|
||||||
Left e -> pure (Left e)
|
Left e -> pure (Left (T.pack e))
|
||||||
Right statement -> do
|
Right statement -> do
|
||||||
result <- beStatement backend statement
|
result <- runStatement session (T.pack statement)
|
||||||
case result of
|
case result of
|
||||||
Left e -> pure (Left e)
|
Left message -> pure (Left message)
|
||||||
Right _ -> runAll rest
|
Right _ -> runAll rest
|
||||||
|
|
||||||
indexMissing :: [TableInfo] -> T.Text -> T.Text -> Bool
|
-- | 目录里这张表缺不缺这个索引
|
||||||
|
indexMissing :: [TableInfo] -> Text -> Text -> Bool
|
||||||
indexMissing infos t c = case lookup (T.unpack t) [(tiTable i, tiIndexes i) | i <- infos] of
|
indexMissing infos t c = case lookup (T.unpack t) [(tiTable i, tiIndexes i) | i <- infos] of
|
||||||
Nothing -> True
|
Nothing -> True
|
||||||
Just cols -> T.unpack c `notElem` cols
|
Just cols -> T.unpack c `notElem` cols
|
||||||
|
|
||||||
|
-- | 演示用户数据
|
||||||
userRecords :: [(String, Int)]
|
userRecords :: [(String, Int)]
|
||||||
userRecords =
|
userRecords =
|
||||||
[ ("Alice", 24)
|
[ ("Alice", 24)
|
||||||
@@ -135,6 +135,7 @@ userRecords =
|
|||||||
, ("Judy", 29)
|
, ("Judy", 29)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
-- | 演示订单数据
|
||||||
orderRecords :: [(Int, String, Int)]
|
orderRecords :: [(Int, String, Int)]
|
||||||
orderRecords =
|
orderRecords =
|
||||||
[ (1, "Laptop", 1299)
|
[ (1, "Laptop", 1299)
|
||||||
@@ -151,6 +152,7 @@ orderRecords =
|
|||||||
, (9, "Lamp", 39)
|
, (9, "Lamp", 39)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
-- | 演示产品数据
|
||||||
productRecords :: [(String, Int, Int)]
|
productRecords :: [(String, Int, Int)]
|
||||||
productRecords =
|
productRecords =
|
||||||
[ ("Laptop", 1001, 1299)
|
[ ("Laptop", 1001, 1299)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ maxNameLength = 128
|
|||||||
allowedExtensions :: [String]
|
allowedExtensions :: [String]
|
||||||
allowedExtensions = [".html", ".css", ".js", ".svg", ".png", ".ico", ".ttf", ".woff2"]
|
allowedExtensions = [".html", ".css", ".js", ".svg", ".png", ".ico", ".ttf", ".woff2"]
|
||||||
|
|
||||||
-- | 把请求里的文件名变成安全的相对路径;不合法给 Nothing
|
-- | 把请求文件名校验成安全相对路径,不合法给 Nothing
|
||||||
safeRelative :: Text -> Maybe FilePath
|
safeRelative :: Text -> Maybe FilePath
|
||||||
safeRelative t
|
safeRelative t
|
||||||
| T.null t = Nothing
|
| T.null t = Nothing
|
||||||
@@ -33,6 +33,7 @@ safeRelative t
|
|||||||
| map toLower (takeExtension (T.unpack t)) `notElem` allowedExtensions = Nothing
|
| map toLower (takeExtension (T.unpack t)) `notElem` allowedExtensions = Nothing
|
||||||
| otherwise = Just (T.unpack t)
|
| otherwise = Just (T.unpack t)
|
||||||
where
|
where
|
||||||
|
-- | 文件名里是否出现不允许的字符
|
||||||
badChar c = not (isAsciiLower c || isAsciiUpper c || isDigit c || c `elem` ("._-" :: String))
|
badChar c = not (isAsciiLower c || isAsciiUpper c || isDigit c || c `elem` ("._-" :: String))
|
||||||
|
|
||||||
-- | 读一个静态文件;不存在(或是目录)给 Nothing
|
-- | 读一个静态文件;不存在(或是目录)给 Nothing
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
{-# LANGUAGE OverloadedStrings #-}
|
|
||||||
|
|
||||||
module ChuSQL.Web.StorageProcess (
|
|
||||||
StorageProcess (..),
|
|
||||||
findStorageServer,
|
|
||||||
startStorageProcess,
|
|
||||||
startStorageProcessAt,
|
|
||||||
platformBinaryName,
|
|
||||||
storageChildArgs,
|
|
||||||
stopStorageProcess,
|
|
||||||
waitForStorage,
|
|
||||||
) where
|
|
||||||
|
|
||||||
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 System.Directory (doesFileExist)
|
|
||||||
import System.Exit (ExitCode)
|
|
||||||
import System.FilePath ((</>))
|
|
||||||
import System.Info (os)
|
|
||||||
import System.IO (Handle)
|
|
||||||
import System.Process (
|
|
||||||
CreateProcess (..),
|
|
||||||
ProcessHandle,
|
|
||||||
StdStream (..),
|
|
||||||
createProcess,
|
|
||||||
proc,
|
|
||||||
terminateProcess,
|
|
||||||
waitForProcess,
|
|
||||||
)
|
|
||||||
|
|
||||||
-- 拉起并管理 Rust 存储进程,起来后等管道应答 ping 再继续。
|
|
||||||
-- 调参、管名与数据目录都在全局 chusql.toml 里,孩子自己读;这里只负责起进程、
|
|
||||||
-- 用 --config 把「读哪份配置文件」告诉它,并等就绪。
|
|
||||||
|
|
||||||
data StorageProcess = StorageProcess
|
|
||||||
{ spProcess :: ProcessHandle
|
|
||||||
, spPipe :: String
|
|
||||||
}
|
|
||||||
|
|
||||||
-- | 可执行文件名跟平台走: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
|
|
||||||
ok <- doesFileExist p
|
|
||||||
pure (if ok then Right p else Left ("storage server not found: " ++ p))
|
|
||||||
findStorageServer Nothing = do
|
|
||||||
let dirs =
|
|
||||||
[ "chusql-storage/target"
|
|
||||||
, "../chusql-storage/target"
|
|
||||||
, "../../chusql-storage/target"
|
|
||||||
, "target"
|
|
||||||
]
|
|
||||||
modes = ["release", "debug"]
|
|
||||||
exes = [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 <path>"
|
|
||||||
|
|
||||||
-- | 按配置文件里的管名起存储进程
|
|
||||||
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 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
|
|
||||||
setPipeName pipe
|
|
||||||
ready <- waitForStorage (200 :: Int)
|
|
||||||
if ready
|
|
||||||
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)")
|
|
||||||
|
|
||||||
-- | 停掉存储进程,保留数据目录
|
|
||||||
stopStorageProcess :: StorageProcess -> IO ()
|
|
||||||
stopStorageProcess sp = do
|
|
||||||
closeConnection
|
|
||||||
_ <- try (terminateProcess (spProcess sp)) :: IO (Either SomeException ())
|
|
||||||
_ <- try (waitForProcess (spProcess sp)) :: IO (Either SomeException ExitCode)
|
|
||||||
pure ()
|
|
||||||
|
|
||||||
-- | 轮询 ping 直到对面应答
|
|
||||||
waitForStorage :: Int -> IO Bool
|
|
||||||
waitForStorage 0 = pure False
|
|
||||||
waitForStorage n = do
|
|
||||||
r <- try (sendRequest ReqPing) :: IO (Either SomeException Response)
|
|
||||||
case r of
|
|
||||||
Right RespPong -> pure True
|
|
||||||
_ -> threadDelay 25000 >> waitForStorage (n - 1)
|
|
||||||
@@ -42,15 +42,18 @@ resolveUISettingsFile = do
|
|||||||
ready <- firstExistingDir uiSettingsFileCandidates
|
ready <- firstExistingDir uiSettingsFileCandidates
|
||||||
pure (maybe firstCandidate id ready)
|
pure (maybe firstCandidate id ready)
|
||||||
where
|
where
|
||||||
|
-- | 首选候选路径
|
||||||
firstCandidate = case uiSettingsFileCandidates of
|
firstCandidate = case uiSettingsFileCandidates of
|
||||||
(p : _) -> p
|
(p : _) -> p
|
||||||
[] -> "chusql.ui.settings.json"
|
[] -> "chusql.ui.settings.json"
|
||||||
|
|
||||||
|
-- | 第一个存在的候选文件
|
||||||
firstExistingFile [] = pure Nothing
|
firstExistingFile [] = pure Nothing
|
||||||
firstExistingFile (p : ps) = do
|
firstExistingFile (p : ps) = do
|
||||||
ok <- doesFileExist p
|
ok <- doesFileExist p
|
||||||
if ok then pure (Just p) else firstExistingFile ps
|
if ok then pure (Just p) else firstExistingFile ps
|
||||||
|
|
||||||
|
-- | 目录已存在的第一个候选
|
||||||
firstExistingDir [] = pure Nothing
|
firstExistingDir [] = pure Nothing
|
||||||
firstExistingDir (p : ps) = do
|
firstExistingDir (p : ps) = do
|
||||||
let dir = takeDirectory p
|
let dir = takeDirectory p
|
||||||
@@ -61,6 +64,7 @@ resolveUISettingsFile = do
|
|||||||
validateUISettings :: A.Value -> Either String (Map.Map Text A.Value)
|
validateUISettings :: A.Value -> Either String (Map.Map Text A.Value)
|
||||||
validateUISettings (A.Object o) = Map.fromList <$> mapM checkField (KM.toList o)
|
validateUISettings (A.Object o) = Map.fromList <$> mapM checkField (KM.toList o)
|
||||||
where
|
where
|
||||||
|
-- | 校验一个字段
|
||||||
checkField (rawKey, value) =
|
checkField (rawKey, value) =
|
||||||
let key = K.toText rawKey
|
let key = K.toText rawKey
|
||||||
in case key of
|
in case key of
|
||||||
@@ -80,16 +84,20 @@ validateUISettings (A.Object o) = Map.fromList <$> mapM checkField (KM.toList o)
|
|||||||
"minimap" -> pair key <$> boolValue key value
|
"minimap" -> pair key <$> boolValue key value
|
||||||
_ -> Left ("unknown setting: " ++ T.unpack key)
|
_ -> Left ("unknown setting: " ++ T.unpack key)
|
||||||
|
|
||||||
|
-- | 把键和值配成一对
|
||||||
pair key value = (key, value)
|
pair key value = (key, value)
|
||||||
|
|
||||||
validateUISettings _ = Left "settings must be a JSON object"
|
validateUISettings _ = Left "settings must be a JSON object"
|
||||||
|
|
||||||
|
-- | 字体链最多几项
|
||||||
fontChainMax :: Int
|
fontChainMax :: Int
|
||||||
fontChainMax = 10
|
fontChainMax = 10
|
||||||
|
|
||||||
|
-- | 单个字体名最长多少字
|
||||||
fontNameMax :: Int
|
fontNameMax :: Int
|
||||||
fontNameMax = 64
|
fontNameMax = 64
|
||||||
|
|
||||||
|
-- | 空值占位文本最长多少字
|
||||||
nullTextMax :: Int
|
nullTextMax :: Int
|
||||||
nullTextMax = 64
|
nullTextMax = 64
|
||||||
|
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
resolver: lts-24.59
|
resolver: lts-24.59
|
||||||
|
|
||||||
|
extra-lib-dirs:
|
||||||
|
- ../chusql-core/storage/target/release
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
- .
|
- .
|
||||||
- ../chusql-engine
|
- ../chusql-core/engine
|
||||||
|
- ../chusql-server
|
||||||
|
- ../chusql-core/model
|
||||||
|
- ../chusql-interface
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
// REST 数据源:与 chusql-web 的 /api/* 一一对应。
|
// REST 数据源:与 chusql-web 的 /api/* 一一对应。
|
||||||
// 所有请求带 X-ChuSQL-Database 头(服务端不保存 USE 状态);401 时广播登录事件。
|
// 请求都带 X-ChuSQL-Database 头;401 时广播登录事件。
|
||||||
|
|
||||||
export const AUTH_EVENT = 'chusql-auth-required';
|
export const AUTH_EVENT = 'chusql-auth-required';
|
||||||
|
|
||||||
|
// 是否是普通对象。
|
||||||
function isObject(value) {
|
function isObject(value) {
|
||||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 发一次请求并处理错误。
|
||||||
async function request(url, init, database) {
|
async function request(url, init, database) {
|
||||||
const headers = new Headers(init && init.headers ? init.headers : undefined);
|
const headers = new Headers(init && init.headers ? init.headers : undefined);
|
||||||
if (database) headers.set('X-ChuSQL-Database', database);
|
if (database) headers.set('X-ChuSQL-Database', database);
|
||||||
@@ -34,6 +36,7 @@ async function request(url, init, database) {
|
|||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 组装 JSON 请求参数。
|
||||||
function jsonInit(method, body) {
|
function jsonInit(method, body) {
|
||||||
return {
|
return {
|
||||||
method,
|
method,
|
||||||
@@ -44,12 +47,14 @@ function jsonInit(method, body) {
|
|||||||
|
|
||||||
const identifier = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
const identifier = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
||||||
|
|
||||||
|
// 校验数据库名格式。
|
||||||
function checkDatabaseName(name) {
|
function checkDatabaseName(name) {
|
||||||
if (!identifier.test(String(name))) throw new Error('数据库名只能是字母、数字与下划线,且以字母或下划线开头。');
|
if (!identifier.test(String(name))) throw new Error('数据库名只能是字母、数字与下划线,且以字母或下划线开头。');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 会话
|
// ---------------------------------------------------------------- 会话
|
||||||
|
|
||||||
|
// 读取当前会话。
|
||||||
export async function currentSession() {
|
export async function currentSession() {
|
||||||
const body = await request('/api/session');
|
const body = await request('/api/session');
|
||||||
if (!isObject(body) || typeof body.user !== 'string') throw new Error('服务器返回的会话信息无法识别。');
|
if (!isObject(body) || typeof body.user !== 'string') throw new Error('服务器返回的会话信息无法识别。');
|
||||||
@@ -60,16 +65,19 @@ export async function currentSession() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 提交登录。
|
||||||
export async function login(user, password) {
|
export async function login(user, password) {
|
||||||
await request('/api/login', jsonInit('POST', { user, password }));
|
await request('/api/login', jsonInit('POST', { user, password }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 退出登录。
|
||||||
export async function logout() {
|
export async function logout() {
|
||||||
await request('/api/logout', jsonInit('POST', {}));
|
await request('/api/logout', jsonInit('POST', {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 数据库
|
// ---------------------------------------------------------------- 数据库
|
||||||
|
|
||||||
|
// 列出数据库。
|
||||||
export async function listDatabases() {
|
export async function listDatabases() {
|
||||||
const body = await request('/api/databases');
|
const body = await request('/api/databases');
|
||||||
if (!Array.isArray(body) || !body.every((name) => typeof name === 'string')) {
|
if (!Array.isArray(body) || !body.every((name) => typeof name === 'string')) {
|
||||||
@@ -78,16 +86,18 @@ export async function listDatabases() {
|
|||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 新建数据库。
|
||||||
export async function createDatabase(name, database) {
|
export async function createDatabase(name, database) {
|
||||||
checkDatabaseName(name);
|
checkDatabaseName(name);
|
||||||
await request('/api/databases', jsonInit('POST', { name }), database);
|
await request('/api/databases', jsonInit('POST', { name }), database);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 删除数据库。
|
||||||
export async function dropDatabase(name, database) {
|
export async function dropDatabase(name, database) {
|
||||||
await request(`/api/databases/${encodeURIComponent(name)}`, jsonInit('DELETE'), database);
|
await request(`/api/databases/${encodeURIComponent(name)}`, jsonInit('DELETE'), database);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换当前库:走一条 USE 语句,服务端在响应里回带新的库名。
|
// 切换当前库(发 USE 语句)。
|
||||||
export async function useDatabase(name, database) {
|
export async function useDatabase(name, database) {
|
||||||
checkDatabaseName(name);
|
checkDatabaseName(name);
|
||||||
return query(`USE ${name}`, database);
|
return query(`USE ${name}`, database);
|
||||||
@@ -95,12 +105,14 @@ export async function useDatabase(name, database) {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- 表结构
|
// ---------------------------------------------------------------- 表结构
|
||||||
|
|
||||||
|
// 取类型基名(去掉参数)。
|
||||||
function columnKind(type) {
|
function columnKind(type) {
|
||||||
return String(type).split('(')[0].trim().toLowerCase();
|
return String(type).split('(')[0].trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
const knownTypes = ['int', 'bigint', 'smallint', 'str', 'text', 'varchar', 'char', 'float', 'double', 'decimal', 'numeric', 'bool', 'date', 'timestamp', 'blob'];
|
const knownTypes = ['int', 'bigint', 'smallint', 'str', 'text', 'varchar', 'char', 'float', 'double', 'decimal', 'numeric', 'bool', 'date', 'timestamp', 'blob'];
|
||||||
|
|
||||||
|
// 校验并规整列描述。
|
||||||
function asColumns(value) {
|
function asColumns(value) {
|
||||||
if (!Array.isArray(value)) throw new Error('服务器返回的列清单无法识别。');
|
if (!Array.isArray(value)) throw new Error('服务器返回的列清单无法识别。');
|
||||||
return value.map((entry) => {
|
return value.map((entry) => {
|
||||||
@@ -112,6 +124,7 @@ function asColumns(value) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 校验并规整表描述。
|
||||||
function asTable(value) {
|
function asTable(value) {
|
||||||
if (!isObject(value) || typeof value.table !== 'string' || typeof value.rowCount !== 'number') {
|
if (!isObject(value) || typeof value.table !== 'string' || typeof value.rowCount !== 'number') {
|
||||||
throw new Error('服务器返回的表描述无法识别。');
|
throw new Error('服务器返回的表描述无法识别。');
|
||||||
@@ -125,38 +138,46 @@ function asTable(value) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 列出表。
|
||||||
export async function listTables(database) {
|
export async function listTables(database) {
|
||||||
const body = await request('/api/tables', undefined, database);
|
const body = await request('/api/tables', undefined, database);
|
||||||
if (!Array.isArray(body)) throw new Error('服务器返回的表清单无法识别。');
|
if (!Array.isArray(body)) throw new Error('服务器返回的表清单无法识别。');
|
||||||
return body.map(asTable);
|
return body.map(asTable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 取单表结构。
|
||||||
export async function getTable(database, table) {
|
export async function getTable(database, table) {
|
||||||
return asTable(await request(`/api/tables/${encodeURIComponent(table)}`, undefined, database));
|
return asTable(await request(`/api/tables/${encodeURIComponent(table)}`, undefined, database));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 新建表。
|
||||||
export async function createTable(database, name, columns) {
|
export async function createTable(database, name, columns) {
|
||||||
await request('/api/tables', jsonInit('POST', { name, columns }), database);
|
await request('/api/tables', jsonInit('POST', { name, columns }), database);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 删除表。
|
||||||
export async function dropTable(database, table) {
|
export async function dropTable(database, table) {
|
||||||
await request(`/api/tables/${encodeURIComponent(table)}`, jsonInit('DELETE'), database);
|
await request(`/api/tables/${encodeURIComponent(table)}`, jsonInit('DELETE'), database);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 建索引。
|
||||||
export async function createIndex(database, table, column) {
|
export async function createIndex(database, table, column) {
|
||||||
await request(`/api/tables/${encodeURIComponent(table)}/indexes`, jsonInit('POST', { column }), database);
|
await request(`/api/tables/${encodeURIComponent(table)}/indexes`, jsonInit('POST', { column }), database);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 删索引。
|
||||||
export async function dropIndex(database, table, column) {
|
export async function dropIndex(database, table, column) {
|
||||||
await request(`/api/tables/${encodeURIComponent(table)}/indexes/${encodeURIComponent(column)}`, jsonInit('DELETE'), database);
|
await request(`/api/tables/${encodeURIComponent(table)}/indexes/${encodeURIComponent(column)}`, jsonInit('DELETE'), database);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 删列。
|
||||||
export async function dropColumn(database, table, column) {
|
export async function dropColumn(database, table, column) {
|
||||||
await request(`/api/tables/${encodeURIComponent(table)}/columns/${encodeURIComponent(column)}`, jsonInit('DELETE'), database);
|
await request(`/api/tables/${encodeURIComponent(table)}/columns/${encodeURIComponent(column)}`, jsonInit('DELETE'), database);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 行
|
// ---------------------------------------------------------------- 行
|
||||||
|
|
||||||
|
// 分页取表数据。
|
||||||
export async function fetchRows({ database, table, limit, offset, sort, dir, filters }) {
|
export async function fetchRows({ database, table, limit, offset, sort, dir, filters }) {
|
||||||
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
|
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
|
||||||
if (sort) {
|
if (sort) {
|
||||||
@@ -179,14 +200,17 @@ export async function fetchRows({ database, table, limit, offset, sort, dir, fil
|
|||||||
return { columns, rows, total: body.total };
|
return { columns, rows, total: body.total };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 插入一行。
|
||||||
export async function insertRow(database, table, values) {
|
export async function insertRow(database, table, values) {
|
||||||
await request(`/api/tables/${encodeURIComponent(table)}/rows`, jsonInit('POST', { values }), database);
|
await request(`/api/tables/${encodeURIComponent(table)}/rows`, jsonInit('POST', { values }), database);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新一行。
|
||||||
export async function updateRow(database, table, id, values) {
|
export async function updateRow(database, table, id, values) {
|
||||||
await request(`/api/tables/${encodeURIComponent(table)}/rows/${encodeURIComponent(id)}`, jsonInit('PATCH', { values }), database);
|
await request(`/api/tables/${encodeURIComponent(table)}/rows/${encodeURIComponent(id)}`, jsonInit('PATCH', { values }), database);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 删除一行。
|
||||||
export async function deleteRow(database, table, id) {
|
export async function deleteRow(database, table, id) {
|
||||||
await request(`/api/tables/${encodeURIComponent(table)}/rows/${encodeURIComponent(id)}`, jsonInit('DELETE'), database);
|
await request(`/api/tables/${encodeURIComponent(table)}/rows/${encodeURIComponent(id)}`, jsonInit('DELETE'), database);
|
||||||
}
|
}
|
||||||
@@ -209,6 +233,7 @@ export async function commitChanges(database, changes) {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- 查询
|
// ---------------------------------------------------------------- 查询
|
||||||
|
|
||||||
|
// 执行 SQL 并规整结果。
|
||||||
export async function query(sql, database) {
|
export async function query(sql, database) {
|
||||||
const started = performance.now();
|
const started = performance.now();
|
||||||
try {
|
try {
|
||||||
@@ -231,27 +256,32 @@ export async function query(sql, database) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 灌演示数据。
|
||||||
export async function seedDemoData(database) {
|
export async function seedDemoData(database) {
|
||||||
return request('/api/demo-data', jsonInit('POST', {}), database);
|
return request('/api/demo-data', jsonInit('POST', {}), database);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 设置
|
// ---------------------------------------------------------------- 设置
|
||||||
|
|
||||||
|
// 读取服务器设置。
|
||||||
export async function loadServerSettings() {
|
export async function loadServerSettings() {
|
||||||
const body = await request('/api/settings');
|
const body = await request('/api/settings');
|
||||||
if (!isObject(body) || !Array.isArray(body.items)) throw new Error('服务器返回的设置清单无法识别。');
|
if (!isObject(body) || !Array.isArray(body.items)) throw new Error('服务器返回的设置清单无法识别。');
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 保存服务器设置。
|
||||||
export async function saveServerSettings(values) {
|
export async function saveServerSettings(values) {
|
||||||
const body = await request('/api/settings', jsonInit('PUT', { values }));
|
const body = await request('/api/settings', jsonInit('PUT', { values }));
|
||||||
return isObject(body) ? body : { ok: true };
|
return isObject(body) ? body : { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 读取 IDE 设置。
|
||||||
export async function loadUiSettings() {
|
export async function loadUiSettings() {
|
||||||
return request('/api/ui-settings');
|
return request('/api/ui-settings');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 保存 IDE 设置。
|
||||||
export async function saveUiSettings(settings) {
|
export async function saveUiSettings(settings) {
|
||||||
await request('/api/ui-settings', jsonInit('PUT', settings));
|
await request('/api/ui-settings', jsonInit('PUT', settings));
|
||||||
}
|
}
|
||||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user