From 7424491944a273b879203862036b9f6322308699 Mon Sep 17 00:00:00 2001 From: Paperclip CTO Date: Thu, 26 Mar 2026 03:49:06 +0000 Subject: [PATCH] chore: bootstrap independent git workflow Co-Authored-By: Paperclip --- .github/workflows/release-smoke.yml | 100 ++ .gitignore | 4 + ACCEPTANCE_CHECKLIST.md | 129 ++ Cargo.lock | 1505 +++++++++++++++++ Cargo.toml | 29 + DEFECT_CLASSIFICATION.md | 58 + DEVELOPING.md | 63 + GIT_WORKFLOW.md | 194 +++ PRE_RELEASE_CHECKLIST.md | 32 + PRODUCT_REQUIREMENTS.md | 218 +++ QA_RUNTIME_ENVIRONMENT.md | 118 ++ README.md | 112 ++ RELEASE_RUNBOOK.md | 83 + SMOKE_RUNBOOK.md | 126 ++ TEST_STRATEGY.md | 74 + apps/cli/Cargo.toml | 21 + apps/cli/src/main.rs | 1069 ++++++++++++ backlog/first-wave-issues.md | 33 + crates/db-config/Cargo.toml | 13 + crates/db-config/src/lib.rs | 123 ++ crates/db-core/Cargo.toml | 9 + crates/db-core/src/lib.rs | 384 +++++ crates/db-drivers/Cargo.toml | 15 + crates/db-drivers/src/lib.rs | 1110 ++++++++++++ docker-compose.demo.yml | 30 + examples/README.md | 38 + examples/fixtures/EXPECTED_RESULTS.md | 28 + examples/fixtures/mysql/init.sql | 35 + examples/fixtures/postgres/init.sql | 32 + examples/fixtures/sqlite/init.sql | 34 + examples/scripts/bootstrap-mysql.sh | 13 + examples/scripts/bootstrap-postgres.sh | 14 + examples/scripts/bootstrap-sqlite.sh | 45 + examples/sql/mysql/export_query.sql | 12 + examples/sql/mysql/failing_query.sql | 3 + examples/sql/mysql/happy_path_query.sql | 11 + examples/sql/postgres/export_query.sql | 12 + examples/sql/postgres/failing_query.sql | 3 + examples/sql/postgres/happy_path_query.sql | 11 + examples/sql/sqlite/export_query.sql | 10 + examples/sql/sqlite/failing_query.sql | 1 + examples/sql/sqlite/happy_path_query.sql | 9 + gui/README.md | 40 + gui/desktop-foundation.md | 291 ++++ gui/preview-server.mjs | 33 + gui/prototype/app.js | 53 + gui/prototype/index.html | 293 ++++ gui/prototype/styles.css | 423 +++++ .../2026-03-25-cli-release-smoke-baseline.md | 100 ++ ...03-25-cmp-9-desktop-gui-foundation-plan.md | 46 + ...6-03-25-dbtool-cli-v1-delivery-tracking.md | 92 + ...25-dbtool-cli-v1-rust-architecture-plan.md | 264 +++ plans/2026-03-25-pm-v1-sequencing.md | 15 + .../2026-03-25-qa-cmp-13-demo-runbook-plan.md | 20 + ...26-03-26-cmp-18-git-repo-bootstrap-plan.md | 51 + ...2026-03-26-qa-runtime-smoke-environment.md | 37 + scripts/git/gitea-askpass.sh | 13 + scripts/release/package-unix.sh | 36 + scripts/release/smoke-binary.sh | 12 + scripts/release/version.mjs | 11 + 60 files changed, 7793 insertions(+) create mode 100644 .github/workflows/release-smoke.yml create mode 100644 .gitignore create mode 100644 ACCEPTANCE_CHECKLIST.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 DEFECT_CLASSIFICATION.md create mode 100644 DEVELOPING.md create mode 100644 GIT_WORKFLOW.md create mode 100644 PRE_RELEASE_CHECKLIST.md create mode 100644 PRODUCT_REQUIREMENTS.md create mode 100644 QA_RUNTIME_ENVIRONMENT.md create mode 100644 README.md create mode 100644 RELEASE_RUNBOOK.md create mode 100644 SMOKE_RUNBOOK.md create mode 100644 TEST_STRATEGY.md create mode 100644 apps/cli/Cargo.toml create mode 100644 apps/cli/src/main.rs create mode 100644 backlog/first-wave-issues.md create mode 100644 crates/db-config/Cargo.toml create mode 100644 crates/db-config/src/lib.rs create mode 100644 crates/db-core/Cargo.toml create mode 100644 crates/db-core/src/lib.rs create mode 100644 crates/db-drivers/Cargo.toml create mode 100644 crates/db-drivers/src/lib.rs create mode 100644 docker-compose.demo.yml create mode 100644 examples/README.md create mode 100644 examples/fixtures/EXPECTED_RESULTS.md create mode 100644 examples/fixtures/mysql/init.sql create mode 100644 examples/fixtures/postgres/init.sql create mode 100644 examples/fixtures/sqlite/init.sql create mode 100755 examples/scripts/bootstrap-mysql.sh create mode 100755 examples/scripts/bootstrap-postgres.sh create mode 100755 examples/scripts/bootstrap-sqlite.sh create mode 100644 examples/sql/mysql/export_query.sql create mode 100644 examples/sql/mysql/failing_query.sql create mode 100644 examples/sql/mysql/happy_path_query.sql create mode 100644 examples/sql/postgres/export_query.sql create mode 100644 examples/sql/postgres/failing_query.sql create mode 100644 examples/sql/postgres/happy_path_query.sql create mode 100644 examples/sql/sqlite/export_query.sql create mode 100644 examples/sql/sqlite/failing_query.sql create mode 100644 examples/sql/sqlite/happy_path_query.sql create mode 100644 gui/README.md create mode 100644 gui/desktop-foundation.md create mode 100644 gui/preview-server.mjs create mode 100644 gui/prototype/app.js create mode 100644 gui/prototype/index.html create mode 100644 gui/prototype/styles.css create mode 100644 plans/2026-03-25-cli-release-smoke-baseline.md create mode 100644 plans/2026-03-25-cmp-9-desktop-gui-foundation-plan.md create mode 100644 plans/2026-03-25-dbtool-cli-v1-delivery-tracking.md create mode 100644 plans/2026-03-25-dbtool-cli-v1-rust-architecture-plan.md create mode 100644 plans/2026-03-25-pm-v1-sequencing.md create mode 100644 plans/2026-03-25-qa-cmp-13-demo-runbook-plan.md create mode 100644 plans/2026-03-26-cmp-18-git-repo-bootstrap-plan.md create mode 100644 plans/2026-03-26-qa-runtime-smoke-environment.md create mode 100755 scripts/git/gitea-askpass.sh create mode 100755 scripts/release/package-unix.sh create mode 100755 scripts/release/smoke-binary.sh create mode 100644 scripts/release/version.mjs diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml new file mode 100644 index 0000000..2455e6e --- /dev/null +++ b/.github/workflows/release-smoke.yml @@ -0,0 +1,100 @@ +name: release-smoke + +on: + workflow_dispatch: + push: + paths: + - ".github/workflows/release-smoke.yml" + - "Cargo.toml" + - "Cargo.lock" + - "apps/**" + - "crates/**" + - "README.md" + - "RELEASE_RUNBOOK.md" + - "SMOKE_RUNBOOK.md" + - "scripts/release/**" + +jobs: + build-release-artifacts: + name: ${{ matrix.os }} / ${{ matrix.target }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + archive_ext: tar.gz + binary_name: dbtool + - os: macos-13 + target: x86_64-apple-darwin + archive_ext: tar.gz + binary_name: dbtool + - os: windows-latest + target: x86_64-pc-windows-msvc + archive_ext: zip + binary_name: dbtool.exe + + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Resolve release metadata + shell: bash + run: | + version="$(node scripts/release/version.mjs)" + echo "APP_VERSION=$version" >> "$GITHUB_ENV" + echo "ARTIFACT_BASENAME=dbtool-$version-${{ matrix.target }}" >> "$GITHUB_ENV" + + - name: Build release binary + run: cargo build --release --bin dbtool + + - name: Smoke binary on Unix + if: runner.os != 'Windows' + shell: bash + run: scripts/release/smoke-binary.sh "target/release/${{ matrix.binary_name }}" + + - name: Smoke binary on Windows + if: runner.os == 'Windows' + shell: pwsh + run: | + $binary = "target/release/${{ matrix.binary_name }}" + & $binary --help | Out-Host + & $binary --version | Out-Host + + - name: Package Unix artifact + if: runner.os != 'Windows' + shell: bash + run: scripts/release/package-unix.sh "${{ matrix.target }}" "target/release/${{ matrix.binary_name }}" + + - name: Package Windows artifact + if: runner.os == 'Windows' + shell: pwsh + run: | + $artifactDir = Join-Path "dist" $env:ARTIFACT_BASENAME + New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null + Copy-Item "target/release/${{ matrix.binary_name }}" (Join-Path $artifactDir "${{ matrix.binary_name }}") + Copy-Item "README.md" (Join-Path $artifactDir "README.md") + Copy-Item "RELEASE_RUNBOOK.md" (Join-Path $artifactDir "RELEASE_RUNBOOK.md") + Copy-Item "SMOKE_RUNBOOK.md" (Join-Path $artifactDir "SMOKE_RUNBOOK.md") + + $archivePath = "dist/$env:ARTIFACT_BASENAME.zip" + if (Test-Path $archivePath) { + Remove-Item $archivePath -Force + } + Compress-Archive -Path "$artifactDir/*" -DestinationPath $archivePath + + $hash = (Get-FileHash -Path $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() + Set-Content -Path "$archivePath.sha256" -Value "$hash $(Split-Path $archivePath -Leaf)" + + - name: Upload packaged artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ env.ARTIFACT_BASENAME }} + path: | + dist/${{ env.ARTIFACT_BASENAME }}.${{ matrix.archive_ext }} + dist/${{ env.ARTIFACT_BASENAME }}.${{ matrix.archive_ext }}.sha256 + if-no-files-found: error diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..88829d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +/examples/tmp +/dist +/.agents diff --git a/ACCEPTANCE_CHECKLIST.md b/ACCEPTANCE_CHECKLIST.md new file mode 100644 index 0000000..3423741 --- /dev/null +++ b/ACCEPTANCE_CHECKLIST.md @@ -0,0 +1,129 @@ +# dbtool-cli-v1 验收检查表(初版) + +## 当前结论 + +- 当前环境中,项目配置声明的主工作区路径为 `/workspace/repo/dbtool-cli-v1`。 +- QA 首次盘点时,该路径原本不存在;当前已经补齐 Rust workspace、CLI、demo seed 资产、smoke runbook 和 release-smoke workflow 基线。 +- 2026-03-26 本地 Linux 已执行:CLI `--help`、`--version`、SQLite `connect` / `inspect` / `query` / `export`、SQLite seed 重放、Linux artifact checksum 与 artifact smoke。 +- 2026-03-26 当前 QA runner 再次确认:`node v24.14.0` 可用,`docker` 与 `cargo` 均不存在;因此本机只能继续复核预编译二进制与 SQLite 路径,不能在此处完成 PostgreSQL / MySQL 的 Docker-backed smoke。 +- 当前检查表仍不能直接宣布通过,因为 PostgreSQL / MySQL 的 Docker-backed smoke 与 GitHub 的 macOS / Windows release runner 还未在本 agent 环境实测。 + +## 与 PM / CTO 的当前对齐依据 + +- PM 对齐来源:项目说明与任务描述中定义的范围和完成标准。 +- CTO 对齐来源:项目说明中的工程边界与跨平台目标。 +- 待补强项:来自产品说明任务和架构任务的正式共享文档落地后,需要回填到本检查表。 + +## 发布门槛 + +- [x] 项目仓库已落地到可访问工作区,路径可重复获取 +- [x] 安装、启动、demo、seed、smoke、Docker 路径存在且写入文档 +- [ ] PostgreSQL、MySQL、SQLite 三类数据库均有可执行验证步骤 +- [x] macOS、Linux、Windows 的期望行为已记录 +- [x] 失败路径可复现、可记录、可区分严重级别 +- [ ] 验收证据可由非开发者重复执行 + +## 基础可运行性 + +- [x] 仓库可拉起或可构建 +- [x] CLI 二进制或运行命令已定义 +- [x] `--help` 输出稳定 +- [x] 版本信息可读取 +- [ ] 缺失依赖时返回可理解错误 + +## 启动 / Demo / Seed / Smoke / Docker + +- [x] 启动说明存在且从零可执行 +- [x] demo 数据准备步骤存在 +- [x] seed 步骤存在且幂等 +- [x] smoke 脚本存在且可一键执行 +- [ ] Dockerfile 或 compose 路径存在并可运行 +- [x] 上述路径都有预期输出与失败输出说明 + +## 连接验证 + +### PostgreSQL + +- [ ] 有效连接参数可成功连接 +- [ ] 无效 host / port / database / user / password 会失败 +- [ ] 网络不可达时返回清晰错误 +- [ ] 权限不足时返回清晰错误 + +### MySQL + +- [ ] 有效连接参数可成功连接 +- [ ] 无效 host / port / database / user / password 会失败 +- [ ] 网络不可达时返回清晰错误 +- [ ] 权限不足时返回清晰错误 + +### SQLite + +- [x] 有效文件路径可成功连接 +- [x] 文件不存在时返回清晰错误 +- [x] 路径无权限时返回清晰错误 +- [x] 非数据库文件时返回清晰错误 + +## Schema Introspection + +- [ ] 可列出 schema(适用时) +- [x] 可列出 table / view +- [x] 可查看列定义 +- [ ] 空 schema / 空数据库表现明确 +- [ ] 不支持对象类型时提示明确 + +## 查询执行 + +- [x] 可执行基础查询 +- [x] 可执行带条件查询 +- [x] 空结果集表现正确 +- [x] 语法错误会失败且可定位 +- [ ] 超时 / 长查询路径可观测 +- [x] 参数化查询行为明确 + +## 导出流程 + +- [x] 导出命令存在 +- [x] 输出路径规则明确 +- [x] 目标文件已存在时行为明确 +- [ ] 空结果导出行为明确 +- [x] 编码、换行、分隔符规则明确 +- [x] 导出失败时无静默成功 + +## 边界与回归关注 + +- [ ] 大结果集不会直接崩溃 +- [x] 特殊字符 / Unicode 结果可处理 +- [ ] NULL 值处理一致 +- [ ] 时区 / 日期时间输出规则明确 +- [x] 数据库间差异有记录 +- [ ] 回归验证步骤可重复执行 + +## 2026-03-26 执行证据 + +- 本地 Linux:`target/release/dbtool --help`、`target/release/dbtool --version` +- SQLite happy path:`connect`、`inspect --schema main`、`inspect --schema main --table tickets`、`query`、`export --format csv|json` +- SQLite failure path:missing file、permission denied、missing table、multi statement blocked、export overwrite blocked +- SQLite `connect` failure path:non-db file 现在返回非零退出码,并输出 `connection failed: file is not a database` +- SQLite seed:`examples/scripts/bootstrap-sqlite.sh` 使用 Node fallback 成功重放 +- Linux artifact:`dist/dbtool-0.1.0-x86_64-unknown-linux-gnu.tar.gz` checksum 通过,解包后二进制 `--help` / `--version` 通过 +- 当前 runner 能力盘点:`node v24.14.0` 存在;`sqlite3`、`python3`、`docker`、`cargo` 不存在 +- 当前 runner 复核:`examples/scripts/bootstrap-sqlite.sh`、`target/release/dbtool connect --driver sqlite ...`、`inspect --schema main`、`query --file examples/sql/sqlite/happy_path_query.sql` 再次通过 +- 当前 runner 导出复核:已有文件时 `export` 明确拒绝覆盖;改用 `examples/tmp/qa-export-2.csv` 后导出 `4` 行成功,内容与 `EXPECTED_RESULTS.md` 一致 + +## 平台矩阵 + +| 场景 | Linux | macOS | Windows | 备注 | +| --- | --- | --- | --- | --- | +| 安装 / 构建 | 本地 Linux 已验证 | 待验证 | 待验证 | GitHub workflow 已配置,macOS / Windows runner 未在当前环境执行 | +| CLI 启动 | 本地 Linux 已验证 | 待验证 | 待验证 | `--help` / `--version` 已纳入 release-smoke workflow | +| PostgreSQL | 待验证 | 待验证 | 待验证 | 需 Docker 环境完成真实 smoke | +| MySQL | 待验证 | 待验证 | 待验证 | 需 Docker 环境完成真实 smoke | +| SQLite | 本地 Linux 已验证 | 待验证 | 待验证 | 本地 query / export happy path 已验证 | +| 导出流程 | 本地 Linux 已验证 | 待验证 | 待验证 | CSV / JSON 本地已验证,跨平台产物仍待 CI 实测 | + +## 当前阻塞 + +- 阻塞 1:当前 agent 环境没有 `docker` 可执行文件,因此无法在此处实测 PostgreSQL / MySQL 的 `connect` / `inspect` / `query` / `export` smoke。 +- 阻塞 2:当前 agent 环境无法执行 GitHub 的 macOS / Windows runner,因此 release artifact matrix 还缺跨平台实测证据。 +- 阻塞 3:当前 agent 环境也没有 `cargo`,因此即使迁入宿主机方案,仍需预编译二进制或额外准备 Rust toolchain。 +- 阻塞 4:发布 workflow 已落地,但仍需真实 CI 运行记录与 QA 证据来关闭发布前验收风险。 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6ec53c0 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1505 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "btoi" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b5ab9db53bcda568284df0fd39f6eac24ad6f7ba7ff1168b9e76eba6576b976" +dependencies = [ + "num-traits", +] + +[[package]] +name = "bufstream" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40e38929add23cdf8a366df9b0e088953150724bcbe5fc330b0d8eb3b328eec8" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "db-config" +version = "0.1.0" +dependencies = [ + "db-core", +] + +[[package]] +name = "db-core" +version = "0.1.0" + +[[package]] +name = "db-drivers" +version = "0.1.0" +dependencies = [ + "db-core", + "mysql", + "postgres", + "rusqlite", +] + +[[package]] +name = "dbtool-cli" +version = "0.1.0" +dependencies = [ + "db-config", + "db-core", + "db-drivers", + "rusqlite", +] + +[[package]] +name = "derive_utils" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "362f47930db19fe7735f527e6595e4900316b893ebf6d48ad3d31be928d57dd6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-sink", + "futures-task", + "pin-project-lite", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "io-enum" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de9008599afe8527a8c9d70423437363b321649161e98473f433de802d76107" +dependencies = [ + "derive_utils", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys", +] + +[[package]] +name = "mysql" +version = "28.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a732193888328fc060ab901c0ed1355521267a51ffbfd9a0b3786434c6b8e7f" +dependencies = [ + "bufstream", + "bytes", + "crossbeam-queue", + "crossbeam-utils", + "flate2", + "io-enum", + "libc", + "lru", + "mysql_common", + "named_pipe", + "pem", + "percent-encoding", + "socket2", + "twox-hash", + "url", +] + +[[package]] +name = "mysql_common" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3050537bd6640ffee56d233a668b5f516bfa6f6936be934d552d2c2aec915fac" +dependencies = [ + "base64", + "bitflags", + "btoi", + "byteorder", + "bytes", + "crc32fast", + "flate2", + "getrandom", + "num-bigint", + "num-traits", + "regex", + "saturating", + "serde", + "serde_json", + "sha1", + "sha2", + "thiserror", + "uuid", +] + +[[package]] +name = "named_pipe" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad9c443cce91fc3e12f017290db75dde490d685cdaaf508d7159d7cf41f0eb2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "postgres" +version = "0.19.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c48ece1c6cda0db61b058c1721378da76855140e9214339fa1317decacb176" +dependencies = [ + "bytes", + "fallible-iterator 0.2.0", + "futures-util", + "log", + "tokio", + "tokio-postgres", +] + +[[package]] +name = "postgres-protocol" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ee9dd5fe15055d2b6806f4736aa0c9637217074e224bbec46d4041b91bb9491" +dependencies = [ + "base64", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "hmac", + "md-5", + "memchr", + "rand", + "sha2", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b858f82211e84682fecd373f68e1ceae642d8d751a1ebd13f33de6257b3e20" +dependencies = [ + "bytes", + "fallible-iterator 0.2.0", + "postgres-protocol", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags", + "fallible-iterator 0.3.0", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "saturating" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcea47c8f71744367793f16c2db1f11cb859d28f436bdb4ca9193eb1f787ee42" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand", + "socket2", + "tokio", + "tokio-util", + "whoami", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "whoami" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6a5b12f9df4f978d2cfdb1bd3bac52433f44393342d7ee9c25f5a1c14c0f45d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9635216 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,29 @@ +[workspace] +members = [ + "apps/cli", + "crates/db-config", + "crates/db-core", + "crates/db-drivers", +] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2021" +rust-version = "1.82" + +[workspace.dependencies] +db-config = { path = "crates/db-config" } +db-core = { path = "crates/db-core" } +db-drivers = { path = "crates/db-drivers" } +mysql = { version = "28.0.0", default-features = false, features = ["minimal-rust"] } +postgres = "0.19.12" +rusqlite = { version = "0.37.0", features = ["bundled"] } + +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +dbg_macro = "deny" +todo = "deny" +unwrap_used = "deny" diff --git a/DEFECT_CLASSIFICATION.md b/DEFECT_CLASSIFICATION.md new file mode 100644 index 0000000..5c29303 --- /dev/null +++ b/DEFECT_CLASSIFICATION.md @@ -0,0 +1,58 @@ +# dbtool-cli-v1 缺陷分类(初版) + +## 严重级别 + +### Blocker + +- CLI 无法启动 +- 三种目标数据库全部无法连接 +- 基础查询完全不可用 +- 导出产生错误结果且无替代路径 +- 发布路径不可构建、不可运行、不可验证 + +### Critical + +- 任一目标数据库的核心 happy path 不可用 +- 错误提示误导用户造成高概率误操作 +- 数据结果明显错误 +- 导出文件损坏或内容错乱 + +### Major + +- schema introspection 不完整 +- 部分查询场景失败 +- 平台间行为明显不一致 +- 失败路径不可定位 + +### Minor + +- 帮助文案、提示文案、非阻断交互问题 +- 输出格式轻微不一致 + +## 缺陷类型 + +- 功能缺陷 +- 回归缺陷 +- 可用性缺陷 +- 可重复性缺陷 +- 文档缺陷 +- 环境 / 配置缺陷 + +## 缺陷记录最小字段 + +- 标题 +- 环境 +- 数据库类型 +- 操作系统 +- 复现步骤 +- 预期结果 +- 实际结果 +- 证据 +- 严重级别 +- 是否阻塞发布 + +## 发布阻断原则 + +- 任一 Blocker 未关闭,不得宣布通过。 +- Critical 缺陷若无明确降级方案,不得宣布通过。 +- 未复现或无证据的“怀疑问题”不能当作结论,但必须登记为风险观察。 diff --git a/DEVELOPING.md b/DEVELOPING.md new file mode 100644 index 0000000..255f952 --- /dev/null +++ b/DEVELOPING.md @@ -0,0 +1,63 @@ +# Developing dbtool-cli-v1 + +## 本地开发 + +```bash +cargo fmt +cargo test +cargo run -p dbtool-cli -- --help +``` + +Git 仓库、branch、commit、push 与 agent 认证规范见 `GIT_WORKFLOW.md`。 + +如果当前 Linux 环境没有 `cc`,改用一个 C 兼容 linker,例如 `zig cc`: + +```bash +cat > /tmp/zig-cc <<'EOF' +#!/bin/sh +exec zig cc "$@" +EOF +chmod +x /tmp/zig-cc +export CC=/tmp/zig-cc +export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=/tmp/zig-cc +cargo test +cargo run -p dbtool-cli -- --help +``` + +## 当前 crate 责任 + +- `apps/cli`:命令行入口、参数解析、退出码与用户可见输出 +- `crates/db-core`:领域模型、输入约束、公共错误 +- `crates/db-config`:连接 profile、配置抽象、脱敏摘要 +- `crates/db-drivers`:驱动 trait 与按数据库分发的边界 + +## 当前约束 + +- 当前 PostgreSQL、MySQL 与 SQLite 已实现真实数据库 I/O +- `export` 已通过共享查询结果模型实现 CSV / JSON 写出 +- 子命令的 flag 形状已稳定到可供 QA / GUI 复用的程度,后续只能做兼容性演进 +- 错误信息必须避免泄露密码等敏感信息 +- PostgreSQL 非参数化查询使用 simple query,可执行多语句 SQL 文件;带 `--param` 时会切到 prepared execution,因此应传单条参数化语句 +- MySQL 非参数化查询使用 text protocol,可执行多语句 SQL 文件;带 `--param` 时会切到 positional prepared execution,因此应传单条参数化语句 +- MySQL `inspect` 继续复用 `--schema` flag,但语义上对应数据库名 +- SQLite `inspect` 也复用 `--schema` flag,但 demo 路径固定使用 `main` +- SQLite 查询当前按单条 statement 执行;多语句 SQL 会返回显式错误,而不是隐式忽略后续语句 +- `export` 当前要求查询必须返回结果集;非结果集语句会被明确拒绝 +- `export` 当前要求父目录已存在,且默认拒绝覆盖已有文件,除非显式传入 `--overwrite` + +## 验证路径 + +- `cargo build` 验证 workspace 可编译 +- `cargo test` 验证核心输入校验、脱敏逻辑和 CLI 参数解析 +- `cargo run -p dbtool-cli -- --help` 验证 CLI 可启动 +- `cargo run -p dbtool-cli -- connect --help` 与 `cargo run -p dbtool-cli -- inspect --help` 验证关键子命令可启动 +- `cargo run -p dbtool-cli -- query ... --file ` 验证 SQL 文件读取入口 +- `cargo run -p dbtool-cli -- export ... --format --output ` 验证导出路径 +- `./examples/scripts/bootstrap-sqlite.sh` + SQLite CLI happy path 验证本地样例数据库闭环 +- 无系统 linker 的 Linux 环境使用 `zig cc` 作为 host linker 验证路径 + +## 发布 smoke + +- GitHub Actions baseline: `.github/workflows/release-smoke.yml` +- 发布与回滚说明:`RELEASE_RUNBOOK.md` +- 本地 Linux 产物复核:`scripts/release/smoke-binary.sh ` 与 `scripts/release/package-unix.sh ` diff --git a/GIT_WORKFLOW.md b/GIT_WORKFLOW.md new file mode 100644 index 0000000..5413bcb --- /dev/null +++ b/GIT_WORKFLOW.md @@ -0,0 +1,194 @@ +# dbtool-cli-v1 Git 仓库与 Agent 提交规范 + +日期:2026-03-26 +作者:CTO + +## 1. 结论 + +- `dbtool-cli-v1` 应立即作为独立 Git 仓库初始化,不再依赖任何“Paperclip 根仓库”语义。 +- 默认远程使用 HTTPS: + - `https://gitea.shay7sev.site/paperclip/dbtool-cli-v1.git` +- agent 不允许直接 push 到默认分支 `main`。 +- 一个实现类 issue 允许多次 commit,但每次都必须对应清晰检查点。 +- 前端、后端各自对自己负责的 issue 分支与提交负责;跨前后端/集成类 issue 由该 issue 的 owner 负责最终整合与合并。 +- `main` 应开启 protected branch;常规变更走 branch + review gate,只有一次性仓库 bootstrap 允许例外。 + +## 2. 独立仓库边界 + +从本次初始化开始,项目根目录就是唯一仓库边界: + +- 仓库根:`/workspace/repo/dbtool-cli-v1` +- Git 元数据:`/workspace/repo/dbtool-cli-v1/.git` +- 远程名:`origin` +- 默认分支:`main` + +这意味着: + +- 共享计划、backlog、runbook、代码和测试都跟随本仓库历史演进。 +- 后续任何 agent 都应在本仓库内提交,不再把该项目视为上层 monorepo 的子目录。 +- 若未来存在前后端共存结构,也应继续共用此仓库边界,按目录分工,而不是拆成“一个产品多个隐藏仓库”。 + +## 3. 远程接入方案结论 + +### 推荐:HTTPS + Gitea bot token + +推荐把 HTTPS 作为默认 push 方案,原因如下: + +- 容器内不依赖宿主机 SSH agent 或私钥转发。 +- 更容易通过 Paperclip secret / 环境变量注入。 +- 更适合做统一脚本化封装,复用到前端、后端和全栈项目。 +- 轮换 token 和停用单个 bot 凭据更直接。 + +建议采用: + +- 一个专用 Gitea bot / service account +- 仅授予目标仓库写权限 +- token 不写入仓库,不落盘到共享文档 +- agent 运行时通过环境变量注入: + - `GITEA_USERNAME` + - `GITEA_TOKEN` + +### 备选:SSH bot key / deploy key + +SSH 可以作为组织偏好的备选,但不是当前默认推荐: + +- 优点:不需要 HTTP token;可与已有 SSH 管理习惯一致。 +- 缺点:容器内要管理私钥文件、`known_hosts`、权限位与 key 分发。 +- 对多项目复用来说,落地复杂度高于 HTTPS。 + +只有在以下条件成立时再选 SSH: + +- 组织已有稳定的 bot key 生命周期管理; +- 已有容器内密钥挂载与 host fingerprint 管理机制; +- 明确要求禁用 HTTPS token。 + +## 4. Agent 认证与 push 机制 + +### 最小可落地方案 + +1. 运行时向 agent 注入: + - `GITEA_USERNAME` + - `GITEA_TOKEN` +2. 使用 `GIT_ASKPASS` 辅助脚本回答 Git 的用户名/密码提示。 +3. 本地仓库远程固定为: + - `origin -> https://gitea.shay7sev.site/paperclip/dbtool-cli-v1.git` +4. push 时只推送当前工作分支,不推送 `main`。 + +仓库内已提供最小脚本: + +- `scripts/git/gitea-askpass.sh` + +示例: + +```bash +export GITEA_USERNAME=paperclip-bot +export GITEA_TOKEN=***REDACTED*** +export GIT_ASKPASS="$PWD/scripts/git/gitea-askpass.sh" +git push -u origin HEAD +``` + +### 安全边界 + +- token 只通过运行时环境注入。 +- 禁止把 token 写入 `.git/config`、shell history 或文档。 +- bot 凭据只用于 Git 读写,不复用个人账号。 +- 若 agent 只需 commit、不需 push,则不注入 `GITEA_TOKEN`。 + +## 5. Commit / Branch / Push 规则 + +### Commit 粒度 + +实现类 issue 允许多次 commit,但必须满足: + +- 每次 commit 对应一个清晰检查点; +- 检查点至少满足“代码可读”或“文档可评审”; +- 不允许把多个无关 issue 混在一个 commit; +- 未达到检查点时可继续在工作区迭代,不强制碎片化提交。 + +推荐检查点: + +- 一个子命令可运行 +- 一个 defect 已复现并修复 +- 一组测试新增并通过 +- 一个 runbook / governance 文档完成首版 + +### Branch 规则 + +默认分支命名: + +- agent:`agent//-` +- human:`user//-` + +示例: + +- `agent/backend/CMP-14-sqlite-connect-validation` +- `agent/cto/CMP-18-git-bootstrap` +- `user/alice/CMP-12-release-smoke` + +### Push 规则 + +- agent 不直接 push `main` +- agent 只 push 自己负责 issue 的工作分支 +- 合并到 `main` 前必须经过 review gate +- 仅仓库第一次 bootstrap 可由指定 owner 执行一次受控初始化推送 + +## 6. Protected Branch / Review Gate + +`main` 应立即启用以下保护: + +- 禁止直接 push +- 禁止 force push +- 至少 1 个 review +- CI / smoke 必须通过后才允许合并 + +当前阶段的最小 gate: + +- Rust 项目:`cargo test` +- CLI 可启动检查:`cargo run -p dbtool-cli -- --help` +- 发布链路 issue 涉及时,再叠加 release smoke + +## 7. 前端 / 后端 / 集成 issue 提交责任 + +- 后端 issue:由后端 owner 负责提交、push、自测说明和最终合并准备 +- 前端 issue:由前端 owner 负责提交、push、预览说明和最终合并准备 +- 测试 / QA issue:由 QA owner 提交测试资产、矩阵、runbook 和证据脚本 +- 集成类 issue:由该 issue 明确 owner 负责最终整合提交 + +管理规则: + +- 不存在“别人顺手帮你提交”的默认机制 +- 如需跨角色修改,必须在 issue 上明确 owner 与验收边界 +- 多人参与同一产品仓库时,各自 push 自己的分支,由集成 owner 收口 + +## 8. dbtool-cli-v1 当前落地动作 + +本次已完成: + +- 在项目根目录初始化独立 Git 仓库 +- 确认目标远程仓库页面可达: + - `https://gitea.shay7sev.site/paperclip/dbtool-cli-v1` +- 配置 `origin` +- 完成首次本地 commit,证明 agent 在受控条件下具备 commit 能力 + +本次未做: + +- 未直接 push 到远程默认分支 +- 未在仓库中存放任何长期凭据 +- 未跳过未来应有的 branch protection / review gate + +## 9. 后续复用方式 + +该机制可直接复用到前后端共存项目: + +1. 每个产品/项目一个独立仓库 +2. 统一使用 `origin` + HTTPS bot token +3. 统一使用 `agent/...` / `user/...` 分支命名 +4. 统一要求“一个 issue 多次 commit 允许,但每次必须是清晰检查点” +5. 统一由 issue owner 对最终提交与合并负责 + +如果未来要平台化,可以把以下内容抽成公司级模板: + +- `GIT_WORKFLOW.md` +- `scripts/git/gitea-askpass.sh` +- protected branch 默认策略 +- Paperclip agent 运行时凭据注入规范 diff --git a/PRE_RELEASE_CHECKLIST.md b/PRE_RELEASE_CHECKLIST.md new file mode 100644 index 0000000..f857e47 --- /dev/null +++ b/PRE_RELEASE_CHECKLIST.md @@ -0,0 +1,32 @@ +# dbtool-cli-v1 发布前检查清单(初版) + +## 发布前必须确认 + +- [ ] 当前发布版本、提交来源、构建来源明确 +- [x] 项目工作区可访问 +- [ ] README / 运行说明与实际一致 +- [x] smoke 路径可执行 +- [x] demo / seed 路径可执行 +- [ ] Docker 路径可执行(如果承诺提供) + +## 功能门槛 + +- [ ] PostgreSQL happy path 通过 +- [ ] MySQL happy path 通过 +- [x] SQLite happy path 通过 +- [x] 至少一条连接失败路径已验证 +- [x] 至少一条查询失败路径已验证 +- [x] 至少一条导出失败路径已验证 + +## 证据门槛 + +- [x] 验收检查表已更新 +- [x] 失败路径有复现步骤 +- [x] 已知风险有记录 +- [x] 未验证项有明确原因 + +## 当前状态 + +- 当前清单已具备可执行代码、SQLite 主流程证据、SQLite non-db file failure-path 证据和 Linux release artifact smoke 证据,但仍不可整体判定通过。 +- 原因 1:当前 agent 环境无法执行 GitHub 的 macOS / Windows runner,也没有 `docker` 来完成 PostgreSQL / MySQL 的真实 smoke 复核。 +- 原因 2:当前 agent 环境没有 `cargo`,后续宿主机复核必须提前准备 Rust toolchain 或直接使用预编译二进制。 diff --git a/PRODUCT_REQUIREMENTS.md b/PRODUCT_REQUIREMENTS.md new file mode 100644 index 0000000..71fac9f --- /dev/null +++ b/PRODUCT_REQUIREMENTS.md @@ -0,0 +1,218 @@ +# dbtool-cli-v1 Product Requirements + +## Document Purpose + +This document defines the first release boundary for `dbtool-cli-v1` so the CTO can split implementation work without guessing and QA can derive a repeatable acceptance checklist. + +## Current Demo Audit + +- As of 2026-03-25, the configured project workspace had no repository contents, no Rust workspace, no README, and no runnable CLI binary. +- The current project state is therefore a scoped backlog plus a project goal, not an already-working demo. +- This document should be treated as the product baseline for implementation, not as a polish pass on existing behavior. + +## Target Users + +### Primary User + +Terminal-comfortable technical operators who need one consistent way to work across PostgreSQL, MySQL, and SQLite without switching between three native tools. + +Typical roles: + +- backend engineers investigating application data +- data engineers validating schema and query output +- QA or support engineers collecting evidence from a database during debugging + +### User Need + +The user needs a single local CLI that can: + +1. verify a connection +2. inspect database structure +3. execute SQL +4. export query results + +The value is consistency across supported databases, not feature depth equal to a full SQL IDE. + +## Product Problem Statement + +Today, the operator would otherwise need to learn different connection rules, inspection commands, and export workflows across `psql`, MySQL tools, and SQLite tooling. `dbtool-cli-v1` should reduce that tool-switching cost for the most common technical workflows. + +## Primary V1 Workflow + +The most important end-to-end flow is: + +1. choose a supported database target +2. confirm the tool can connect +3. inspect schemas, tables, and columns to find the right object +4. execute SQL against that object +5. export the result for analysis, sharing, or evidence capture + +If this flow is not smooth, V1 is not successful. + +## V1 Scope + +### In Scope + +- local CLI only +- database support for PostgreSQL, MySQL, and SQLite only +- explicit connection selection for each run +- schema and table inspection +- column-level metadata inspection +- ad hoc SQL execution +- `.sql` file execution +- export of query results to CSV and JSON +- clear terminal output and actionable failure messages + +### Product Boundaries + +- V1 is an operator tool, not a visual database client. +- V1 must optimize for correctness and consistency before convenience features. +- Exact command names, flag shapes, crate boundaries, and config storage are implementation choices owned by the CTO as long as they satisfy the acceptance criteria below. + +## Non-Goals + +The following are explicitly out of scope for V1: + +- desktop GUI or web GUI +- support for databases beyond PostgreSQL, MySQL, and SQLite +- migration management or schema-editing workflows +- ORM features, query builders, or notebook-style authoring +- background sync, job scheduling, alerts, or long-running daemon behavior +- shared team workspaces, cloud sync, or centralized connection management +- dashboards, charting, or analytics visualization +- advanced IDE features such as autocomplete, visual explain plans, or query history UX beyond what is minimally needed for CLI execution + +## Acceptance Criteria + +### Global Acceptance + +- All supported flows use one coherent CLI mental model across PostgreSQL, MySQL, and SQLite. +- Every failure returns a non-zero exit path and an actionable terminal message. +- Secrets are not echoed back in plain text error output. +- Driver-specific differences that affect user behavior are documented in the project README or command help. + +### Connect + +The `connect` flow is acceptable when: + +- the user can provide a supported database type plus connection input and the CLI validates that target +- success output clearly confirms which database type and target was reached without leaking secrets +- unsupported database types are rejected before an attempted connection +- authentication errors, network errors, unreachable hosts, and SQLite file path errors are distinguishable enough for the user to act +- a failed connection does not create the impression that later commands are safe to run + +### Inspect + +The `inspect` flow is acceptable when: + +- after selecting a valid target, the user can list available schemas or equivalent top-level objects for that database +- the user can list tables or views within the chosen scope +- the user can inspect columns for a table or view and see at least name, type, nullability, and key or primary-key indication when the driver exposes it +- empty databases or empty scopes return a clear "no objects found" style outcome instead of a stack trace +- SQLite differences are handled gracefully even though it does not mirror server-database structure exactly + +### Query + +The `query` flow is acceptable when: + +- the user can run SQL entered inline or from a `.sql` file +- row-returning statements show column headers and row output in a human-readable terminal format +- non-row-returning statements show a clear execution summary and affected row count when the driver provides it +- invalid SQL, permission failures, and driver execution failures return actionable errors +- empty result sets are treated as successful execution, not as a tool failure +- the output format remains suitable for terminal reading and shell piping + +### Export + +The `export` flow is acceptable when: + +- the user can export query results to CSV or JSON +- the export path is explicit +- the CLI does not silently overwrite an existing file unless the user explicitly allows overwrite behavior +- exported row counts match the query result set +- text output is UTF-8 and stable for common multilingual content +- export failures never report success and leave the user with a clear next action + +## Recommended User Scenarios + +### Scenario A: Backend Debugging + +A backend engineer needs to verify whether a bug is caused by bad data: + +1. connect to a PostgreSQL or MySQL environment +2. inspect the relevant schema and table +3. run a targeted query +4. export the result to JSON or CSV for a bug report + +### Scenario B: Local SQLite Verification + +A QA engineer receives a local SQLite file: + +1. point the CLI at the file path +2. inspect available tables +3. run a validation query +4. export evidence for a test record + +These scenarios should remain happy-path examples in engineering docs and QA checks. + +## CLI To Product Evolution + +GUI is not in the current scope, but the CLI should evolve in a way that supports a later product surface. + +The CLI should establish stable product concepts: + +- connection target +- catalog or schema browser state +- query execution +- result set +- export job + +Future desktop or web surfaces should map directly to the same concepts: + +- connection manager +- schema browser +- query editor +- results table +- export action + +### Evolution Rule + +The future GUI should reuse the same underlying product workflow, not invent a separate one. The CLI is the first validation surface for product behavior. + +### Implication For Engineering + +Engineering should avoid CLI-only shortcuts that would make later UI reuse difficult, especially around: + +- connection target representation +- inspection result structure +- query result structure +- export request inputs and outputs + +This is a product direction, not a mandate for a specific architecture. + +## Handoff Notes For CTO + +The following decisions are fixed for V1: + +- supported databases: PostgreSQL, MySQL, SQLite +- surface: local CLI +- critical path: connect, inspect, query, export +- GUI: out of scope +- acceptance must cover both success and obvious failure paths + +The following decisions remain implementation-owned by the CTO: + +- exact command names and flags +- Rust workspace structure +- connection config format and storage approach +- output rendering library choices +- packaging and distribution mechanics + +## Release Readiness Definition + +`dbtool-cli-v1` is ready for a first release when: + +- the primary workflow works across PostgreSQL, MySQL, and SQLite +- the documented non-goals have not crept into scope +- QA can execute acceptance checks directly from this document and companion test material +- CTO can map the remaining work into engineering tasks without reopening basic product questions diff --git a/QA_RUNTIME_ENVIRONMENT.md b/QA_RUNTIME_ENVIRONMENT.md new file mode 100644 index 0000000..92f5910 --- /dev/null +++ b/QA_RUNTIME_ENVIRONMENT.md @@ -0,0 +1,118 @@ +# PostgreSQL / MySQL Runtime Smoke 环境建议 + +## 结论 + +当前阶段的推荐方案是:**让 QA 在宿主机执行 smoke,但数据库仍使用仓库内的 Docker Compose 临时拉起**。 + +- 继续复用现有 `docker-compose.demo.yml`、bootstrap 脚本和 SQL fixtures +- 不依赖长期运行的宿主机 PostgreSQL / MySQL +- 不在当前阶段扩展 `local-db-codex` 运行时去支持 Docker + +这条路径最符合当前项目状态:仓库里的 smoke 输入已经存在,真正缺的是一个有 Docker 的执行宿主,而不是新的产品代码或平台改造。 + +## 三种方案对比 + +### 方案 A:宿主机长期运行 PostgreSQL / MySQL + +优点: + +- 首次接入快 +- QA 不需要每次拉起容器 + +缺点: + +- 状态容易漂移,重复执行时很难保证数据库干净 +- 需要长期维护端口、用户、权限和版本 +- 一旦多人共用宿主机,测试数据会互相污染 +- 更适合共享开发环境,不适合作为 release smoke 证据来源 + +结论:**可作为临时兜底,不作为推荐主路径。** + +### 方案 B:为 `local-db-codex` 部署增加 Docker runtime 能力 + +优点: + +- 从 agent 视角最方便,理论上可以把 smoke 也放回当前容器体系 +- 后续若多个项目都依赖容器型集成测试,长期上限更高 + +缺点: + +- 这是平台/安全/运维改造,不是当前仓库内的小改动 +- 会引入容器嵌套、权限边界、镜像缓存、资源隔离等额外复杂度 +- 当前项目的真实瓶颈是缺可执行宿主,不是缺应用层代码 + +结论:**不适合作为本阶段 unblock 手段,只在未来多个项目都提出同类需求时再立项。** + +### 方案 C:QA runner 在宿主机执行,数据库通过 Docker Compose 临时拉起 + +优点: + +- 直接复用现有仓库资产,落地最快 +- 每次 `up` / `down -v` 都是干净环境,可重复性最好 +- 不要求改 `local-db-codex` 平台 +- 后续可平滑迁移到自托管 runner 或专用 QA 主机 + +缺点: + +- 需要一台具备 Docker / `docker compose` 的宿主机 +- 宿主机上仍需准备 Rust toolchain 或 release binary + +结论:**这是当前推荐方案。** + +## 推荐执行方式 + +### 宿主机要求 + +- Linux 或 macOS 宿主机 +- 已安装 Docker 和 `docker compose` +- 可运行 Rust `cargo`,或已取得本仓库 release / debug 二进制 +- 2026-03-26 当前 QA agent runner 不满足该要求:它有 `node v24.14.0` 和预编译 `target/release/dbtool`,但缺少 `docker` 与 `cargo` + +### 使用步骤 + +1. 在宿主机拉取当前仓库代码并进入项目根目录 +2. 准备 CLI: + - 开发验证:`cargo build` + - 或使用已生成的 `target/debug/dbtool` / `target/release/dbtool` +3. 导出密码环境变量: + +```bash +export DBTOOL_PASSWORD=dbtool +``` + +4. 拉起 PostgreSQL / MySQL: + +```bash +docker compose -f docker-compose.demo.yml up -d postgres mysql +``` + +5. 导入 demo 数据: + +```bash +./examples/scripts/bootstrap-postgres.sh +./examples/scripts/bootstrap-mysql.sh +``` + +6. 按 `SMOKE_RUNBOOK.md` 执行 PostgreSQL / MySQL 的 `connect` / `inspect` / `query` / `export` +7. 将结果回填到 `ACCEPTANCE_CHECKLIST.md`、`TEST_STRATEGY.md`、`PRE_RELEASE_CHECKLIST.md` +8. 清理环境: + +```bash +docker compose -f docker-compose.demo.yml down -v +``` + +## 团队职责 + +- 后端:维护 `docker-compose.demo.yml`、bootstrap 脚本、fixtures 和命令契约 +- QA:在宿主机执行 smoke,沉淀证据和失败复现记录 +- 前端:当前继续 gated,不参与此轮 runtime smoke +- CTO:维持方案边界;若没有 Docker-capable host,再升级为平台支持请求 + +## 风险与维护成本 + +- 宿主机不可用:需要指定一台固定 QA 主机或自托管 runner +- 二进制与文档漂移:QA 执行时必须绑定具体 commit 或 release artifact +- 端口冲突:优先使用专用 QA 主机;必要时再参数化 compose 端口 +- 当前 runner 与目标宿主能力不一致:本地可继续复核 SQLite 与预编译二进制,但 PostgreSQL / MySQL Docker smoke 必须迁移到符合要求的宿主机 + +总体维护成本低,因为主路径复用仓库现有资产,不引入新的长期数据库运维负担,也不引入新的平台能力改造。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb26d08 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# dbtool-cli-v1 + +`dbtool-cli-v1` 是一个面向 PostgreSQL、MySQL 和 SQLite 的跨数据库 CLI 工具。 + +当前仓库已经具备 Rust workspace 基线,并已落地 PostgreSQL、MySQL 与 SQLite 的首个 happy path: + +- 根 `Cargo` workspace +- `apps/cli` 可执行入口 +- `crates/db-core` 领域模型与输入校验 +- `crates/db-config` 连接 profile 与脱敏摘要 +- `crates/db-drivers` 驱动边界与 PostgreSQL / MySQL / SQLite 实现 + +## 当前状态 + +当前已实现: + +- PostgreSQL `connect` +- PostgreSQL `inspect` +- PostgreSQL `query` +- MySQL `connect` +- MySQL `inspect` +- MySQL `query` +- SQLite `connect` +- SQLite `inspect` +- SQLite `query` +- `export` 到 CSV +- `export` 到 JSON +- `.sql` 文件读取 +- `--password-env` 密码注入且保持输出脱敏 + +已验证: + +- 标准环境:`cargo build` +- 标准环境:`cargo test` +- 标准环境:`cargo run -p dbtool-cli -- --help` +- 链接器受限的 Linux 环境:使用 `zig cc` 作为 host linker 执行 `cargo check` / `cargo test` + +当前 `connect` / `inspect` / `query` / `export` 在 PostgreSQL、MySQL 与 SQLite 的共享抽象上都已经可用;当前剩余工作主要是 Docker 环境下的跨数据库 smoke 与发布链路。 + +## Workspace 结构 + +```text +dbtool-cli-v1/ + Cargo.toml + README.md + DEVELOPING.md + apps/ + cli/ + Cargo.toml + src/main.rs + crates/ + db-core/ + db-config/ + db-drivers/ +``` + +## 快速开始 + +```bash +cargo run -p dbtool-cli -- --help +export DBTOOL_PASSWORD=dbtool +cargo run -p dbtool-cli -- connect --driver postgres --host 127.0.0.1 --port 55432 --database dbtool_demo --username dbtool --password-env DBTOOL_PASSWORD +cargo run -p dbtool-cli -- inspect --driver postgres --host 127.0.0.1 --port 55432 --database dbtool_demo --username dbtool --password-env DBTOOL_PASSWORD --schema qa_demo +cargo run -p dbtool-cli -- query --driver postgres --host 127.0.0.1 --port 55432 --database dbtool_demo --username dbtool --password-env DBTOOL_PASSWORD --file examples/sql/postgres/happy_path_query.sql +cargo run -p dbtool-cli -- connect --driver mysql --host 127.0.0.1 --port 53306 --database qa_demo --username dbtool --password-env DBTOOL_PASSWORD +cargo run -p dbtool-cli -- inspect --driver mysql --host 127.0.0.1 --port 53306 --database qa_demo --username dbtool --password-env DBTOOL_PASSWORD --schema qa_demo +cargo run -p dbtool-cli -- query --driver mysql --host 127.0.0.1 --port 53306 --database qa_demo --username dbtool --password-env DBTOOL_PASSWORD --file examples/sql/mysql/happy_path_query.sql +cargo run -p dbtool-cli -- connect --driver sqlite --path examples/tmp/dbtool-demo.sqlite +cargo run -p dbtool-cli -- inspect --driver sqlite --path examples/tmp/dbtool-demo.sqlite --schema main +cargo run -p dbtool-cli -- query --driver sqlite --path examples/tmp/dbtool-demo.sqlite --file examples/sql/sqlite/happy_path_query.sql +cargo run -p dbtool-cli -- export --driver sqlite --path examples/tmp/dbtool-demo.sqlite --file examples/sql/sqlite/export_query.sql --format csv --output examples/tmp/export.csv +``` + +如果当前 Linux 环境没有系统 `cc`,可以改用一个 C 兼容 linker,例如 `zig cc`: + +```bash +cat > /tmp/zig-cc <<'EOF' +#!/bin/sh +exec zig cc "$@" +EOF +chmod +x /tmp/zig-cc +export CC=/tmp/zig-cc +export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=/tmp/zig-cc +cargo test +cargo run -p dbtool-cli -- --help +``` + +最小发布与产物 smoke 已定义在 `.github/workflows/release-smoke.yml` 与 `RELEASE_RUNBOOK.md`;当前 Linux 还可本地执行 `scripts/release/smoke-binary.sh` 和 `scripts/release/package-unix.sh` 复核产物命名与 checksum。 + +Git 仓库初始化、agent 提交规范与远程接入方式见 `GIT_WORKFLOW.md`。 + +## PostgreSQL / MySQL / SQLite 行为说明 + +- 非参数化 `query` 会走 PostgreSQL simple query 协议,因此支持包含多条语句的 `.sql` 文件,例如 `SET search_path ...; SELECT ...` +- 非参数化 `query` 在 MySQL 上走 text protocol,因此也支持包含多条语句的 `.sql` 文件,例如 `USE qa_demo; SELECT ...` +- 带 `--param` 的 `query` 会切换到 prepared execution,当前要求 SQL 为单条参数化语句 +- 凭据只通过 `--password-env` 注入,错误和成功输出都不会回显密码 +- MySQL `inspect` 里的 `schema` 参数当前映射到数据库名;顶层 `inspect` 返回的是数据库列表 +- SQLite 顶层 `inspect` 返回附加数据库列表,demo happy path 使用 `main`;`--schema main` 会列出表,`--schema main --table ` 会列出列 +- SQLite 当前使用单条 statement 执行模型;若传入多语句 SQL,会明确返回 `Multiple statements provided` +- `export` 只接受返回结果集的查询;若 SQL 只有 rows affected 而没有结果集,会明确报错 +- `export` 不会静默覆盖已有文件,且会在父目录不存在时给出显式下一步提示 + +## 设计原则 + +- CLI、未来 GUI 与自动化场景共享同一套核心语义 +- 先稳定领域边界、错误模型和可测试输入校验 +- 驱动差异留在 `db-drivers`,不污染公共 crate + +## 下一步 + +- `CMP-12`:在此 workspace 基线之上接入跨平台打包与 smoke diff --git a/RELEASE_RUNBOOK.md b/RELEASE_RUNBOOK.md new file mode 100644 index 0000000..409ff60 --- /dev/null +++ b/RELEASE_RUNBOOK.md @@ -0,0 +1,83 @@ +# dbtool-cli-v1 Release Runbook + +## Purpose + +This runbook defines the smallest release artifact contract for `dbtool-cli-v1`. + +## Current Baseline + +- Release smoke is implemented in `.github/workflows/release-smoke.yml`. +- The workflow targets Linux, macOS, and Windows release runners. +- Each runner builds the release binary, runs `--help` and `--version`, packages one distributable archive, and emits a SHA256 checksum. +- Local validation in the current backend environment is limited to the Linux packaging path; macOS and Windows still require GitHub-hosted runners or equivalent CI workers. + +## Artifact Contract + +- Linux: `dbtool--x86_64-unknown-linux-gnu.tar.gz` +- macOS: `dbtool--x86_64-apple-darwin.tar.gz` +- Windows: `dbtool--x86_64-pc-windows-msvc.zip` +- Checksum: `.sha256` + +Each packaged artifact contains: + +- `dbtool` or `dbtool.exe` +- `README.md` +- `RELEASE_RUNBOOK.md` +- `SMOKE_RUNBOOK.md` + +## CI Steps + +Each matrix entry in `.github/workflows/release-smoke.yml` performs: + +1. install stable Rust +2. `cargo build --release --bin dbtool` +3. smoke the binary with `--help` and `--version` +4. package one archive with the platform naming contract +5. generate a SHA256 sidecar file +6. upload the archive and checksum as workflow artifacts + +## Local Linux Validation + +In a Linux environment with the same linker workaround used by the repo: + +```bash +export HOME="$AGENT_HOME" +export CARGO_HOME="$AGENT_HOME/.cargo" +export RUSTUP_HOME="$AGENT_HOME/.rustup" +export PATH="$CARGO_HOME/bin:$PATH" +export CC="$AGENT_HOME/tools/zig-cc" +export AR="$AGENT_HOME/tools/zig-ar" +export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER="$AGENT_HOME/tools/zig-cc" + +cargo build --release --bin dbtool +scripts/release/smoke-binary.sh target/release/dbtool +scripts/release/package-unix.sh x86_64-unknown-linux-gnu target/release/dbtool +``` + +Expected outputs: + +- `dist/dbtool--x86_64-unknown-linux-gnu.tar.gz` +- `dist/dbtool--x86_64-unknown-linux-gnu.tar.gz.sha256` + +## QA Usage + +For a produced artifact, QA should: + +1. verify the SHA256 checksum +2. unpack the archive +3. run `dbtool --help` +4. run `dbtool --version` +5. follow `SMOKE_RUNBOOK.md` for database-backed validation in a Docker-capable environment + +## Rollback + +If a newly built artifact fails smoke or QA validation: + +1. stop distributing the new archive set +2. keep the failing archive and checksum for investigation +3. redirect consumers back to the most recent previously verified artifact set +4. record the failure cause, affected platform, and whether the issue is build-time, package-time, or runtime + +## Current Blocker + +This runbook cannot claim fully verified cross-platform release readiness until the workflow runs successfully on real Linux, macOS, and Windows runners and QA completes the Docker-backed smoke path. diff --git a/SMOKE_RUNBOOK.md b/SMOKE_RUNBOOK.md new file mode 100644 index 0000000..8a4472c --- /dev/null +++ b/SMOKE_RUNBOOK.md @@ -0,0 +1,126 @@ +# dbtool-cli-v1 Smoke Runbook + +## Purpose + +This runbook defines the smallest repeatable cross-database validation path for `connect`, `inspect`, `query`, and `export`. + +## Current Status + +- Demo databases and query fixtures are prepared. +- A repeatable Docker path exists for PostgreSQL and MySQL. +- 推荐执行环境已明确为“宿主机执行 + Docker Compose 临时数据库”,详见 `QA_RUNTIME_ENVIRONMENT.md`。 +- SQLite bootstrap exists for local execution. +- PostgreSQL `connect`, `inspect`, and `query` are now implemented in the Rust CLI. +- MySQL `connect`, `inspect`, and `query` are now implemented in the Rust CLI. +- SQLite `connect`, `inspect`, and `query` are now implemented in the Rust CLI. +- `export` to CSV / JSON is now implemented in the Rust CLI. +- Release artifact smoke is now defined separately in `RELEASE_RUNBOOK.md`. +- This runbook remains partially staged only because PostgreSQL / MySQL runtime smoke still requires a Docker-capable environment. + +## Preconditions + +- Docker with `docker compose` +- one of `sqlite3`, `node`, or `python3` for SQLite bootstrap +- compiled CLI binary from the Rust workspace baseline + +## Bootstrap + +### 1. Start PostgreSQL and MySQL + +```bash +docker compose -f docker-compose.demo.yml up -d postgres mysql +``` + +### 2. Seed PostgreSQL + +```bash +./examples/scripts/bootstrap-postgres.sh +``` + +Expected result: + +- database: `dbtool_demo` +- schema: `qa_demo` +- tables: `accounts`, `tickets` + +### 3. Seed MySQL + +```bash +./examples/scripts/bootstrap-mysql.sh +``` + +Expected result: + +- database: `qa_demo` +- tables: `accounts`, `tickets` +- user `dbtool` retains privileges on `qa_demo` + +### 4. Seed SQLite + +```bash +./examples/scripts/bootstrap-sqlite.sh +``` + +Expected result: + +- file: `examples/tmp/dbtool-demo.sqlite` +- tables: `accounts`, `tickets` + +## Smoke Inputs + +### PostgreSQL + +- connect target: host `127.0.0.1`, port `55432`, database `dbtool_demo`, user `dbtool` +- inspect scope: schema `qa_demo` +- happy query: `examples/sql/postgres/happy_path_query.sql` +- export query: `examples/sql/postgres/export_query.sql` +- failure query: `examples/sql/postgres/failing_query.sql` + +### MySQL + +- connect target: host `127.0.0.1`, port `53306`, database `qa_demo`, user `dbtool` +- inspect scope: database `qa_demo` +- happy query: `examples/sql/mysql/happy_path_query.sql` +- export query: `examples/sql/mysql/export_query.sql` +- failure query: `examples/sql/mysql/failing_query.sql` + +### SQLite + +- connect target: `examples/tmp/dbtool-demo.sqlite` +- inspect scope: schema `main` +- happy query: `examples/sql/sqlite/happy_path_query.sql` +- export query: `examples/sql/sqlite/export_query.sql` +- failure query: `examples/sql/sqlite/failing_query.sql` + +## Required Assertions + +Use `examples/fixtures/EXPECTED_RESULTS.md` as the shared oracle. + +- `connect` succeeds for each valid target +- `inspect` reveals `accounts` and `tickets` +- `query` returns `3` account rows in the happy-path query +- `export` returns `4` ticket rows in deterministic order +- failure query exits non-zero and identifies a missing object +- secrets do not appear in terminal errors + +## Failure Paths To Capture + +- PostgreSQL wrong password +- PostgreSQL unreachable port +- MySQL wrong password +- SQLite missing file +- invalid SQL from `failing_query.sql` +- export path collision once the CLI contract exists + +## Cleanup + +```bash +docker compose -f docker-compose.demo.yml down -v +rm -f examples/tmp/dbtool-demo.sqlite +``` + +## Blocker + +This runbook cannot be marked passed until engineering lands: + +- full runtime smoke on a Docker-capable host as described in `QA_RUNTIME_ENVIRONMENT.md` diff --git a/TEST_STRATEGY.md b/TEST_STRATEGY.md new file mode 100644 index 0000000..a442c9b --- /dev/null +++ b/TEST_STRATEGY.md @@ -0,0 +1,74 @@ +# dbtool-cli-v1 测试策略(初版) + +## 目标 + +- 为 CLI 第一版建立可重复、可说明、可交付的验收方式。 +- 在代码落地前先建立 QA 基线,避免后期以“本地能跑”代替“满足验收”。 + +## 当前盘点结果 + +- 当前已观察到 Rust workspace、CLI 二进制、release artifact、GitHub release-smoke workflow、demo seed SQL、query fixture、bootstrap 脚本和 smoke runbook。 +- 当前 agent 环境可直接执行本地 Linux + SQLite 的运行型验证。 +- 当前 agent 环境有 `node v24.14.0` 与预编译 `target/release/dbtool`,因此 SQLite bootstrap 与本地二进制 smoke 可重复执行。 +- 当前 agent 环境仍缺 `docker`,因此 PostgreSQL / MySQL 运行时 smoke 不能在此本地复现。 +- 当前 agent 环境也缺 `cargo`,因此 Docker-capable host 若无 Rust toolchain,需改用已构建二进制或 release artifact 执行 smoke。 +- 当前 agent 环境也无法替代 GitHub 的 macOS / Windows runner 证据。 +- 2026-03-26 已补做 SQLite non-db file 回归:`connect` 现在会在前置检查阶段失败,不再误报成功。 + +## 测试优先级 + +1. 工作区与可执行入口存在 +2. 三种数据库的连接成功 / 失败路径 +3. schema introspection +4. 查询执行 +5. 导出流程 +6. 跨平台差异与边界条件 + +## 测试层次 + +### 1. Smoke + +- 验证 CLI 可启动、可显示帮助、可做最小连接检查。 +- 必须能一条命令执行,且适合发布前快速复核。 + +### 2. 功能验收 + +- 按 PostgreSQL、MySQL、SQLite 分库执行 connect / inspect / query / export。 +- happy path 与失败路径都要保留证据。 + +### 3. 回归 + +- 对已修缺陷建立回归步骤。 +- 每次发布前至少回归:启动、连接、查询、导出、错误提示。 + +### 4. 边界条件 + +- 空数据库、空结果集、大结果集、非法路径、错误凭据、权限不足、Unicode、NULL、日期时间。 + +## 证据要求 + +- 每个通过项都需要命令、输入、输出摘要和环境信息。 +- 每个失败项都需要复现步骤、预期行为、实际行为和严重级别。 +- 未执行项必须明确标记原因,不能默认视为通过。 + +## 推荐交付资产 + +- 可复制的 README 或开发说明 +- demo / seed 脚本 +- smoke 脚本 +- Docker 运行方式 +- 跨数据库样例配置 +- 缺陷复现模板 + +## 当前明显缺口 + +- 缺 PostgreSQL / MySQL 的本地 Docker 执行证据 +- 缺 GitHub macOS / Windows release runner 的真实执行证据 +- 缺大结果集 / 超时 / 空结果导出等剩余边界证据 + +## 下一次验证入口 + +- 在 Docker 可用环境执行 PostgreSQL / MySQL `connect` / `inspect` / `query` / `export` smoke;若宿主无 `cargo`,直接使用 `target/release/dbtool` 或 release artifact。 +- 继续补齐 SQLite missing file 与其余边界路径证据。 +- 在 CI 或等价 runner 上收集 Linux / macOS / Windows release artifact 证据。 +- 将新增结果持续回填到验收检查表和发布前清单。 diff --git a/apps/cli/Cargo.toml b/apps/cli/Cargo.toml new file mode 100644 index 0000000..f895e93 --- /dev/null +++ b/apps/cli/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "dbtool-cli" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +publish = false + +[[bin]] +name = "dbtool" +path = "src/main.rs" + +[dependencies] +db-config.workspace = true +db-core.workspace = true +db-drivers.workspace = true + +[dev-dependencies] +rusqlite.workspace = true + +[lints] +workspace = true diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs new file mode 100644 index 0000000..8d10231 --- /dev/null +++ b/apps/cli/src/main.rs @@ -0,0 +1,1069 @@ +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process::ExitCode; + +use db_config::ConnectionProfile; +use db_core::{ + ColumnSummary, ConnectionTarget, ConnectionTransport, ExportFormat, ExportRequest, + InspectRequest, InspectResult, QueryRequest, QueryResult, QuerySource, TableSummary, +}; +use db_drivers::DriverRegistry; + +fn main() -> ExitCode { + match run(env::args().skip(1).collect()) { + Ok(message) => { + println!("{message}"); + ExitCode::SUCCESS + } + Err(message) => { + eprintln!("{message}"); + ExitCode::from(2) + } + } +} + +fn run(args: Vec) -> Result { + if args.is_empty() { + return Ok(String::from( + "dbtool workspace baseline is ready; use --help to inspect available commands.", + )); + } + + match args[0].as_str() { + "--help" | "-h" => Ok(root_help()), + "--version" | "-V" => Ok(String::from(env!("CARGO_PKG_VERSION"))), + "connect" => { + if has_help_flag(&args[1..]) { + return Ok(command_help("connect")); + } + + let profile = + profile_from_target_args("adhoc-connect", parse_target_args(&args[1..])?)?; + DriverRegistry::connect(&profile.target).map_err(|error| error.to_string())?; + + Ok(format!("connected {}", profile.redacted_summary())) + } + "inspect" => { + if has_help_flag(&args[1..]) { + return Ok(command_help("inspect")); + } + + let parsed = parse_inspect_args(&args[1..])?; + let profile = profile_from_target_args("adhoc-inspect", parsed.target)?; + let request = InspectRequest::new(parsed.schema, parsed.table) + .map_err(|error| error.to_string())?; + let result = DriverRegistry::inspect(&profile.target, &request) + .map_err(|error| error.to_string())?; + + Ok(render_inspect_result(&profile, result)) + } + "query" => { + if has_help_flag(&args[1..]) { + return Ok(command_help("query")); + } + + let parsed = parse_query_args(&args[1..])?; + let profile = profile_from_target_args("adhoc-query", parsed.target)?; + let request = query_request(parsed.sql, parsed.file, parsed.params)?; + let result = DriverRegistry::query(&profile.target, &request) + .map_err(|error| error.to_string())?; + + Ok(render_query_result(&profile, &request, result)) + } + "export" => { + if has_help_flag(&args[1..]) { + return Ok(command_help("export")); + } + + let parsed = parse_export_args(&args[1..])?; + let profile = profile_from_target_args("adhoc-export", parsed.query.target)?; + let request = query_request(parsed.query.sql, parsed.query.file, parsed.query.params)?; + let export = ExportRequest::new(parsed.format, parsed.output, parsed.overwrite) + .map_err(|error| error.to_string())?; + + validate_export_path(&export)?; + + let result = DriverRegistry::query(&profile.target, &request) + .map_err(|error| error.to_string())?; + validate_export_result(&result)?; + write_export(&export, &result)?; + + Ok(format!( + "exported {} rows from {} to {}", + row_count(&result), + profile.redacted_summary(), + export.output_path.display() + )) + } + other => Err(format!("unknown command `{other}`\n\n{}", root_help())), + } +} + +#[derive(Clone, Debug, Default)] +struct TargetArgs { + driver: Option, + host: Option, + port: Option, + database: Option, + username: Option, + password_env: Option, + path: Option, +} + +#[derive(Debug)] +struct InspectArgs { + target: TargetArgs, + schema: Option, + table: Option, +} + +#[derive(Debug)] +struct QueryArgs { + target: TargetArgs, + sql: Option, + file: Option, + params: Vec, +} + +#[derive(Debug)] +struct ExportArgs { + query: QueryArgs, + format: ExportFormat, + output: PathBuf, + overwrite: bool, +} + +#[derive(Clone, Copy, Debug)] +enum DriverArg { + Postgres, + Mysql, + Sqlite, +} + +fn has_help_flag(args: &[String]) -> bool { + args.iter() + .any(|arg| matches!(arg.as_str(), "--help" | "-h")) +} + +fn root_help() -> String { + String::from( + "dbtool 0.1.0\n\ +Cross-database CLI for PostgreSQL, MySQL, and SQLite\n\n\ +USAGE:\n dbtool [OPTIONS]\n\n\ +COMMANDS:\n connect Connect and ping a target\n inspect List schemas, tables, or columns\n query Execute SQL inline or from a file\n export Export query results to CSV or JSON\n\n\ +COMMAND NOTES:\n inspect without --schema lists schemas; with --schema lists tables; with --schema and --table lists columns\n\n\ +GLOBAL FLAGS:\n -h, --help Show help\n -V, --version Show version\n", + ) +} + +fn command_help(command: &str) -> String { + match command { + "connect" => String::from( + "USAGE:\n dbtool connect --driver [TARGET OPTIONS]\n\n\ +TARGET OPTIONS:\n --driver \n --host \n --port \n --database \n --username \n --password-env \n --path \n", + ), + "inspect" => String::from( + "USAGE:\n dbtool inspect --driver [TARGET OPTIONS] [--schema ] [--table
]\n\n\ +TARGET OPTIONS:\n --driver \n --host \n --port \n --database \n --username \n --password-env \n --path \n", + ), + "query" => String::from( + "USAGE:\n dbtool query --driver [TARGET OPTIONS] (--sql | --file ) [--param ...]\n\n\ +TARGET OPTIONS:\n --driver \n --host \n --port \n --database \n --username \n --password-env \n --path \n", + ), + "export" => String::from( + "USAGE:\n dbtool export --driver [TARGET OPTIONS] (--sql | --file ) [--param ...] --format --output [--overwrite]\n\n\ +TARGET OPTIONS:\n --driver \n --host \n --port \n --database \n --username \n --password-env \n --path \n", + ), + _ => root_help(), + } +} + +fn profile_from_target_args(name: &str, args: TargetArgs) -> Result { + let password_env_var = args.password_env.clone(); + let target = args.into_target().map_err(|error| error.to_string())?; + let password = password_env_var + .as_ref() + .map(|env_name| { + env::var(env_name).map_err(|_| format!("missing password env `{env_name}`")) + }) + .transpose()?; + + let profile = ConnectionProfile::new(name, target, password_env_var) + .map_err(|error| error.to_string())?; + + Ok(profile.with_password(password)) +} + +fn parse_target_args(args: &[String]) -> Result { + let mut parsed = TargetArgs::default(); + let mut index = 0; + + while index < args.len() { + let flag = args[index].as_str(); + let value = args + .get(index + 1) + .filter(|next| !next.starts_with("--")) + .cloned(); + + match flag { + "--driver" => { + let driver = value.ok_or(String::from("missing value for --driver"))?; + parsed.driver = Some(parse_driver(&driver)?); + index += 2; + } + "--host" => { + parsed.host = Some(value.ok_or(String::from("missing value for --host"))?); + index += 2; + } + "--port" => { + let raw = value.ok_or(String::from("missing value for --port"))?; + parsed.port = Some( + raw.parse::() + .map_err(|_| format!("invalid port `{raw}`"))?, + ); + index += 2; + } + "--database" => { + parsed.database = Some(value.ok_or(String::from("missing value for --database"))?); + index += 2; + } + "--username" => { + parsed.username = Some(value.ok_or(String::from("missing value for --username"))?); + index += 2; + } + "--password-env" => { + parsed.password_env = + Some(value.ok_or(String::from("missing value for --password-env"))?); + index += 2; + } + "--path" => { + parsed.path = Some(PathBuf::from( + value.ok_or(String::from("missing value for --path"))?, + )); + index += 2; + } + other => return Err(format!("unknown option `{other}`")), + } + } + + Ok(parsed) +} + +fn parse_inspect_args(args: &[String]) -> Result { + let mut schema = None; + let mut table = None; + let mut target_tokens = Vec::new(); + let mut index = 0; + + while index < args.len() { + match args[index].as_str() { + "--schema" => { + schema = Some( + args.get(index + 1) + .ok_or(String::from("missing value for --schema"))? + .clone(), + ); + index += 2; + } + "--table" => { + table = Some( + args.get(index + 1) + .ok_or(String::from("missing value for --table"))? + .clone(), + ); + index += 2; + } + flag => { + target_tokens.push(String::from(flag)); + if let Some(next) = args.get(index + 1) { + if !next.starts_with("--") { + target_tokens.push(next.clone()); + index += 2; + continue; + } + } + index += 1; + } + } + } + + Ok(InspectArgs { + target: parse_target_args(&target_tokens)?, + schema, + table, + }) +} + +fn parse_query_args(args: &[String]) -> Result { + let mut sql = None; + let mut file = None; + let mut params = Vec::new(); + let mut target_tokens = Vec::new(); + let mut index = 0; + + while index < args.len() { + match args[index].as_str() { + "--sql" => { + sql = Some( + args.get(index + 1) + .ok_or(String::from("missing value for --sql"))? + .clone(), + ); + index += 2; + } + "--file" => { + file = Some(PathBuf::from( + args.get(index + 1) + .ok_or(String::from("missing value for --file"))? + .clone(), + )); + index += 2; + } + "--param" => { + params.push( + args.get(index + 1) + .ok_or(String::from("missing value for --param"))? + .clone(), + ); + index += 2; + } + flag => { + target_tokens.push(String::from(flag)); + if let Some(next) = args.get(index + 1) { + if !next.starts_with("--") { + target_tokens.push(next.clone()); + index += 2; + continue; + } + } + index += 1; + } + } + } + + Ok(QueryArgs { + target: parse_target_args(&target_tokens)?, + sql, + file, + params, + }) +} + +fn parse_export_args(args: &[String]) -> Result { + let mut format = None; + let mut output = None; + let mut overwrite = false; + let mut query_tokens = Vec::new(); + let mut index = 0; + + while index < args.len() { + match args[index].as_str() { + "--format" => { + let raw = args + .get(index + 1) + .ok_or(String::from("missing value for --format"))?; + format = Some(parse_export_format(raw)?); + index += 2; + } + "--output" => { + output = Some(PathBuf::from( + args.get(index + 1) + .ok_or(String::from("missing value for --output"))? + .clone(), + )); + index += 2; + } + "--overwrite" => { + overwrite = true; + index += 1; + } + flag => { + query_tokens.push(String::from(flag)); + if let Some(next) = args.get(index + 1) { + if !next.starts_with("--") { + query_tokens.push(next.clone()); + index += 2; + continue; + } + } + index += 1; + } + } + } + + Ok(ExportArgs { + query: parse_query_args(&query_tokens)?, + format: format.ok_or(String::from("missing value for --format"))?, + output: output.ok_or(String::from("missing value for --output"))?, + overwrite, + }) +} + +fn query_request( + sql: Option, + file: Option, + params: Vec, +) -> Result { + match (sql, file) { + (Some(sql), None) => { + QueryRequest::new(sql, QuerySource::Inline, params).map_err(|error| error.to_string()) + } + (None, Some(path)) => { + let sql = fs::read_to_string(&path) + .map_err(|error| format!("failed to read SQL file {}: {error}", path.display()))?; + QueryRequest::new(sql, QuerySource::File(path), params) + .map_err(|error| error.to_string()) + } + _ => Err(String::from( + "query request is invalid: provide exactly one of --sql or --file", + )), + } +} + +fn parse_driver(raw: &str) -> Result { + match raw { + "postgres" => Ok(DriverArg::Postgres), + "mysql" => Ok(DriverArg::Mysql), + "sqlite" => Ok(DriverArg::Sqlite), + _ => Err(format!("unsupported driver `{raw}`")), + } +} + +fn parse_export_format(raw: &str) -> Result { + match raw { + "csv" => Ok(ExportFormat::Csv), + "json" => Ok(ExportFormat::Json), + _ => Err(format!("unsupported export format `{raw}`")), + } +} + +impl TargetArgs { + fn into_target(self) -> Result { + let kind = self + .driver + .ok_or(db_core::CoreError::InvalidConnection("missing --driver"))? + .into(); + + let transport = match kind { + db_core::DatabaseKind::Sqlite => ConnectionTransport::File { + path: self.path.ok_or(db_core::CoreError::InvalidConnection( + "sqlite requires --path", + ))?, + }, + _ => ConnectionTransport::Tcp { + host: self.host.ok_or(db_core::CoreError::InvalidConnection( + "network drivers require --host", + ))?, + port: self.port.ok_or(db_core::CoreError::InvalidConnection( + "network drivers require --port", + ))?, + }, + }; + + ConnectionTarget::new(kind, transport, self.database, self.username) + } +} + +impl From for db_core::DatabaseKind { + fn from(value: DriverArg) -> Self { + match value { + DriverArg::Postgres => Self::Postgres, + DriverArg::Mysql => Self::Mysql, + DriverArg::Sqlite => Self::Sqlite, + } + } +} + +fn render_inspect_result(profile: &ConnectionProfile, result: InspectResult) -> String { + match result { + InspectResult::Schemas(schemas) => { + if schemas.is_empty() { + return format!("no schemas found for {}", profile.redacted_summary()); + } + + let names = schemas + .into_iter() + .map(|schema| schema.name) + .collect::>() + .join("\n"); + format!("schemas for {}\n{names}", profile.redacted_summary()) + } + InspectResult::Tables(tables) => { + if tables.is_empty() { + return format!("no tables found for {}", profile.redacted_summary()); + } + + let body = tables + .into_iter() + .map(render_table) + .collect::>() + .join("\n"); + format!("tables for {}\n{body}", profile.redacted_summary()) + } + InspectResult::Columns(columns) => { + if columns.is_empty() { + return format!("no columns found for {}", profile.redacted_summary()); + } + + let body = columns + .into_iter() + .map(render_column) + .collect::>() + .join("\n"); + format!("columns for {}\n{body}", profile.redacted_summary()) + } + } +} + +fn render_query_result( + profile: &ConnectionProfile, + request: &QueryRequest, + result: QueryResult, +) -> String { + if let Some(rows_affected) = result.rows_affected { + return format!( + "query succeeded on {} from {} (rows affected: {})", + profile.redacted_summary(), + request.source.label(), + rows_affected + ); + } + + let mut lines = Vec::new(); + lines.push(format!( + "query succeeded on {} from {}", + profile.redacted_summary(), + request.source.label() + )); + lines.push(result.columns.join("\t")); + if result.rows.is_empty() { + lines.push(String::from("(0 rows)")); + } else { + lines.extend(result.rows.into_iter().map(|row| row.join("\t"))); + } + lines.join("\n") +} + +fn render_table(table: TableSummary) -> String { + format!("{}\t{}\t{}", table.schema, table.name, table.kind) +} + +fn render_column(column: ColumnSummary) -> String { + format!( + "{}\t{}\tnullable={}\tprimary_key={}", + column.name, column.data_type, column.nullable, column.primary_key + ) +} + +fn write_export(export: &ExportRequest, result: &QueryResult) -> Result<(), String> { + let body = render_export_body(export.format, result); + + fs::write(&export.output_path, body).map_err(|error| { + format!( + "failed to write export {}: {error}", + export.output_path.display() + ) + }) +} + +fn validate_export_path(export: &ExportRequest) -> Result<(), String> { + if export.output_path.exists() && !export.overwrite { + return Err(format!( + "export request is invalid: output file {} already exists; pass --overwrite to replace it", + export.output_path.display() + )); + } + + if export.output_path.is_dir() { + return Err(format!( + "export request is invalid: output path {} is a directory; provide a file path", + export.output_path.display() + )); + } + + if let Some(parent) = export.output_path.parent() { + if !parent.as_os_str().is_empty() && !parent.exists() { + return Err(format!( + "export request is invalid: parent directory {} does not exist; create it first or choose another output path", + parent.display() + )); + } + } + + Ok(()) +} + +fn validate_export_result(result: &QueryResult) -> Result<(), String> { + if result.rows_affected.is_some() { + return Err(String::from( + "export requires a row-returning query; the provided SQL completed without a result set", + )); + } + + Ok(()) +} + +fn render_export_body(format: ExportFormat, result: &QueryResult) -> String { + match format { + ExportFormat::Csv => render_csv(result), + ExportFormat::Json => render_json(result), + } +} + +fn render_csv(result: &QueryResult) -> String { + let mut lines = Vec::new(); + lines.push(result.columns.join(",")); + lines.extend(result.rows.iter().map(|row| { + row.iter() + .map(|value| escape_csv(value)) + .collect::>() + .join(",") + })); + + if lines.is_empty() { + String::new() + } else { + format!("{}\n", lines.join("\n")) + } +} + +fn escape_csv(value: &str) -> String { + if value.contains(',') || value.contains('"') || value.contains('\n') { + format!("\"{}\"", value.replace('"', "\"\"")) + } else { + value.to_string() + } +} + +fn render_json(result: &QueryResult) -> String { + let rows = result + .rows + .iter() + .map(|row| { + let fields = result + .columns + .iter() + .zip(row.iter()) + .map(|(column, value)| { + format!("\"{}\":\"{}\"", escape_json(column), escape_json(value)) + }) + .collect::>() + .join(","); + format!(" {{{fields}}}") + }) + .collect::>() + .join(",\n"); + + if rows.is_empty() { + String::from("[]\n") + } else { + format!("[\n{rows}\n]\n") + } +} + +fn escape_json(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") +} + +fn row_count(result: &QueryResult) -> usize { + result.rows.len() +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection as SqliteConnection; + use std::env; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + #[cfg(unix)] + use std::{fs::Permissions, os::unix::fs::PermissionsExt}; + + #[test] + fn parse_query_args_collects_params() { + let args = vec![ + String::from("--driver"), + String::from("postgres"), + String::from("--host"), + String::from("127.0.0.1"), + String::from("--port"), + String::from("55432"), + String::from("--database"), + String::from("dbtool_demo"), + String::from("--username"), + String::from("dbtool"), + String::from("--sql"), + String::from("select $1, $2"), + String::from("--param"), + String::from("alpha"), + String::from("--param"), + String::from("beta"), + ]; + + let parsed = parse_query_args(&args).expect("query args should parse"); + + assert_eq!(parsed.sql.as_deref(), Some("select $1, $2")); + assert_eq!( + parsed.params, + vec![String::from("alpha"), String::from("beta")] + ); + assert!(matches!(parsed.target.driver, Some(DriverArg::Postgres))); + assert_eq!(parsed.target.port, Some(55432)); + } + + #[test] + fn query_request_reads_sql_file_contents() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos(); + let path = env::temp_dir().join(format!("dbtool-query-{unique}.sql")); + fs::write(&path, "select 42;\n").expect("temp SQL file should be written"); + + let request = + query_request(None, Some(path.clone()), Vec::new()).expect("request should parse"); + + assert_eq!(request.sql, "select 42;\n"); + assert_eq!(request.source, QuerySource::File(path.clone())); + + fs::remove_file(path).expect("temp SQL file should be removed"); + } + + #[test] + fn parse_inspect_args_keeps_schema_and_table() { + let args = vec![ + String::from("--driver"), + String::from("postgres"), + String::from("--host"), + String::from("127.0.0.1"), + String::from("--port"), + String::from("55432"), + String::from("--database"), + String::from("dbtool_demo"), + String::from("--username"), + String::from("dbtool"), + String::from("--schema"), + String::from("qa_demo"), + String::from("--table"), + String::from("accounts"), + ]; + + let parsed = parse_inspect_args(&args).expect("inspect args should parse"); + + assert_eq!(parsed.schema.as_deref(), Some("qa_demo")); + assert_eq!(parsed.table.as_deref(), Some("accounts")); + } + + #[test] + fn sqlite_cli_happy_path_runs_against_seeded_database() { + let path = temp_sqlite_db_path("dbtool-cli-sqlite"); + let connection = + SqliteConnection::open(&path).expect("temporary sqlite database should open"); + connection + .execute_batch(include_str!("../../../examples/fixtures/sqlite/init.sql")) + .expect("sqlite fixture should seed"); + drop(connection); + + let connect_output = run(vec![ + String::from("connect"), + String::from("--driver"), + String::from("sqlite"), + String::from("--path"), + path.display().to_string(), + ]) + .expect("sqlite connect should succeed"); + assert!(connect_output.contains("connected sqlite://")); + + let inspect_output = run(vec![ + String::from("inspect"), + String::from("--driver"), + String::from("sqlite"), + String::from("--path"), + path.display().to_string(), + String::from("--schema"), + String::from("main"), + ]) + .expect("sqlite inspect should succeed"); + assert!(inspect_output.contains("main\taccounts\ttable")); + assert!(inspect_output.contains("main\ttickets\ttable")); + + let query_output = run(vec![ + String::from("query"), + String::from("--driver"), + String::from("sqlite"), + String::from("--path"), + path.display().to_string(), + String::from("--sql"), + String::from("SELECT COUNT(*) AS count FROM tickets WHERE status = ?1"), + String::from("--param"), + String::from("open"), + ]) + .expect("sqlite query should succeed"); + assert!(query_output.contains("count")); + assert!(query_output.contains("\n3")); + + fs::remove_file(path).expect("temporary sqlite database should be removed"); + } + + #[test] + fn sqlite_connect_rejects_non_database_file() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos(); + let path = env::temp_dir().join(format!("dbtool-cli-not-a-db-{unique}.txt")); + fs::write(&path, "not a database\n").expect("temporary text file should be written"); + + let error = run(vec![ + String::from("connect"), + String::from("--driver"), + String::from("sqlite"), + String::from("--path"), + path.display().to_string(), + ]) + .expect_err("sqlite connect should fail for text files"); + + assert!(error.contains("file is not a database")); + + fs::remove_file(path).expect("temporary text file should be removed"); + } + + #[test] + fn sqlite_connect_rejects_empty_file() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos(); + let path = env::temp_dir().join(format!("dbtool-cli-empty-db-{unique}.sqlite")); + fs::write(&path, []).expect("temporary empty file should be written"); + + let error = run(vec![ + String::from("connect"), + String::from("--driver"), + String::from("sqlite"), + String::from("--path"), + path.display().to_string(), + ]) + .expect_err("sqlite connect should fail for empty files"); + + assert!(error.contains("file is not a database")); + + fs::remove_file(path).expect("temporary empty file should be removed"); + } + + #[test] + fn sqlite_connect_rejects_missing_file() { + let path = temp_sqlite_db_path("dbtool-cli-missing-db"); + + let error = run(vec![ + String::from("connect"), + String::from("--driver"), + String::from("sqlite"), + String::from("--path"), + path.display().to_string(), + ]) + .expect_err("sqlite connect should fail for missing files"); + + assert!(error.contains("sqlite database file does not exist")); + } + + #[cfg(unix)] + #[test] + fn sqlite_connect_rejects_permission_denied_file() { + let path = temp_sqlite_db_path("dbtool-cli-permission-db"); + let connection = + SqliteConnection::open(&path).expect("temporary sqlite database should open"); + connection + .execute("CREATE TABLE healthcheck (id INTEGER PRIMARY KEY)", []) + .expect("sqlite fixture should initialize"); + drop(connection); + fs::set_permissions(&path, Permissions::from_mode(0o000)) + .expect("permissions should be updated"); + + let error = run(vec![ + String::from("connect"), + String::from("--driver"), + String::from("sqlite"), + String::from("--path"), + path.display().to_string(), + ]) + .expect_err("sqlite connect should fail for unreadable files"); + + assert!(error.contains("permission denied opening sqlite database file")); + + fs::set_permissions(&path, Permissions::from_mode(0o600)) + .expect("permissions should be restorable"); + fs::remove_file(path).expect("temporary sqlite database should be removed"); + } + + #[test] + fn export_csv_escapes_commas_quotes_and_newlines() { + let body = render_export_body( + ExportFormat::Csv, + &QueryResult::rows( + vec![String::from("title"), String::from("notes")], + vec![vec![ + String::from("emoji smoke 😀"), + String::from("line one,\n\"line two\""), + ]], + ), + ); + + assert_eq!( + body, + "title,notes\nemoji smoke 😀,\"line one,\n\"\"line two\"\"\"\n" + ); + } + + #[test] + fn export_json_escapes_quotes_and_newlines() { + let body = render_export_body( + ExportFormat::Json, + &QueryResult::rows( + vec![String::from("title"), String::from("notes")], + vec![vec![ + String::from("csv export mismatch"), + String::from("line one\n\"line two\""), + ]], + ), + ); + + assert_eq!( + body, + "[\n {\"title\":\"csv export mismatch\",\"notes\":\"line one\\n\\\"line two\\\"\"}\n]\n" + ); + } + + #[test] + fn export_requires_existing_parent_directory() { + let base = temp_sqlite_db_path("dbtool-export-parent"); + let output = base.join("missing").join("result.csv"); + let export = + ExportRequest::new(ExportFormat::Csv, output.clone(), false).expect("export valid"); + + let error = validate_export_path(&export).expect_err("missing parent should fail"); + + assert_eq!( + error, + format!( + "export request is invalid: parent directory {} does not exist; create it first or choose another output path", + output + .parent() + .expect("output should have parent") + .display() + ) + ); + } + + #[test] + fn export_rejects_non_row_returning_results() { + let error = validate_export_result(&QueryResult::command(2)) + .expect_err("command result should not be exportable"); + + assert_eq!( + error, + "export requires a row-returning query; the provided SQL completed without a result set" + ); + } + + #[test] + fn sqlite_export_writes_csv_and_json_and_requires_overwrite() { + let path = temp_sqlite_db_path("dbtool-cli-export"); + let connection = + SqliteConnection::open(&path).expect("temporary sqlite database should open"); + connection + .execute_batch(include_str!("../../../examples/fixtures/sqlite/init.sql")) + .expect("sqlite fixture should seed"); + drop(connection); + + let output_dir = path + .parent() + .expect("temp sqlite db should have parent") + .join(format!( + "dbtool-export-out-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos() + )); + fs::create_dir_all(&output_dir).expect("output dir should be created"); + let csv_path = output_dir.join("tickets.csv"); + let json_path = output_dir.join("tickets.json"); + let export_sql = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../examples/sql/sqlite/export_query.sql"); + + let csv_output = run(vec![ + String::from("export"), + String::from("--driver"), + String::from("sqlite"), + String::from("--path"), + path.display().to_string(), + String::from("--file"), + export_sql.display().to_string(), + String::from("--format"), + String::from("csv"), + String::from("--output"), + csv_path.display().to_string(), + ]) + .expect("sqlite csv export should succeed"); + assert!(csv_output.contains("exported 4 rows")); + + let csv_body = fs::read_to_string(&csv_path).expect("csv export should be readable"); + assert!(csv_body.starts_with("ticket_id,email,title,status,amount_cents,created_at\n")); + assert!(csv_body.contains("103,maria@example.com,权限验证,open,300,2026-03-22T15:30:00Z\n")); + + let overwrite_error = run(vec![ + String::from("export"), + String::from("--driver"), + String::from("sqlite"), + String::from("--path"), + path.display().to_string(), + String::from("--file"), + export_sql.display().to_string(), + String::from("--format"), + String::from("csv"), + String::from("--output"), + csv_path.display().to_string(), + ]) + .expect_err("second export without overwrite should fail"); + assert!(overwrite_error.contains("pass --overwrite to replace it")); + + let json_output = run(vec![ + String::from("export"), + String::from("--driver"), + String::from("sqlite"), + String::from("--path"), + path.display().to_string(), + String::from("--file"), + export_sql.display().to_string(), + String::from("--format"), + String::from("json"), + String::from("--output"), + json_path.display().to_string(), + ]) + .expect("sqlite json export should succeed"); + assert!(json_output.contains("exported 4 rows")); + + let json_body = fs::read_to_string(&json_path).expect("json export should be readable"); + assert!(json_body.contains("\"ticket_id\":\"104\"")); + assert!(json_body.contains("\"title\":\"emoji smoke 😀\"")); + assert!(json_body.ends_with("]\n")); + + fs::remove_file(&csv_path).expect("csv export should be removed"); + fs::remove_file(&json_path).expect("json export should be removed"); + fs::remove_dir(&output_dir).expect("output dir should be removed"); + fs::remove_file(path).expect("temporary sqlite database should be removed"); + } + + fn temp_sqlite_db_path(prefix: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos(); + env::temp_dir().join(format!("{prefix}-{unique}.sqlite")) + } +} diff --git a/backlog/first-wave-issues.md b/backlog/first-wave-issues.md new file mode 100644 index 0000000..f459ef2 --- /dev/null +++ b/backlog/first-wave-issues.md @@ -0,0 +1,33 @@ +# dbtool-cli-v1 第一轮工程 backlog + +日期:2026-03-25 + +## 已分配 + +- 后端:`CMP-4` Rust workspace 初始化 +- 后端:`CMP-5` PostgreSQL 驱动 +- 后端:`CMP-6` MySQL 驱动 +- 后端:`CMP-7` SQLite 驱动 +- 后端:`CMP-8` CSV / JSON 导出 +- 后端:`CMP-12` CLI 打包、发布与跨平台 smoke 流水线 +- 前端:`CMP-9` GUI 信息架构和设计基线 +- QA:`CMP-10` 跨数据库验收矩阵 +- QA:`CMP-13` demo 数据库、样例脚本和 smoke runbook +- PM:`CMP-11` 里程碑、依赖图和交付跟踪 + +## 执行顺序 + +1. `CMP-4` +2. `CMP-5` +3. `CMP-10` 与 `CMP-13` 并行启动 +4. `CMP-6` +5. `CMP-7` +6. `CMP-8` +7. `CMP-12` +8. `CMP-11` 持续跟踪里程碑和关键路径 + +## 当前判断 + +- 真正的起跑线不是“写功能”,而是先把仓库和最小 CLI 基线建起来。 +- 前端当前应保持 gated,只输出未来 GUI 基线,不抢占 CLI 主路径资源。 +- QA 不应等功能全部完成后再介入,应从 fixtures、矩阵和 smoke runbook 开始提前进入。 diff --git a/crates/db-config/Cargo.toml b/crates/db-config/Cargo.toml new file mode 100644 index 0000000..4391365 --- /dev/null +++ b/crates/db-config/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "db-config" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +publish = false + +[dependencies] +db-core.workspace = true + +[lints] +workspace = true + diff --git a/crates/db-config/src/lib.rs b/crates/db-config/src/lib.rs new file mode 100644 index 0000000..2089ebf --- /dev/null +++ b/crates/db-config/src/lib.rs @@ -0,0 +1,123 @@ +use db_core::{ConnectionTarget, CoreError}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConnectionProfile { + pub name: String, + pub target: ConnectionTarget, + pub password_env_var: Option, +} + +impl ConnectionProfile { + pub fn new( + name: impl Into, + target: ConnectionTarget, + password_env_var: Option, + ) -> Result { + let profile = Self { + name: name.into(), + target, + password_env_var, + }; + + profile.validate()?; + Ok(profile) + } + + pub fn validate(&self) -> Result<(), CoreError> { + if self.name.trim().is_empty() { + return Err(CoreError::InvalidConnection("profile name is empty")); + } + + self.target.validate() + } + + pub fn redacted_summary(&self) -> String { + match &self.password_env_var { + Some(password_env_var) => format!( + "{} via password env {}", + self.target.redacted_endpoint(), + password_env_var + ), + None => self.target.redacted_endpoint(), + } + } + + pub fn with_password(mut self, password: Option) -> Self { + self.target.password = password; + self + } +} + +#[cfg(test)] +mod tests { + use db_core::{ConnectionTarget, ConnectionTransport, DatabaseKind}; + + use super::*; + + #[test] + fn rejects_empty_profile_name() { + let target = ConnectionTarget::new( + DatabaseKind::Sqlite, + ConnectionTransport::File { + path: "demo.sqlite".into(), + }, + None, + None, + ) + .expect("target should be valid"); + + let result = ConnectionProfile::new(" ", target, None); + + assert_eq!( + result, + Err(CoreError::InvalidConnection("profile name is empty")) + ); + } + + #[test] + fn summary_mentions_password_env_without_secret() { + let target = ConnectionTarget::new( + DatabaseKind::Postgres, + ConnectionTransport::Tcp { + host: String::from("127.0.0.1"), + port: 55432, + }, + Some(String::from("dbtool_demo")), + Some(String::from("dbtool")), + ) + .expect("target should be valid"); + let profile = + ConnectionProfile::new("local", target, Some(String::from("DBTOOL_PASSWORD"))) + .expect("profile should be valid"); + + assert_eq!( + profile.redacted_summary(), + "postgres://dbtool@127.0.0.1:55432/dbtool_demo via password env DBTOOL_PASSWORD" + ); + } + + #[test] + fn injects_password_without_changing_redacted_output() { + let target = ConnectionTarget::new( + DatabaseKind::Postgres, + ConnectionTransport::Tcp { + host: String::from("127.0.0.1"), + port: 55432, + }, + Some(String::from("dbtool_demo")), + Some(String::from("dbtool")), + ) + .expect("target should be valid"); + + let profile = + ConnectionProfile::new("local", target, Some(String::from("DBTOOL_PASSWORD"))) + .expect("profile should be valid") + .with_password(Some(String::from("secret"))); + + assert_eq!(profile.target.password.as_deref(), Some("secret")); + assert_eq!( + profile.redacted_summary(), + "postgres://dbtool@127.0.0.1:55432/dbtool_demo via password env DBTOOL_PASSWORD" + ); + } +} diff --git a/crates/db-core/Cargo.toml b/crates/db-core/Cargo.toml new file mode 100644 index 0000000..72f0f3c --- /dev/null +++ b/crates/db-core/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "db-core" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +publish = false + +[lints] +workspace = true diff --git a/crates/db-core/src/lib.rs b/crates/db-core/src/lib.rs new file mode 100644 index 0000000..0e3f425 --- /dev/null +++ b/crates/db-core/src/lib.rs @@ -0,0 +1,384 @@ +use std::path::PathBuf; +use std::{error::Error, fmt}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DatabaseKind { + Postgres, + Mysql, + Sqlite, +} + +impl DatabaseKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Postgres => "postgres", + Self::Mysql => "mysql", + Self::Sqlite => "sqlite", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ConnectionTransport { + Tcp { host: String, port: u16 }, + File { path: PathBuf }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConnectionTarget { + pub kind: DatabaseKind, + pub transport: ConnectionTransport, + pub database: Option, + pub username: Option, + pub password: Option, +} + +impl ConnectionTarget { + pub fn new( + kind: DatabaseKind, + transport: ConnectionTransport, + database: Option, + username: Option, + ) -> Result { + let target = Self { + kind, + transport, + database, + username, + password: None, + }; + + target.validate()?; + Ok(target) + } + + pub fn validate(&self) -> Result<(), CoreError> { + match (&self.kind, &self.transport) { + (DatabaseKind::Sqlite, ConnectionTransport::File { path }) => { + if path.as_os_str().is_empty() { + return Err(CoreError::InvalidConnection("sqlite path is empty")); + } + } + (DatabaseKind::Sqlite, ConnectionTransport::Tcp { .. }) => { + return Err(CoreError::InvalidConnection( + "sqlite must use a filesystem path", + )); + } + (_, ConnectionTransport::File { .. }) => { + return Err(CoreError::InvalidConnection( + "server databases must use host and port", + )); + } + (_, ConnectionTransport::Tcp { host, port }) => { + if host.trim().is_empty() { + return Err(CoreError::InvalidConnection("host is empty")); + } + if *port == 0 { + return Err(CoreError::InvalidConnection("port must be non-zero")); + } + } + } + + if self.kind != DatabaseKind::Sqlite + && self + .database + .as_deref() + .is_none_or(|database| database.trim().is_empty()) + { + return Err(CoreError::InvalidConnection( + "server databases require a database name", + )); + } + + Ok(()) + } + + pub fn redacted_endpoint(&self) -> String { + match &self.transport { + ConnectionTransport::Tcp { host, port } => { + let database = self.database.as_deref().unwrap_or(""); + match self.username.as_deref() { + Some(username) => { + format!( + "{}://{}@{}:{}/{}", + self.kind.as_str(), + username, + host, + port, + database + ) + } + None => format!("{}://{}:{}/{}", self.kind.as_str(), host, port, database), + } + } + ConnectionTransport::File { path } => { + format!("sqlite://{}", path.display()) + } + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InspectRequest { + pub schema: Option, + pub table: Option, +} + +impl InspectRequest { + pub fn new(schema: Option, table: Option) -> Result { + let request = Self { schema, table }; + request.validate()?; + Ok(request) + } + + pub fn validate(&self) -> Result<(), CoreError> { + if self.table.is_some() && self.schema.is_none() { + return Err(CoreError::InvalidInspect( + "table inspection requires --schema", + )); + } + + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SchemaSummary { + pub name: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TableSummary { + pub schema: String, + pub name: String, + pub kind: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ColumnSummary { + pub name: String, + pub data_type: String, + pub nullable: bool, + pub primary_key: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum InspectResult { + Schemas(Vec), + Tables(Vec), + Columns(Vec), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum QuerySource { + Inline, + File(PathBuf), +} + +impl QuerySource { + pub fn label(&self) -> &'static str { + match self { + Self::Inline => "inline SQL", + Self::File(_) => "SQL file", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct QueryRequest { + pub sql: String, + pub source: QuerySource, + pub parameters: Vec, +} + +impl QueryRequest { + pub fn new( + sql: String, + source: QuerySource, + parameters: Vec, + ) -> Result { + let request = Self { + sql, + source, + parameters, + }; + request.validate()?; + Ok(request) + } + + pub fn validate(&self) -> Result<(), CoreError> { + if self.sql.trim().is_empty() { + return Err(CoreError::EmptyQuery); + } + + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct QueryResult { + pub columns: Vec, + pub rows: Vec>, + pub rows_affected: Option, +} + +impl QueryResult { + pub fn rows(columns: Vec, rows: Vec>) -> Self { + Self { + columns, + rows, + rows_affected: None, + } + } + + pub fn command(rows_affected: u64) -> Self { + Self { + columns: Vec::new(), + rows: Vec::new(), + rows_affected: Some(rows_affected), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExportFormat { + Csv, + Json, +} + +impl ExportFormat { + pub fn file_extension(self) -> &'static str { + match self { + Self::Csv => "csv", + Self::Json => "json", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExportRequest { + pub format: ExportFormat, + pub output_path: PathBuf, + pub overwrite: bool, +} + +impl ExportRequest { + pub fn new( + format: ExportFormat, + output_path: PathBuf, + overwrite: bool, + ) -> Result { + let request = Self { + format, + output_path, + overwrite, + }; + + request.validate()?; + Ok(request) + } + + pub fn validate(&self) -> Result<(), CoreError> { + if self.output_path.file_name().is_none() { + return Err(CoreError::InvalidExport( + "output path must include a filename", + )); + } + + Ok(()) + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum CoreError { + InvalidConnection(&'static str), + InvalidInspect(&'static str), + InvalidQuery(&'static str), + EmptyQuery, + InvalidExport(&'static str), +} + +impl fmt::Display for CoreError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidConnection(message) => { + write!(formatter, "connection target is invalid: {message}") + } + Self::InvalidInspect(message) => { + write!(formatter, "inspect request is invalid: {message}") + } + Self::InvalidQuery(message) => { + write!(formatter, "query request is invalid: {message}") + } + Self::EmptyQuery => write!(formatter, "query text is empty"), + Self::InvalidExport(message) => { + write!(formatter, "export request is invalid: {message}") + } + } + } +} + +impl Error for CoreError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_server_target_without_database() { + let result = ConnectionTarget::new( + DatabaseKind::Postgres, + ConnectionTransport::Tcp { + host: String::from("127.0.0.1"), + port: 5432, + }, + None, + Some(String::from("dbtool")), + ); + + assert_eq!( + result, + Err(CoreError::InvalidConnection( + "server databases require a database name" + )) + ); + } + + #[test] + fn redacts_target_without_password() { + let target = ConnectionTarget::new( + DatabaseKind::Mysql, + ConnectionTransport::Tcp { + host: String::from("db.internal"), + port: 3306, + }, + Some(String::from("qa_demo")), + Some(String::from("dbtool")), + ) + .expect("target should be valid"); + + assert_eq!( + target.redacted_endpoint(), + "mysql://dbtool@db.internal:3306/qa_demo" + ); + } + + #[test] + fn rejects_empty_query() { + let result = QueryRequest::new(String::from(" "), QuerySource::Inline, Vec::new()); + + assert_eq!(result, Err(CoreError::EmptyQuery)); + } + + #[test] + fn rejects_table_without_schema() { + let result = InspectRequest::new(None, Some(String::from("accounts"))); + + assert_eq!( + result, + Err(CoreError::InvalidInspect( + "table inspection requires --schema" + )) + ); + } +} diff --git a/crates/db-drivers/Cargo.toml b/crates/db-drivers/Cargo.toml new file mode 100644 index 0000000..2c909c1 --- /dev/null +++ b/crates/db-drivers/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "db-drivers" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +publish = false + +[dependencies] +db-core.workspace = true +mysql.workspace = true +postgres.workspace = true +rusqlite.workspace = true + +[lints] +workspace = true diff --git a/crates/db-drivers/src/lib.rs b/crates/db-drivers/src/lib.rs new file mode 100644 index 0000000..b2d85eb --- /dev/null +++ b/crates/db-drivers/src/lib.rs @@ -0,0 +1,1110 @@ +use db_core::{ + ColumnSummary, ConnectionTarget, ConnectionTransport, DatabaseKind, InspectRequest, + InspectResult, QueryRequest, QueryResult, SchemaSummary, TableSummary, +}; +use mysql::prelude::{Protocol as MySqlProtocol, Queryable}; +use mysql::{Conn as MySqlConn, OptsBuilder as MySqlOptsBuilder, Params as MySqlParams}; +use mysql::{QueryResult as MySqlQueryResult, Row as MySqlRow, Value as MySqlValue}; +use postgres::types::ToSql; +use postgres::{Client, NoTls, Row, SimpleQueryMessage}; +use rusqlite::types::ValueRef as SqliteValueRef; +use rusqlite::{params_from_iter, Connection as SqliteConnection, OpenFlags as SqliteOpenFlags}; +use std::fs::File; +use std::io::Read; +use std::path::Path; +use std::{error::Error, fmt}; + +const SQLITE_DATABASE_HEADER: &[u8; 16] = b"SQLite format 3\0"; + +pub trait DatabaseDriver { + fn kind(&self) -> DatabaseKind; + fn connect(&self, target: &ConnectionTarget) -> Result<(), DriverError>; + fn inspect( + &self, + target: &ConnectionTarget, + request: &InspectRequest, + ) -> Result; + fn query( + &self, + target: &ConnectionTarget, + request: &QueryRequest, + ) -> Result; +} + +pub struct DriverRegistry; + +impl DriverRegistry { + pub fn connect(target: &ConnectionTarget) -> Result<(), DriverError> { + match target.kind { + DatabaseKind::Postgres => PostgresDriver.connect(target), + DatabaseKind::Mysql => MysqlDriver.connect(target), + DatabaseKind::Sqlite => SqliteDriver.connect(target), + } + } + + pub fn inspect( + target: &ConnectionTarget, + request: &InspectRequest, + ) -> Result { + match target.kind { + DatabaseKind::Postgres => PostgresDriver.inspect(target, request), + DatabaseKind::Mysql => MysqlDriver.inspect(target, request), + DatabaseKind::Sqlite => SqliteDriver.inspect(target, request), + } + } + + pub fn query( + target: &ConnectionTarget, + request: &QueryRequest, + ) -> Result { + match target.kind { + DatabaseKind::Postgres => PostgresDriver.query(target, request), + DatabaseKind::Mysql => MysqlDriver.query(target, request), + DatabaseKind::Sqlite => SqliteDriver.query(target, request), + } + } + + pub fn driver_name(kind: DatabaseKind) -> &'static str { + match kind { + DatabaseKind::Postgres => "postgres", + DatabaseKind::Mysql => "mysql", + DatabaseKind::Sqlite => "sqlite", + } + } +} + +struct PostgresDriver; +struct MysqlDriver; +struct SqliteDriver; + +impl DatabaseDriver for PostgresDriver { + fn kind(&self) -> DatabaseKind { + DatabaseKind::Postgres + } + + fn connect(&self, target: &ConnectionTarget) -> Result<(), DriverError> { + let mut client = connect_postgres(target)?; + client + .simple_query("SELECT 1") + .map_err(map_postgres_error)?; + Ok(()) + } + + fn inspect( + &self, + target: &ConnectionTarget, + request: &InspectRequest, + ) -> Result { + let mut client = connect_postgres(target)?; + + match (&request.schema, &request.table) { + (None, None) => { + let rows = client + .query( + "SELECT schema_name \ + FROM information_schema.schemata \ + WHERE schema_name NOT IN ('pg_catalog', 'information_schema') \ + ORDER BY schema_name", + &[], + ) + .map_err(map_postgres_error)?; + + Ok(InspectResult::Schemas( + rows.into_iter() + .map(|row| SchemaSummary { + name: row.get::<_, String>(0), + }) + .collect(), + )) + } + (Some(schema), None) => { + let rows = client + .query( + "SELECT table_schema, table_name, table_type \ + FROM information_schema.tables \ + WHERE table_schema = $1 \ + ORDER BY table_name", + &[schema], + ) + .map_err(map_postgres_error)?; + + Ok(InspectResult::Tables( + rows.into_iter() + .map(|row| TableSummary { + schema: row.get::<_, String>(0), + name: row.get::<_, String>(1), + kind: row.get::<_, String>(2), + }) + .collect(), + )) + } + (Some(schema), Some(table)) => { + let rows = client + .query( + "SELECT c.column_name, + c.data_type, + c.is_nullable, + EXISTS ( + SELECT 1 + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + AND tc.table_name = kcu.table_name + WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_schema = c.table_schema + AND tc.table_name = c.table_name + AND kcu.column_name = c.column_name + ) AS primary_key + FROM information_schema.columns c + WHERE c.table_schema = $1 + AND c.table_name = $2 + ORDER BY c.ordinal_position", + &[schema, table], + ) + .map_err(map_postgres_error)?; + + Ok(InspectResult::Columns( + rows.into_iter() + .map(|row| ColumnSummary { + name: row.get::<_, String>(0), + data_type: row.get::<_, String>(1), + nullable: row.get::<_, String>(2) == "YES", + primary_key: row.get::<_, bool>(3), + }) + .collect(), + )) + } + (None, Some(_)) => Err(DriverError::Inspect( + "table inspection requires a schema".to_string(), + )), + } + } + + fn query( + &self, + target: &ConnectionTarget, + request: &QueryRequest, + ) -> Result { + let mut client = connect_postgres(target)?; + + if request.parameters.is_empty() { + return simple_query_result(&mut client, &request.sql); + } + + let statement = client.prepare(&request.sql).map_err(map_postgres_error)?; + let params: Vec<&(dyn ToSql + Sync)> = request + .parameters + .iter() + .map(|value| value as &(dyn ToSql + Sync)) + .collect(); + + if statement.columns().is_empty() { + let rows_affected = client + .execute(&statement, ¶ms) + .map_err(map_postgres_error)?; + return Ok(QueryResult::command(rows_affected)); + } + + let columns = statement + .columns() + .iter() + .map(|column| column.name().to_string()) + .collect::>(); + + let rows = client + .query(&statement, ¶ms) + .map_err(map_postgres_error)?; + + Ok(QueryResult::rows( + columns, + rows.into_iter().map(row_to_strings).collect(), + )) + } +} + +impl DatabaseDriver for MysqlDriver { + fn kind(&self) -> DatabaseKind { + DatabaseKind::Mysql + } + + fn connect(&self, target: &ConnectionTarget) -> Result<(), DriverError> { + let mut conn = connect_mysql(target)?; + conn.query_drop("SELECT 1").map_err(map_mysql_error)?; + Ok(()) + } + + fn inspect( + &self, + target: &ConnectionTarget, + request: &InspectRequest, + ) -> Result { + let mut conn = connect_mysql(target)?; + + match (&request.schema, &request.table) { + (None, None) => { + let rows = conn + .exec::<(String,), _, _>( + "SELECT schema_name \ + FROM information_schema.schemata \ + WHERE schema_name NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') \ + ORDER BY schema_name", + (), + ) + .map_err(map_mysql_error)?; + + Ok(InspectResult::Schemas( + rows.into_iter() + .map(|(name,)| SchemaSummary { name }) + .collect(), + )) + } + (Some(schema), None) => { + let rows = conn + .exec::<(String, String, String), _, _>( + "SELECT table_schema, table_name, table_type \ + FROM information_schema.tables \ + WHERE table_schema = ? \ + ORDER BY table_name", + (schema,), + ) + .map_err(map_mysql_error)?; + + Ok(InspectResult::Tables( + rows.into_iter() + .map(|(schema, name, kind)| TableSummary { schema, name, kind }) + .collect(), + )) + } + (Some(schema), Some(table)) => { + let rows = conn + .exec::<(String, String, String, u8), _, _>( + "SELECT c.column_name, + c.data_type, + c.is_nullable, + EXISTS ( + SELECT 1 + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + AND tc.table_name = kcu.table_name + WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_schema = c.table_schema + AND tc.table_name = c.table_name + AND kcu.column_name = c.column_name + ) AS primary_key + FROM information_schema.columns c + WHERE c.table_schema = ? + AND c.table_name = ? + ORDER BY c.ordinal_position", + (schema, table), + ) + .map_err(map_mysql_error)?; + + Ok(InspectResult::Columns( + rows.into_iter() + .map(|(name, data_type, nullable, primary_key)| ColumnSummary { + name, + data_type, + nullable: nullable == "YES", + primary_key: primary_key != 0, + }) + .collect(), + )) + } + (None, Some(_)) => Err(DriverError::Inspect( + "table inspection requires a schema".to_string(), + )), + } + } + + fn query( + &self, + target: &ConnectionTarget, + request: &QueryRequest, + ) -> Result { + let mut conn = connect_mysql(target)?; + + if request.parameters.is_empty() { + let result = conn.query_iter(&request.sql).map_err(map_mysql_error)?; + return consume_mysql_query_result(result); + } + + let params = MySqlParams::Positional( + request + .parameters + .iter() + .cloned() + .map(MySqlValue::from) + .collect(), + ); + let result = conn + .exec_iter(&request.sql, params) + .map_err(map_mysql_error)?; + consume_mysql_query_result(result) + } +} + +impl DatabaseDriver for SqliteDriver { + fn kind(&self) -> DatabaseKind { + DatabaseKind::Sqlite + } + + fn connect(&self, target: &ConnectionTarget) -> Result<(), DriverError> { + let connection = connect_sqlite(target)?; + validate_sqlite_connection(&connection).map_err(map_sqlite_connection_error)?; + Ok(()) + } + + fn inspect( + &self, + target: &ConnectionTarget, + request: &InspectRequest, + ) -> Result { + let connection = connect_sqlite(target)?; + + match (&request.schema, &request.table) { + (None, None) => inspect_sqlite_schemas(&connection), + (Some(schema), None) => inspect_sqlite_tables(&connection, schema), + (Some(schema), Some(table)) => inspect_sqlite_columns(&connection, schema, table), + (None, Some(_)) => Err(DriverError::Inspect( + "table inspection requires a schema".to_string(), + )), + } + } + + fn query( + &self, + target: &ConnectionTarget, + request: &QueryRequest, + ) -> Result { + let connection = connect_sqlite(target)?; + query_sqlite(&connection, request) + } +} + +fn connect_postgres(target: &ConnectionTarget) -> Result { + let (host, port) = match &target.transport { + ConnectionTransport::Tcp { host, port } => (host.clone(), *port), + ConnectionTransport::File { .. } => { + return Err(DriverError::Connection( + "postgres requires host and port".to_string(), + )); + } + }; + + let database = target + .database + .clone() + .ok_or_else(|| DriverError::Connection("postgres requires a database".to_string()))?; + + let mut config = postgres::Config::new(); + config.host(&host); + config.port(port); + config.dbname(&database); + + if let Some(username) = &target.username { + config.user(username); + } + + if let Some(password) = &target.password { + config.password(password); + } + + config.connect(NoTls).map_err(map_postgres_error) +} + +fn connect_mysql(target: &ConnectionTarget) -> Result { + let (host, port) = match &target.transport { + ConnectionTransport::Tcp { host, port } => (host.as_str(), *port), + ConnectionTransport::File { .. } => { + return Err(DriverError::Connection( + "mysql requires host and port".to_string(), + )); + } + }; + + let database = target + .database + .as_deref() + .ok_or_else(|| DriverError::Connection("mysql requires a database".to_string()))?; + + let builder = MySqlOptsBuilder::new() + .ip_or_hostname(Some(host)) + .tcp_port(port) + .user(target.username.as_deref()) + .pass(target.password.as_deref()) + .db_name(Some(database)) + .prefer_socket(false); + + MySqlConn::new(builder).map_err(map_mysql_error) +} + +fn connect_sqlite(target: &ConnectionTarget) -> Result { + let path = match &target.transport { + ConnectionTransport::File { path } => path, + ConnectionTransport::Tcp { .. } => { + return Err(DriverError::Connection( + "sqlite requires a filesystem path".to_string(), + )); + } + }; + + validate_sqlite_database_file(path)?; + + SqliteConnection::open_with_flags( + path, + SqliteOpenFlags::SQLITE_OPEN_READ_WRITE | SqliteOpenFlags::SQLITE_OPEN_URI, + ) + .map_err(map_sqlite_connection_error) +} + +fn validate_sqlite_database_file(path: &Path) -> Result<(), DriverError> { + let metadata = std::fs::metadata(path).map_err(|error| map_sqlite_path_error(path, error))?; + if !metadata.is_file() { + return Err(DriverError::Connection(format!( + "sqlite path must reference a regular file: {}", + path.display() + ))); + } + + let mut file = File::open(path).map_err(|error| map_sqlite_path_error(path, error))?; + let mut header = [0_u8; SQLITE_DATABASE_HEADER.len()]; + let bytes_read = file + .read(&mut header) + .map_err(|error| map_sqlite_path_error(path, error))?; + + if bytes_read != SQLITE_DATABASE_HEADER.len() || header != *SQLITE_DATABASE_HEADER { + return Err(DriverError::Connection(format!( + "file is not a database: {}", + path.display() + ))); + } + + Ok(()) +} + +fn validate_sqlite_connection(connection: &SqliteConnection) -> rusqlite::Result<()> { + connection.query_row("PRAGMA schema_version", [], |_| Ok(())) +} + +fn inspect_sqlite_schemas(connection: &SqliteConnection) -> Result { + let mut statement = connection + .prepare("PRAGMA database_list") + .map_err(map_sqlite_inspect_error)?; + let schemas = statement + .query_map([], |row| { + Ok(SchemaSummary { + name: row.get::<_, String>(1)?, + }) + }) + .map_err(map_sqlite_inspect_error)? + .collect::>>() + .map_err(map_sqlite_inspect_error)?; + + Ok(InspectResult::Schemas(schemas)) +} + +fn inspect_sqlite_tables( + connection: &SqliteConnection, + schema: &str, +) -> Result { + let sql = format!( + "SELECT name, type \ + FROM {}.sqlite_master \ + WHERE type IN ('table', 'view') \ + AND name NOT LIKE 'sqlite_%' \ + ORDER BY name", + quote_sqlite_identifier(schema) + ); + let mut statement = connection.prepare(&sql).map_err(map_sqlite_inspect_error)?; + let tables = statement + .query_map([], |row| { + Ok(TableSummary { + schema: schema.to_string(), + name: row.get::<_, String>(0)?, + kind: row.get::<_, String>(1)?, + }) + }) + .map_err(map_sqlite_inspect_error)? + .collect::>>() + .map_err(map_sqlite_inspect_error)?; + + Ok(InspectResult::Tables(tables)) +} + +fn inspect_sqlite_columns( + connection: &SqliteConnection, + schema: &str, + table: &str, +) -> Result { + let sql = format!( + "PRAGMA {}.table_info({})", + quote_sqlite_identifier(schema), + quote_sqlite_string(table) + ); + let mut statement = connection.prepare(&sql).map_err(map_sqlite_inspect_error)?; + let columns = statement + .query_map([], |row| { + Ok(ColumnSummary { + name: row.get::<_, String>(1)?, + data_type: row.get::<_, String>(2)?, + nullable: row.get::<_, i64>(3)? == 0, + primary_key: row.get::<_, i64>(5)? != 0, + }) + }) + .map_err(map_sqlite_inspect_error)? + .collect::>>() + .map_err(map_sqlite_inspect_error)?; + + Ok(InspectResult::Columns(columns)) +} + +fn query_sqlite( + connection: &SqliteConnection, + request: &QueryRequest, +) -> Result { + let mut statement = connection + .prepare(&request.sql) + .map_err(map_sqlite_query_error)?; + let columns = statement + .column_names() + .iter() + .map(|name| name.to_string()) + .collect::>(); + + if columns.is_empty() { + let rows_affected = statement + .execute(params_from_iter(request.parameters.iter())) + .map_err(map_sqlite_query_error)?; + return Ok(QueryResult::command(rows_affected as u64)); + } + + let mut rows = statement + .query(params_from_iter(request.parameters.iter())) + .map_err(map_sqlite_query_error)?; + let mut values = Vec::new(); + + while let Some(row) = rows.next().map_err(map_sqlite_query_error)? { + values.push(sqlite_row_to_strings(row, columns.len())?); + } + + Ok(QueryResult::rows(columns, values)) +} + +fn simple_query_result(client: &mut Client, sql: &str) -> Result { + let messages = client.simple_query(sql).map_err(map_postgres_error)?; + let mut columns = Vec::new(); + let mut rows = Vec::new(); + let mut rows_affected = 0; + + for message in messages { + match message { + SimpleQueryMessage::RowDescription(description) => { + columns = description + .iter() + .map(|column| column.name().to_string()) + .collect(); + rows.clear(); + } + SimpleQueryMessage::Row(row) => { + rows.push( + (0..row.len()) + .map(|index| row.get(index).unwrap_or("NULL").to_string()) + .collect(), + ); + } + SimpleQueryMessage::CommandComplete(count) => { + rows_affected = count; + } + _ => {} + } + } + + if columns.is_empty() { + Ok(QueryResult::command(rows_affected)) + } else { + Ok(QueryResult::rows(columns, rows)) + } +} + +fn consume_mysql_query_result( + mut result: MySqlQueryResult<'_, '_, '_, T>, +) -> Result { + let mut last_columns = Vec::new(); + let mut last_rows = Vec::new(); + let mut last_rows_affected = 0; + + while let Some(result_set) = result.iter() { + let columns = result_set + .columns() + .as_ref() + .iter() + .map(|column| column.name_str().to_string()) + .collect::>(); + let rows_affected = result_set.affected_rows(); + + if columns.is_empty() { + last_rows_affected = rows_affected; + continue; + } + + let mut rows = Vec::new(); + for row in result_set { + rows.push(mysql_row_to_strings(row.map_err(map_mysql_error)?)); + } + + last_columns = columns; + last_rows = rows; + } + + if last_columns.is_empty() { + Ok(QueryResult::command(last_rows_affected)) + } else { + Ok(QueryResult::rows(last_columns, last_rows)) + } +} + +fn map_postgres_error(error: postgres::Error) -> DriverError { + if let Some(db_error) = error.as_db_error() { + return match db_error.code().code() { + "28P01" => DriverError::Authentication("postgres authentication failed".to_string()), + _ => DriverError::Query(db_error.message().to_string()), + }; + } + + let message = error.to_string(); + if message.contains("password authentication failed") { + DriverError::Authentication("postgres authentication failed".to_string()) + } else if message.contains("Connection refused") + || message.contains("timed out") + || message.contains("failed to lookup address information") + { + DriverError::Connection(message) + } else { + DriverError::Connection(message) + } +} + +fn map_mysql_error(error: mysql::Error) -> DriverError { + match error { + mysql::Error::MySqlError(db_error) => match db_error.code { + 1045 => DriverError::Authentication("mysql authentication failed".to_string()), + 1044 | 1046 | 1049 => DriverError::Connection(db_error.message), + _ => DriverError::Query(db_error.message), + }, + mysql::Error::IoError(io_error) => DriverError::Connection(io_error.to_string()), + mysql::Error::UrlError(url_error) => DriverError::Connection(url_error.to_string()), + other => { + if other.is_connectivity_error() { + DriverError::Connection(other.to_string()) + } else { + DriverError::Query(other.to_string()) + } + } + } +} + +fn map_sqlite_connection_error(error: rusqlite::Error) -> DriverError { + DriverError::Connection(error.to_string()) +} + +fn map_sqlite_path_error(path: &Path, error: std::io::Error) -> DriverError { + match error.kind() { + std::io::ErrorKind::NotFound => DriverError::Connection(format!( + "sqlite database file does not exist: {}", + path.display() + )), + std::io::ErrorKind::PermissionDenied => DriverError::Connection(format!( + "permission denied opening sqlite database file: {}", + path.display() + )), + _ => DriverError::Connection(format!( + "unable to access sqlite database file {}: {}", + path.display(), + error + )), + } +} + +fn map_sqlite_inspect_error(error: rusqlite::Error) -> DriverError { + DriverError::Inspect(error.to_string()) +} + +fn map_sqlite_query_error(error: rusqlite::Error) -> DriverError { + DriverError::Query(error.to_string()) +} + +fn row_to_strings(row: Row) -> Vec { + row.columns() + .iter() + .enumerate() + .map(|(index, column)| value_to_string(&row, index, column.type_().name())) + .collect() +} + +fn mysql_row_to_strings(row: MySqlRow) -> Vec { + row.columns_ref() + .iter() + .enumerate() + .map(|(index, _)| mysql_value_to_string(&row[index])) + .collect() +} + +fn sqlite_row_to_strings( + row: &rusqlite::Row<'_>, + column_count: usize, +) -> Result, DriverError> { + let mut values = Vec::with_capacity(column_count); + for index in 0..column_count { + let value = row.get_ref(index).map_err(map_sqlite_query_error)?; + values.push(sqlite_value_to_string(value)); + } + Ok(values) +} + +fn mysql_value_to_string(value: &MySqlValue) -> String { + match value { + MySqlValue::NULL => String::from("NULL"), + MySqlValue::Bytes(bytes) => String::from_utf8_lossy(bytes).into_owned(), + MySqlValue::Int(value) => value.to_string(), + MySqlValue::UInt(value) => value.to_string(), + MySqlValue::Float(value) => value.to_string(), + MySqlValue::Double(value) => value.to_string(), + MySqlValue::Date(year, month, day, hour, minute, second, micros) => { + if *hour == 0 && *minute == 0 && *second == 0 && *micros == 0 { + format!("{year:04}-{month:02}-{day:02}") + } else if *micros == 0 { + format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}") + } else { + format!( + "{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}.{:06}", + micros + ) + } + } + MySqlValue::Time(is_negative, days, hours, minutes, seconds, micros) => { + let sign = if *is_negative { "-" } else { "" }; + let total_hours = days.saturating_mul(24) + u32::from(*hours); + if *micros == 0 { + format!("{sign}{total_hours:02}:{minutes:02}:{seconds:02}") + } else { + format!( + "{sign}{total_hours:02}:{minutes:02}:{seconds:02}.{:06}", + micros + ) + } + } + } +} + +fn sqlite_value_to_string(value: SqliteValueRef<'_>) -> String { + match value { + SqliteValueRef::Null => String::from("NULL"), + SqliteValueRef::Integer(value) => value.to_string(), + SqliteValueRef::Real(value) => value.to_string(), + SqliteValueRef::Text(value) => String::from_utf8_lossy(value).into_owned(), + SqliteValueRef::Blob(value) => value + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::>() + .join(""), + } +} + +fn quote_sqlite_identifier(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\"\"")) +} + +fn quote_sqlite_string(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +fn value_to_string(row: &Row, index: usize, type_name: &str) -> String { + match type_name { + "bool" => row + .try_get::<_, Option>(index) + .ok() + .flatten() + .map(|value| value.to_string()) + .unwrap_or_else(|| String::from("NULL")), + "int2" => row + .try_get::<_, Option>(index) + .ok() + .flatten() + .map(|value| value.to_string()) + .unwrap_or_else(|| String::from("NULL")), + "int4" => row + .try_get::<_, Option>(index) + .ok() + .flatten() + .map(|value| value.to_string()) + .unwrap_or_else(|| String::from("NULL")), + "int8" => row + .try_get::<_, Option>(index) + .ok() + .flatten() + .map(|value| value.to_string()) + .unwrap_or_else(|| String::from("NULL")), + "float4" => row + .try_get::<_, Option>(index) + .ok() + .flatten() + .map(|value| value.to_string()) + .unwrap_or_else(|| String::from("NULL")), + "float8" => row + .try_get::<_, Option>(index) + .ok() + .flatten() + .map(|value| value.to_string()) + .unwrap_or_else(|| String::from("NULL")), + _ => row + .try_get::<_, Option>(index) + .ok() + .flatten() + .unwrap_or_else(|| String::from("NULL")), + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum DriverError { + NotImplemented(&'static str), + Connection(String), + Authentication(String), + Inspect(String), + Query(String), +} + +impl fmt::Display for DriverError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotImplemented(driver) => { + write!(formatter, "driver is not implemented yet for {driver}") + } + Self::Connection(message) => write!(formatter, "connection failed: {message}"), + Self::Authentication(message) => write!(formatter, "{message}"), + Self::Inspect(message) => write!(formatter, "inspect failed: {message}"), + Self::Query(message) => write!(formatter, "query failed: {message}"), + } + } +} + +impl Error for DriverError {} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + #[cfg(unix)] + use std::{fs::Permissions, os::unix::fs::PermissionsExt}; + + #[test] + fn exposes_driver_names() { + assert_eq!( + DriverRegistry::driver_name(DatabaseKind::Postgres), + "postgres" + ); + assert_eq!(DriverRegistry::driver_name(DatabaseKind::Mysql), "mysql"); + assert_eq!(DriverRegistry::driver_name(DatabaseKind::Sqlite), "sqlite"); + } + + #[test] + fn authentication_error_message_is_redacted() { + let error = DriverError::Authentication("postgres authentication failed".to_string()); + + assert_eq!(error.to_string(), "postgres authentication failed"); + } + + #[test] + fn mysql_authentication_error_message_is_redacted() { + let error = DriverError::Authentication("mysql authentication failed".to_string()); + + assert_eq!(error.to_string(), "mysql authentication failed"); + } + + #[test] + fn sqlite_driver_supports_connect_inspect_and_query() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos(); + let path = env::temp_dir().join(format!("dbtool-sqlite-{unique}.sqlite")); + let connection = + SqliteConnection::open(&path).expect("temporary sqlite database should open"); + connection + .execute_batch(include_str!("../../../examples/fixtures/sqlite/init.sql")) + .expect("sqlite fixture should seed"); + drop(connection); + + let target = ConnectionTarget::new( + DatabaseKind::Sqlite, + ConnectionTransport::File { path: path.clone() }, + None, + None, + ) + .expect("sqlite target should be valid"); + + DriverRegistry::connect(&target).expect("sqlite connection should succeed"); + + let schemas = DriverRegistry::inspect( + &target, + &InspectRequest::new(None, None).expect("schema listing request should be valid"), + ) + .expect("sqlite schemas should load"); + assert_eq!( + schemas, + InspectResult::Schemas(vec![SchemaSummary { + name: String::from("main"), + }]) + ); + + let tables = DriverRegistry::inspect( + &target, + &InspectRequest::new(Some(String::from("main")), None) + .expect("table listing request should be valid"), + ) + .expect("sqlite tables should load"); + assert_eq!( + tables, + InspectResult::Tables(vec![ + TableSummary { + schema: String::from("main"), + name: String::from("accounts"), + kind: String::from("table"), + }, + TableSummary { + schema: String::from("main"), + name: String::from("tickets"), + kind: String::from("table"), + } + ]) + ); + + let query_result = DriverRegistry::query( + &target, + &QueryRequest::new( + String::from("SELECT COUNT(*) AS count FROM accounts WHERE is_active = ?1"), + db_core::QuerySource::Inline, + vec![String::from("1")], + ) + .expect("query request should be valid"), + ) + .expect("sqlite query should succeed"); + assert_eq!(query_result.columns, vec![String::from("count")]); + assert_eq!(query_result.rows, vec![vec![String::from("2")]]); + + fs::remove_file(path).expect("temporary sqlite database should be removed"); + } + + #[test] + fn sqlite_connect_rejects_non_database_files() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos(); + let path = env::temp_dir().join(format!("dbtool-not-a-db-{unique}.txt")); + fs::write(&path, "not a database\n").expect("temporary text file should be written"); + + let target = ConnectionTarget::new( + DatabaseKind::Sqlite, + ConnectionTransport::File { path: path.clone() }, + None, + None, + ) + .expect("sqlite target should be valid"); + + let error = DriverRegistry::connect(&target).expect_err("connect should reject text file"); + assert!(matches!(error, DriverError::Connection(_))); + assert!(error.to_string().contains("file is not a database")); + + fs::remove_file(path).expect("temporary text file should be removed"); + } + + #[test] + fn sqlite_connect_rejects_empty_files() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos(); + let path = env::temp_dir().join(format!("dbtool-empty-db-{unique}.sqlite")); + fs::write(&path, []).expect("temporary empty file should be written"); + + let target = ConnectionTarget::new( + DatabaseKind::Sqlite, + ConnectionTransport::File { path: path.clone() }, + None, + None, + ) + .expect("sqlite target should be valid"); + + let error = DriverRegistry::connect(&target).expect_err("connect should reject empty file"); + assert!(matches!(error, DriverError::Connection(_))); + assert!(error.to_string().contains("file is not a database")); + + fs::remove_file(path).expect("temporary empty file should be removed"); + } + + #[test] + fn sqlite_connect_rejects_missing_files() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos(); + let path = env::temp_dir().join(format!("dbtool-missing-db-{unique}.sqlite")); + + let target = ConnectionTarget::new( + DatabaseKind::Sqlite, + ConnectionTransport::File { path: path.clone() }, + None, + None, + ) + .expect("sqlite target should be valid"); + + let error = + DriverRegistry::connect(&target).expect_err("connect should reject missing files"); + assert!(matches!(error, DriverError::Connection(_))); + assert!(error + .to_string() + .contains("sqlite database file does not exist")); + } + + #[cfg(unix)] + #[test] + fn sqlite_connect_rejects_permission_denied_files() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos(); + let path = env::temp_dir().join(format!("dbtool-permission-db-{unique}.sqlite")); + let connection = + SqliteConnection::open(&path).expect("temporary sqlite database should open"); + connection + .execute("CREATE TABLE healthcheck (id INTEGER PRIMARY KEY)", []) + .expect("sqlite database should initialize"); + drop(connection); + fs::set_permissions(&path, Permissions::from_mode(0o000)) + .expect("permissions should be updated"); + + let target = ConnectionTarget::new( + DatabaseKind::Sqlite, + ConnectionTransport::File { path: path.clone() }, + None, + None, + ) + .expect("sqlite target should be valid"); + + let error = + DriverRegistry::connect(&target).expect_err("connect should reject unreadable files"); + assert!(matches!(error, DriverError::Connection(_))); + assert!(error + .to_string() + .contains("permission denied opening sqlite database file")); + + fs::set_permissions(&path, Permissions::from_mode(0o600)) + .expect("permissions should be restorable"); + fs::remove_file(path).expect("temporary sqlite database should be removed"); + } +} diff --git a/docker-compose.demo.yml b/docker-compose.demo.yml new file mode 100644 index 0000000..5d009cd --- /dev/null +++ b/docker-compose.demo.yml @@ -0,0 +1,30 @@ +services: + postgres: + image: postgres:16 + ports: + - "55432:5432" + environment: + POSTGRES_DB: dbtool_demo + POSTGRES_USER: dbtool + POSTGRES_PASSWORD: dbtool + healthcheck: + test: ["CMD-SHELL", "pg_isready -U dbtool -d dbtool_demo"] + interval: 5s + timeout: 5s + retries: 20 + + mysql: + image: mysql:8.4 + ports: + - "53306:3306" + environment: + MYSQL_DATABASE: dbtool_demo + MYSQL_USER: dbtool + MYSQL_PASSWORD: dbtool + MYSQL_ROOT_PASSWORD: dbtoolroot + command: ["mysqld", "--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci"] + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -uroot -pdbtoolroot"] + interval: 5s + timeout: 5s + retries: 30 diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..f976e3c --- /dev/null +++ b/examples/README.md @@ -0,0 +1,38 @@ +# dbtool-cli-v1 Demo Assets + +These assets give backend, frontend, and QA one small shared dataset for PostgreSQL, MySQL, and SQLite. + +## Current Status + +- Demo fixtures, seed scripts, and smoke inputs are staged. +- PostgreSQL、MySQL 和 SQLite driver behavior now exists for `connect`, `inspect`, and `query`. +- `export` execution to CSV / JSON now exists behind the shared query-result model. +- Use these files as the shared data baseline for backend, frontend, and QA verification. + +## Layout + +- `examples/fixtures/` — seed SQL per database plus expected assertions +- `examples/sql/` — happy-path and failure-path query inputs +- `examples/scripts/` — repeatable bootstrap helpers + +## Intended QA Coverage + +- `connect` against PostgreSQL, MySQL, SQLite +- `inspect` schemas / tables / columns +- `query` from inline SQL or file input +- `export` to CSV / JSON from a known query + +## MySQL Bootstrap Note + +- `examples/fixtures/mysql/init.sql` 会显式授予 `dbtool` 用户对 `qa_demo` 的权限 +- 这样可避免 demo 容器默认库名与 QA 目标库名不一致时,`dbtool` 无法访问 `qa_demo` + +## SQLite Bootstrap Note + +- `examples/scripts/bootstrap-sqlite.sh` 现支持 `sqlite3`、`node:sqlite` 或 `python3 sqlite3` 三种 seed 路径 +- SQLite demo inspect 语义使用 `main` 作为 schema 名称 +- SQLite demo query 目前按单条 statement 执行,多语句输入会明确失败 + +## Known Blocker + +The seed data is ready, and PostgreSQL / MySQL / SQLite query and export flows are implemented, but these assets still cannot serve as release evidence until the full smoke path runs in a Docker-capable environment. diff --git a/examples/fixtures/EXPECTED_RESULTS.md b/examples/fixtures/EXPECTED_RESULTS.md new file mode 100644 index 0000000..149bca8 --- /dev/null +++ b/examples/fixtures/EXPECTED_RESULTS.md @@ -0,0 +1,28 @@ +# Expected Results + +Use these assertions when verifying `connect`, `inspect`, `query`, and `export`. + +## PostgreSQL + +- schema: `qa_demo` +- tables: `accounts`, `tickets` + +## MySQL + +- database: `qa_demo` +- tables: `accounts`, `tickets` + +## SQLite + +- file: `examples/tmp/dbtool-demo.sqlite` +- tables: `accounts`, `tickets` + +## Shared Data Assertions + +- `accounts` row count: `3` +- `tickets` row count: `4` +- open tickets: `3` +- closed tickets: `1` +- at least one `NULL` value exists in `tickets.notes` +- at least one multilingual value exists in `accounts.display_name` +- export query returns `4` rows ordered by `ticket_id` diff --git a/examples/fixtures/mysql/init.sql b/examples/fixtures/mysql/init.sql new file mode 100644 index 0000000..65b835b --- /dev/null +++ b/examples/fixtures/mysql/init.sql @@ -0,0 +1,35 @@ +DROP DATABASE IF EXISTS qa_demo; +CREATE DATABASE qa_demo CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +GRANT ALL PRIVILEGES ON qa_demo.* TO 'dbtool'@'%'; +FLUSH PRIVILEGES; +USE qa_demo; + +CREATE TABLE accounts ( + id BIGINT PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + display_name VARCHAR(255) NULL, + is_active BOOLEAN NOT NULL, + created_at TIMESTAMP NOT NULL +); + +CREATE TABLE tickets ( + id BIGINT PRIMARY KEY, + account_id BIGINT NOT NULL, + title VARCHAR(255) NOT NULL, + status VARCHAR(32) NOT NULL, + notes TEXT NULL, + amount_cents INT NOT NULL, + created_at TIMESTAMP NOT NULL, + CONSTRAINT fk_tickets_account FOREIGN KEY (account_id) REFERENCES accounts(id) +); + +INSERT INTO accounts (id, email, display_name, is_active, created_at) VALUES + (1, 'alice@example.com', 'Alice', TRUE, '2026-03-01 08:30:00'), + (2, 'maria@example.com', 'Máría', FALSE, '2026-03-10 09:45:00'), + (3, 'zhang@example.com', '张敏', TRUE, '2026-03-15 12:00:00'); + +INSERT INTO tickets (id, account_id, title, status, notes, amount_cents, created_at) VALUES + (101, 1, 'login timeout', 'open', NULL, 0, '2026-03-20 10:00:00'), + (102, 1, 'csv export mismatch', 'closed', 'fixed after re-run', 1500, '2026-03-21 11:15:00'), + (103, 2, '权限验证', 'open', 'needs DBA review', 300, '2026-03-22 15:30:00'), + (104, 3, 'emoji smoke 😀', 'open', NULL, 42, '2026-03-23 07:05:00'); diff --git a/examples/fixtures/postgres/init.sql b/examples/fixtures/postgres/init.sql new file mode 100644 index 0000000..4c333d3 --- /dev/null +++ b/examples/fixtures/postgres/init.sql @@ -0,0 +1,32 @@ +DROP SCHEMA IF EXISTS qa_demo CASCADE; +CREATE SCHEMA qa_demo; +SET search_path TO qa_demo; + +CREATE TABLE accounts ( + id BIGINT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + display_name TEXT, + is_active BOOLEAN NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE tickets ( + id BIGINT PRIMARY KEY, + account_id BIGINT NOT NULL REFERENCES accounts(id), + title TEXT NOT NULL, + status TEXT NOT NULL, + notes TEXT, + amount_cents INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); + +INSERT INTO accounts (id, email, display_name, is_active, created_at) VALUES + (1, 'alice@example.com', 'Alice', TRUE, '2026-03-01T08:30:00Z'), + (2, 'maria@example.com', 'Máría', FALSE, '2026-03-10T09:45:00Z'), + (3, 'zhang@example.com', '张敏', TRUE, '2026-03-15T12:00:00Z'); + +INSERT INTO tickets (id, account_id, title, status, notes, amount_cents, created_at) VALUES + (101, 1, 'login timeout', 'open', NULL, 0, '2026-03-20T10:00:00Z'), + (102, 1, 'csv export mismatch', 'closed', 'fixed after re-run', 1500, '2026-03-21T11:15:00Z'), + (103, 2, '权限验证', 'open', 'needs DBA review', 300, '2026-03-22T15:30:00Z'), + (104, 3, 'emoji smoke 😀', 'open', NULL, 42, '2026-03-23T07:05:00Z'); diff --git a/examples/fixtures/sqlite/init.sql b/examples/fixtures/sqlite/init.sql new file mode 100644 index 0000000..752ee03 --- /dev/null +++ b/examples/fixtures/sqlite/init.sql @@ -0,0 +1,34 @@ +PRAGMA foreign_keys = OFF; +DROP TABLE IF EXISTS tickets; +DROP TABLE IF EXISTS accounts; +PRAGMA foreign_keys = ON; + +CREATE TABLE accounts ( + id INTEGER PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + display_name TEXT, + is_active INTEGER NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE tickets ( + id INTEGER PRIMARY KEY, + account_id INTEGER NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL, + notes TEXT, + amount_cents INTEGER NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (account_id) REFERENCES accounts(id) +); + +INSERT INTO accounts (id, email, display_name, is_active, created_at) VALUES + (1, 'alice@example.com', 'Alice', 1, '2026-03-01T08:30:00Z'), + (2, 'maria@example.com', 'Máría', 0, '2026-03-10T09:45:00Z'), + (3, 'zhang@example.com', '张敏', 1, '2026-03-15T12:00:00Z'); + +INSERT INTO tickets (id, account_id, title, status, notes, amount_cents, created_at) VALUES + (101, 1, 'login timeout', 'open', NULL, 0, '2026-03-20T10:00:00Z'), + (102, 1, 'csv export mismatch', 'closed', 'fixed after re-run', 1500, '2026-03-21T11:15:00Z'), + (103, 2, '权限验证', 'open', 'needs DBA review', 300, '2026-03-22T15:30:00Z'), + (104, 3, 'emoji smoke 😀', 'open', NULL, 42, '2026-03-23T07:05:00Z'); diff --git a/examples/scripts/bootstrap-mysql.sh b/examples/scripts/bootstrap-mysql.sh new file mode 100755 index 0000000..fccfe89 --- /dev/null +++ b/examples/scripts/bootstrap-mysql.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +COMPOSE_FILE="${COMPOSE_FILE:-$ROOT_DIR/docker-compose.demo.yml}" +SERVICE_NAME="${MYSQL_SERVICE_NAME:-mysql}" +MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-dbtoolroot}" + +docker compose -f "$COMPOSE_FILE" exec -T "$SERVICE_NAME" \ + mysql -uroot "-p$MYSQL_ROOT_PASSWORD" \ + < "$ROOT_DIR/examples/fixtures/mysql/init.sql" + +echo "MySQL demo data seeded into qa_demo." diff --git a/examples/scripts/bootstrap-postgres.sh b/examples/scripts/bootstrap-postgres.sh new file mode 100755 index 0000000..41c8fb4 --- /dev/null +++ b/examples/scripts/bootstrap-postgres.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +COMPOSE_FILE="${COMPOSE_FILE:-$ROOT_DIR/docker-compose.demo.yml}" +SERVICE_NAME="${POSTGRES_SERVICE_NAME:-postgres}" +POSTGRES_USER="${POSTGRES_USER:-dbtool}" +POSTGRES_DB="${POSTGRES_DB:-dbtool_demo}" + +docker compose -f "$COMPOSE_FILE" exec -T "$SERVICE_NAME" \ + psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ + < "$ROOT_DIR/examples/fixtures/postgres/init.sql" + +echo "PostgreSQL demo data seeded into $POSTGRES_DB." diff --git a/examples/scripts/bootstrap-sqlite.sh b/examples/scripts/bootstrap-sqlite.sh new file mode 100755 index 0000000..487bcd5 --- /dev/null +++ b/examples/scripts/bootstrap-sqlite.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SQLITE_DB_PATH="${SQLITE_DB_PATH:-$ROOT_DIR/examples/tmp/dbtool-demo.sqlite}" +SQL_FILE="$ROOT_DIR/examples/fixtures/sqlite/init.sql" + +mkdir -p "$(dirname "$SQLITE_DB_PATH")" + +if command -v sqlite3 >/dev/null 2>&1; then + sqlite3 "$SQLITE_DB_PATH" < "$SQL_FILE" +elif command -v node >/dev/null 2>&1; then + node --no-warnings - "$SQLITE_DB_PATH" "$SQL_FILE" <<'JS' +const { DatabaseSync } = require('node:sqlite'); +const fs = require('fs'); + +const dbPath = process.argv[2]; +const sqlPath = process.argv[3]; + +const db = new DatabaseSync(dbPath); +try { + db.exec(fs.readFileSync(sqlPath, 'utf8')); +} finally { + db.close(); +} +JS +else + python3 - "$SQLITE_DB_PATH" "$SQL_FILE" <<'PY' +import pathlib +import sqlite3 +import sys + +db_path = pathlib.Path(sys.argv[1]) +sql_path = pathlib.Path(sys.argv[2]) + +conn = sqlite3.connect(db_path) +try: + conn.executescript(sql_path.read_text(encoding="utf-8")) + conn.commit() +finally: + conn.close() +PY +fi + +echo "SQLite demo data seeded into $SQLITE_DB_PATH." diff --git a/examples/sql/mysql/export_query.sql b/examples/sql/mysql/export_query.sql new file mode 100644 index 0000000..ce1eafa --- /dev/null +++ b/examples/sql/mysql/export_query.sql @@ -0,0 +1,12 @@ +USE qa_demo; + +SELECT + t.id AS ticket_id, + a.email, + t.title, + t.status, + t.amount_cents, + t.created_at +FROM tickets t +JOIN accounts a ON a.id = t.account_id +ORDER BY t.id; diff --git a/examples/sql/mysql/failing_query.sql b/examples/sql/mysql/failing_query.sql new file mode 100644 index 0000000..a6ee7a0 --- /dev/null +++ b/examples/sql/mysql/failing_query.sql @@ -0,0 +1,3 @@ +USE qa_demo; + +SELECT * FROM missing_table; diff --git a/examples/sql/mysql/happy_path_query.sql b/examples/sql/mysql/happy_path_query.sql new file mode 100644 index 0000000..10ec289 --- /dev/null +++ b/examples/sql/mysql/happy_path_query.sql @@ -0,0 +1,11 @@ +USE qa_demo; + +SELECT + a.id AS account_id, + a.display_name, + COUNT(t.id) AS ticket_count, + SUM(CASE WHEN t.status = 'open' THEN 1 ELSE 0 END) AS open_ticket_count +FROM accounts a +LEFT JOIN tickets t ON t.account_id = a.id +GROUP BY a.id, a.display_name +ORDER BY a.id; diff --git a/examples/sql/postgres/export_query.sql b/examples/sql/postgres/export_query.sql new file mode 100644 index 0000000..d48fdac --- /dev/null +++ b/examples/sql/postgres/export_query.sql @@ -0,0 +1,12 @@ +SET search_path TO qa_demo; + +SELECT + t.id AS ticket_id, + a.email, + t.title, + t.status, + t.amount_cents, + t.created_at +FROM tickets t +JOIN accounts a ON a.id = t.account_id +ORDER BY t.id; diff --git a/examples/sql/postgres/failing_query.sql b/examples/sql/postgres/failing_query.sql new file mode 100644 index 0000000..9705e9b --- /dev/null +++ b/examples/sql/postgres/failing_query.sql @@ -0,0 +1,3 @@ +SET search_path TO qa_demo; + +SELECT * FROM missing_table; diff --git a/examples/sql/postgres/happy_path_query.sql b/examples/sql/postgres/happy_path_query.sql new file mode 100644 index 0000000..6c45186 --- /dev/null +++ b/examples/sql/postgres/happy_path_query.sql @@ -0,0 +1,11 @@ +SET search_path TO qa_demo; + +SELECT + a.id AS account_id, + a.display_name, + COUNT(t.id) AS ticket_count, + SUM(CASE WHEN t.status = 'open' THEN 1 ELSE 0 END) AS open_ticket_count +FROM accounts a +LEFT JOIN tickets t ON t.account_id = a.id +GROUP BY a.id, a.display_name +ORDER BY a.id; diff --git a/examples/sql/sqlite/export_query.sql b/examples/sql/sqlite/export_query.sql new file mode 100644 index 0000000..a4e4600 --- /dev/null +++ b/examples/sql/sqlite/export_query.sql @@ -0,0 +1,10 @@ +SELECT + t.id AS ticket_id, + a.email, + t.title, + t.status, + t.amount_cents, + t.created_at +FROM tickets t +JOIN accounts a ON a.id = t.account_id +ORDER BY t.id; diff --git a/examples/sql/sqlite/failing_query.sql b/examples/sql/sqlite/failing_query.sql new file mode 100644 index 0000000..7dfc9bf --- /dev/null +++ b/examples/sql/sqlite/failing_query.sql @@ -0,0 +1 @@ +SELECT * FROM missing_table; diff --git a/examples/sql/sqlite/happy_path_query.sql b/examples/sql/sqlite/happy_path_query.sql new file mode 100644 index 0000000..e597e9d --- /dev/null +++ b/examples/sql/sqlite/happy_path_query.sql @@ -0,0 +1,9 @@ +SELECT + a.id AS account_id, + a.display_name, + COUNT(t.id) AS ticket_count, + SUM(CASE WHEN t.status = 'open' THEN 1 ELSE 0 END) AS open_ticket_count +FROM accounts a +LEFT JOIN tickets t ON t.account_id = a.id +GROUP BY a.id, a.display_name +ORDER BY a.id; diff --git a/gui/README.md b/gui/README.md new file mode 100644 index 0000000..f3e9252 --- /dev/null +++ b/gui/README.md @@ -0,0 +1,40 @@ +# GUI Foundation Artifacts + +本目录用于存放 `CMP-9` 的未来桌面端 GUI 基线产物,而不是正式应用代码。 + +## 当前产物 + +- `desktop-foundation.md`:信息架构、布局方向、组件清单、契约要求和 UI/UX 建议 +- `prototype/index.html`:最小可见工作台原型 +- `prototype/styles.css`:原型样式 +- `prototype/app.js`:原型内交互 +- `preview-server.mjs`:零依赖本地预览服务 + +## 查看方式 + +### 方式一:直接打开 + +直接用浏览器打开 `gui/prototype/index.html`。 + +### 方式二:启动本地预览 + +在项目根目录执行: + +```bash +node gui/preview-server.mjs +``` + +然后访问: + +```text +http://127.0.0.1:4173 +``` + +## 界面验收 + +验收人应重点确认: + +1. 是否能看出连接管理、schema browser、query editor、results table、export feedback 的稳定分区 +2. 布局是否体现“工作台”而不是通用 dashboard +3. 状态、错误、执行反馈是否一眼可见 +4. 文档中的后端契约需求是否能映射到 CLI 已定义产品概念 diff --git a/gui/desktop-foundation.md b/gui/desktop-foundation.md new file mode 100644 index 0000000..e7aab27 --- /dev/null +++ b/gui/desktop-foundation.md @@ -0,0 +1,291 @@ +# dbtool-cli-v1 Future Desktop GUI Foundation + +日期:2026-03-25 +作者:Senior Frontend Engineer +对应 issue:`CMP-9` + +## 1. 当前前端基线评估 + +### 真实现状 + +- 当前仓库没有前端入口,也没有任何展示层基础。 +- 当前不存在 `package.json`、React 工程、桌面壳、页面路由、组件目录或样式系统。 +- 当前项目仍以 CLI V1 为主路径,GUI 只允许做未来方向基线,不进入本期交付主链路。 + +### 结论 + +- GUI 第一轮最合理的交付形态应是“信息架构 + 组件边界 + 契约清单 + 静态原型”。 +- 不应在本 issue 中直接引入 Electron、Tauri、React 或完整桌面工程。 +- 后续 GUI 项目启动时,推荐使用 React + shadcn/ui + Tailwind CSS,并以本文件作为产品层输入。 + +## 2. 首版 UI/UX 范围建议 + +首版未来桌面端 GUI 建议仅覆盖一个高价值工作台,而不是多页面堆叠: + +1. **Connection Manager** + - 展示已保存连接、最近连接、驱动类型与连接健康状态 + - 支持新建连接、测试连接、进入工作台 +2. **Database Workspace** + - 左侧 schema browser + - 中间 query editor + execution toolbar + - 下方 results / history / export 分页区 + - 右侧 inspector 展示连接、对象和执行详情 +3. **Execution Feedback** + - 展示执行中、成功、失败、空结果、已导出等状态 +4. **Export Flow** + - 选择格式、目标路径、覆盖提醒、导出结果提示 + +### 当前刻意不做 + +- dashboard 首页 +- 图表和 BI 视图 +- AI 助手面板 +- migration / schema editing +- 多窗口复杂工作流 + +## 3. 信息架构 + +```text +Desktop GUI +├─ Connection Manager +│ ├─ Saved Connections +│ ├─ New Connection +│ └─ Test / Open Workspace +└─ Workspace + ├─ Schema Browser + │ ├─ Schemas + │ ├─ Tables / Views + │ └─ Columns + ├─ Query Workbench + │ ├─ Query Tabs + │ ├─ Execution Toolbar + │ └─ SQL Editor + ├─ Result Region + │ ├─ Results Table + │ ├─ Query History + │ └─ Export Status + └─ Inspector + ├─ Connection Summary + ├─ Object Metadata + └─ Execution Detail / Error +``` + +## 4. 主工作区布局方向 + +### Layout Principle + +- 使用“三栏 + 底部结果区”的桌面工作台布局。 +- 结构重于装饰,让 schema、SQL、结果和状态成为主视觉主体。 +- 避免通用 SaaS 卡片拼贴,不引入噪音型 dashboard 模块。 + +### 推荐布局 + +```text +┌────────────────────────────────────────────────────────────────────┐ +│ App Header / Connection Switcher / Global Actions │ +├──────────────┬─────────────────────────────────────┬───────────────┤ +│ Schema Tree │ Query Toolbar + SQL Editor │ Inspector │ +│ │ │ │ +│ │ │ │ +├──────────────┴─────────────────────────────────────┴───────────────┤ +│ Results / History / Export / Problems │ +└────────────────────────────────────────────────────────────────────┘ +``` + +### 区域职责 + +- **Header**:连接切换、当前数据库上下文、主题、全局命令 +- **Schema Tree**:浏览 schema / table / view / column,支持搜索和展开 +- **Workbench**:SQL 编写、运行、停止、格式切换、目标连接展示 +- **Inspector**:对象详情、执行摘要、错误详情、导出选项 +- **Bottom Panel**:结果表格、历史、导出日志、问题列表 + +## 5. 页面与状态设计 + +### Connection Manager + +- 主要目标:尽快进入一个明确的工作区 +- 关键状态: + - 无连接 + - 测试连接中 + - 测试连接成功 + - 测试连接失败 + - 驱动不支持 / 配置不完整 + +### Workspace + +- 主要目标:围绕单一连接完成 inspect → query → export +- 关键状态: + - 尚未选择对象 + - schema 载入中 + - SQL 执行中 + - 查询成功且有结果 + - 查询成功但空结果 + - 查询失败 + - 导出成功 / 导出失败 + +## 6. 组件清单 + +以下为后续 React + shadcn/ui 落地建议组件边界: + +| 组件 | 作用 | 建议基础 | +| --- | --- | --- | +| `AppShell` | 承载桌面工作台骨架 | `ResizablePanelGroup`, `Separator` | +| `ConnectionSwitcher` | 切换当前连接和工作区入口 | `Popover`, `Command`, `Select` | +| `ConnectionCard` | 展示连接摘要与状态 | `Card`, `Badge`, `Button` | +| `SchemaTree` | 浏览 schema / table / column | `ScrollArea`, `Collapsible`, `Input` | +| `QueryToolbar` | 运行、停止、导出、当前连接状态 | `Button`, `Badge`, `Tooltip` | +| `QueryTabs` | 管理多个 SQL 标签 | `Tabs`, `ContextMenu` | +| `SqlEditorPanel` | 容纳 SQL 编辑器 | 自定义容器,后续接入编辑器 | +| `ResultTabs` | 在结果、历史、导出间切换 | `Tabs` | +| `ResultsTable` | 呈现查询结果集 | `Table`, `ScrollArea` | +| `ExecutionBanner` | 一眼可见的执行状态与错误摘要 | `Alert`, `Badge` | +| `InspectorPanel` | 展示对象属性和执行详情 | `Sheet` 或固定侧栏容器 | +| `ExportDrawer` | 处理导出参数与确认 | `Dialog` / `Sheet` / `Form` | + +## 7. 与 shadcn/ui + Tailwind + impeccable 的对齐说明 + +### 视觉基线 + +- 以稳定中性色为主,不使用高饱和主视觉。 +- 强调结构、对齐、排版和间距,而不是装饰性卡片。 +- light / dark 都应保持低噪音层次和稳定对比度。 + +### 交互基线 + +- 键盘优先:连接切换、schema 搜索、执行操作、底部面板切换应支持快捷路径。 +- 错误优先可读:错误摘要直接进入可见区,不藏在 toast 里。 +- 信息密度优先:结果、状态和元数据必须同时可读,避免只做“大留白”。 + +### 不应出现的方向 + +- 营销站式英雄区 +- 强发光深色主题 +- 以卡片拼贴为主的 dashboard 首页 +- 视觉噪音很高的渐变、阴影、发光描边 + +## 8. 未来 GUI 需要的核心数据契约 + +桌面 GUI 不应重新定义产品对象,而应直接映射 CLI 已明确概念。 + +### 连接与上下文 + +- `ConnectionTarget` + - `id` + - `name` + - `databaseKind` + - `environmentLabel` + - `status` + - `lastValidatedAt` + +### schema browser + +- `CatalogNode` + - `id` + - `kind` (`schema` / `table` / `view` / `column`) + - `name` + - `parentId` + - `isExpandable` + - `metadata` + +### 查询执行 + +- `QueryRequest` + - `connectionId` + - `sql` + - `source` (`inline` / `file`) + - `limitMode` + +- `QueryExecutionState` + - `status` (`idle` / `running` / `success` / `error`) + - `startedAt` + - `finishedAt` + - `durationMs` + - `rowCount` + - `message` + +### 结果与导出 + +- `ResultSet` + - `columns` + - `rows` + - `rowCount` + - `truncated` + +- `ExportRequest` + - `connectionId` + - `queryText` + - `format` (`csv` / `json`) + - `outputPath` + - `overwrite` + +- `ExportResult` + - `status` + - `outputPath` + - `exportedRows` + - `message` + +## 9. 后端契约提前识别 + +未来 GUI 项目启动前,建议 CTO / Backend 先固定以下输出形态: + +1. schema introspection 返回结构是否可直接树化 +2. query 返回结构是否区分“空结果成功”和“执行失败” +3. export 是否返回可复用的结果对象和错误对象 +4. 连接测试结果是否提供统一状态码和展示文案 +5. 错误对象是否包含用户可展示摘要与底层细节 + +若这些结构过度 CLI-only,后续 GUI 会被迫做适配层补丁。 + +## 10. 最小可见界面实现 + +本次已提供静态原型: + +- `gui/prototype/index.html` +- `gui/prototype/styles.css` +- `gui/prototype/app.js` + +该原型的作用是验证: + +- 工作台结构是否稳定 +- 连接管理与执行反馈是否易懂 +- schema browser、query editor、results table、inspector 是否形成合理分区 + +该原型不是正式桌面应用,也不接真实数据源。 + +## 11. 运行方式 + +### 直接打开 + +浏览器打开 `gui/prototype/index.html`。 + +### 本地预览 + +```bash +node gui/preview-server.mjs +``` + +访问 `http://127.0.0.1:4173`。 + +## 12. 界面验收说明 + +QA / CTO / PM 可按以下标准验收本次产物: + +1. 明确确认当前项目“尚无前端基线”这一事实是否被准确记录 +2. 评估信息架构是否完整覆盖 connection manager、schema browser、query editor、results table、export flow +3. 评估布局是否体现高信息密度桌面工作台,而非通用后台模板 +4. 评估组件清单是否足够拆分后续 GUI 子任务 +5. 评估契约清单是否能指导 backend 提前稳定输出结构 + +## 13. 建议的后续 GUI 项目拆分 + +后续若单独立项 GUI,可按以下顺序拆 issue: + +1. GUI shell 与设计 token 基线 +2. Connection Manager +3. Schema Browser +4. Query Workbench +5. Results Table 与状态系统 +6. Export Flow +7. 错误与问题面板 +8. 快捷键与可用性细化 diff --git a/gui/preview-server.mjs b/gui/preview-server.mjs new file mode 100644 index 0000000..a1c013a --- /dev/null +++ b/gui/preview-server.mjs @@ -0,0 +1,33 @@ +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { extname, join, normalize } from "node:path"; + +const root = new URL("./prototype/", import.meta.url); +const port = Number(process.env.GUI_PREVIEW_PORT || 4173); + +const contentTypes = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", +}; + +const server = createServer(async (req, res) => { + const requestPath = req.url === "/" ? "/index.html" : req.url || "/index.html"; + const safePath = normalize(requestPath).replace(/^(\.\.[/\\])+/, ""); + const filePath = new URL(`.${safePath}`, root); + + try { + const body = await readFile(filePath); + const type = contentTypes[extname(filePath.pathname)] || "text/plain; charset=utf-8"; + res.writeHead(200, { "Content-Type": type }); + res.end(body); + } catch { + const fallback = await readFile(join(root.pathname, "index.html")); + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(fallback); + } +}); + +server.listen(port, "127.0.0.1", () => { + console.log(`GUI prototype server running at http://127.0.0.1:${port}`); +}); diff --git a/gui/prototype/app.js b/gui/prototype/app.js new file mode 100644 index 0000000..8e25fe6 --- /dev/null +++ b/gui/prototype/app.js @@ -0,0 +1,53 @@ +const connectionCards = document.querySelectorAll(".connection-card"); +const activeConnection = document.getElementById("active-connection"); +const detailTarget = document.getElementById("detail-target"); +const runQueryButton = document.getElementById("run-query"); +const executionBanner = document.getElementById("execution-banner"); +const executionTitle = document.getElementById("execution-title"); +const executionCopy = document.getElementById("execution-copy"); +const durationValue = document.getElementById("duration-value"); +const rowCount = document.getElementById("row-count"); +const bottomTabs = document.querySelectorAll(".bottom-tab"); +const tabContents = document.querySelectorAll(".tab-content"); + +connectionCards.forEach((card) => { + card.addEventListener("click", () => { + connectionCards.forEach((item) => item.classList.remove("is-active")); + card.classList.add("is-active"); + + const target = card.dataset.connection; + activeConnection.textContent = target; + detailTarget.textContent = target; + }); +}); + +runQueryButton.addEventListener("click", () => { + const isError = executionBanner.classList.contains("is-error"); + + if (isError) { + executionBanner.classList.remove("is-error"); + executionTitle.textContent = "Last run succeeded"; + executionCopy.textContent = "50 rows returned in 182 ms. Export is available."; + durationValue.textContent = "182 ms"; + rowCount.textContent = "50"; + return; + } + + executionBanner.classList.add("is-error"); + executionTitle.textContent = "Last run failed"; + executionCopy.textContent = "Syntax error near `form`. The error summary should stay visible in the workspace."; + durationValue.textContent = "21 ms"; + rowCount.textContent = "0"; +}); + +bottomTabs.forEach((tab) => { + tab.addEventListener("click", () => { + bottomTabs.forEach((item) => item.classList.remove("is-active")); + tabContents.forEach((item) => item.classList.remove("is-active")); + + tab.classList.add("is-active"); + document + .querySelector(`[data-content="${tab.dataset.tab}"]`) + .classList.add("is-active"); + }); +}); diff --git a/gui/prototype/index.html b/gui/prototype/index.html new file mode 100644 index 0000000..02cbfbb --- /dev/null +++ b/gui/prototype/index.html @@ -0,0 +1,293 @@ + + + + + + dbtool-cli-v1 GUI Foundation Prototype + + + +
+
+
+
DB
+
+

dbtool future desktop

+

Query Workspace Foundation

+
+
+
+ + +
+ + pg-prod-readonly +
+
+
+ +
+ + +
+
+
+ + +
+
+ PostgreSQL + + +
+
+ +
+
+ 1 + 2 + 3 + 4 + 5 + 6 +
+
select
+  id,
+  customer_id,
+  status,
+  created_at
+from public.orders
+where created_at > now() - interval '7 days'
+order by created_at desc
+limit 50;
+
+ +
+
+

Execution

+ Last run succeeded +

50 rows returned in 182 ms. Export is available.

+
+ +
+ +
+
+ + + + +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
idcustomer_idstatuscreated_at
8ec1...7cb3...paid2026-03-25 13:55:01 UTC
80f8...5d10...processing2026-03-25 13:44:15 UTC
5aa2...9ab1...failed2026-03-25 13:12:48 UTC
+ + + +
+
+
+ orders audit.sql + Success · 182 ms · 50 rows +
+
+ empty result.sql + Success · 95 ms · 0 rows +
+
+ bad syntax.sql + Error · syntax error near `form` +
+
+
+ +
+
+
+

Export Status

+ Ready to export the current result set +

`/tmp/orders-last-7-days.csv` is available as the default target.

+
+
+ + +
+
+
+ +
+
+ No blocking problem +

When a query fails, this area should show the error summary, SQL fragment, and next action.

+
+
+ + + + + + + + + + diff --git a/gui/prototype/styles.css b/gui/prototype/styles.css new file mode 100644 index 0000000..1b9498c --- /dev/null +++ b/gui/prototype/styles.css @@ -0,0 +1,423 @@ +:root { + color-scheme: dark; + --bg: #0f1115; + --panel: #141821; + --panel-muted: #10141c; + --panel-strong: #1a202b; + --border: #273041; + --text: #e6e8ee; + --muted: #9da7b8; + --accent: #7dd3fc; + --accent-soft: rgba(125, 211, 252, 0.12); + --success: #7dd3a0; + --warning: #f5c26b; + --danger: #f09c9c; + --shadow: 0 18px 40px rgba(0, 0, 0, 0.22); + --radius: 14px; + --radius-sm: 10px; + --font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --mono: "SFMono-Regular", ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, monospace; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at top, rgba(125, 211, 252, 0.07), transparent 25%), + linear-gradient(180deg, #0d1016 0%, #0f1115 100%); + color: var(--text); + font-family: var(--font); +} + +button, +input, +textarea, +select { + font: inherit; +} + +.app-shell { + min-height: 100vh; + padding: 18px; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 22px; + margin-bottom: 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: rgba(20, 24, 33, 0.82); + box-shadow: var(--shadow); + backdrop-filter: blur(12px); +} + +.topbar__brand, +.topbar__context, +.section-head, +.editor-toolbar, +.toolbar-tabs, +.toolbar-actions, +.bottom-tabs, +.export-box, +.export-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.topbar__brand h1, +.section-head h2, +.panel-section h2 { + margin: 0; + font-size: 16px; +} + +.brand-mark { + display: grid; + place-items: center; + width: 40px; + height: 40px; + border: 1px solid rgba(125, 211, 252, 0.3); + border-radius: 12px; + background: var(--accent-soft); + color: var(--accent); + font-weight: 700; +} + +.eyebrow { + margin: 0 0 4px; + color: var(--muted); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.workspace { + display: grid; + grid-template-columns: 280px minmax(640px, 1fr) 280px; + gap: 16px; + min-height: calc(100vh - 124px); +} + +.panel { + border: 1px solid var(--border); + border-radius: var(--radius); + background: rgba(20, 24, 33, 0.9); + box-shadow: var(--shadow); + overflow: hidden; +} + +.panel--nav, +.panel--side { + display: flex; + flex-direction: column; +} + +.panel--main { + display: grid; + grid-template-rows: auto minmax(240px, 1fr) auto minmax(260px, 0.9fr); +} + +.panel-section, +.editor-toolbar, +.execution-banner, +.bottom-panel { + padding: 18px; +} + +.panel-section + .panel-section { + border-top: 1px solid var(--border); +} + +.connection-pill, +.pill, +.ghost-button, +.action-button, +.toolbar-tab, +.bottom-tab, +.search-field, +.info-card, +.log-row, +.problem-box { + border: 1px solid var(--border); + border-radius: 999px; +} + +.ghost-button, +.action-button, +.toolbar-tab, +.bottom-tab { + cursor: pointer; +} + +.ghost-button, +.toolbar-tab, +.bottom-tab, +.search-field { + padding: 8px 12px; + background: var(--panel-muted); + color: var(--muted); +} + +.ghost-button--small { + padding: 6px 10px; +} + +.action-button { + padding: 9px 13px; + background: var(--panel-strong); + color: var(--text); +} + +.action-button--primary { + border-color: rgba(125, 211, 252, 0.28); + background: rgba(125, 211, 252, 0.16); + color: #dff6ff; +} + +.connection-pill, +.pill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 11px; + background: var(--panel-muted); +} + +.pill--success { + color: var(--success); +} + +.pill--muted { + color: var(--muted); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--muted); +} + +.status-dot--ok { + background: var(--success); +} + +.connection-list, +.tree, +.detail-list, +.focus-list, +.log-list { + display: grid; + gap: 10px; +} + +.connection-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + padding: 14px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--panel-muted); + color: inherit; + text-align: left; +} + +.connection-card p, +.export-box p, +.problem-box p, +.focus-list, +.detail-list span, +.info-card span, +.log-row span { + margin: 4px 0 0; + color: var(--muted); +} + +.connection-card.is-active { + border-color: rgba(125, 211, 252, 0.4); + background: rgba(125, 211, 252, 0.08); +} + +.tree details { + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--panel-muted); +} + +.tree summary { + cursor: pointer; + font-weight: 600; +} + +.tree ul { + margin: 10px 0 0 18px; + padding: 0; + color: var(--muted); +} + +.editor { + display: grid; + grid-template-columns: 52px 1fr; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + background: #10141b; +} + +.editor-gutter, +.editor-code { + padding: 18px 16px; + margin: 0; + font-family: var(--mono); + font-size: 13px; + line-height: 1.7; +} + +.editor-gutter { + color: #697386; + border-right: 1px solid var(--border); + background: #0d1016; +} + +.editor-gutter span { + display: block; +} + +.editor-code { + color: #d7e2f0; + overflow: auto; +} + +.execution-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + background: linear-gradient(90deg, rgba(125, 211, 160, 0.12), rgba(125, 211, 252, 0.04)); +} + +.execution-banner.is-error { + background: linear-gradient(90deg, rgba(240, 156, 156, 0.16), rgba(240, 156, 156, 0.06)); +} + +.banner-metrics { + display: grid; + grid-template-columns: repeat(2, minmax(88px, 1fr)); + gap: 12px; +} + +.banner-metrics div, +.detail-list div, +.info-card { + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: rgba(15, 18, 24, 0.7); +} + +.banner-metrics span, +.detail-list span, +.info-card span { + display: block; + margin-bottom: 8px; + color: var(--muted); + font-size: 12px; +} + +.bottom-panel { + display: grid; + grid-template-rows: auto 1fr; + gap: 16px; +} + +.toolbar-tab.is-active, +.bottom-tab.is-active { + border-color: rgba(125, 211, 252, 0.3); + background: rgba(125, 211, 252, 0.14); + color: #e4f7ff; +} + +.tab-content { + display: none; +} + +.tab-content.is-active { + display: block; +} + +.table-wrap { + overflow: auto; + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +th, +td { + padding: 12px 14px; + border-bottom: 1px solid var(--border); + text-align: left; + white-space: nowrap; +} + +th { + color: var(--muted); + font-weight: 600; + background: #121722; +} + +.log-row, +.export-box, +.problem-box { + padding: 16px; + background: var(--panel-muted); +} + +.focus-list { + padding-left: 18px; +} + +.focus-list li + li { + margin-top: 10px; +} + +@media (max-width: 1320px) { + .workspace { + grid-template-columns: 250px minmax(520px, 1fr) 250px; + } +} + +@media (max-width: 1100px) { + .workspace { + grid-template-columns: 1fr; + } + + .panel--main { + order: 1; + } + + .panel--nav { + order: 2; + } + + .panel--side { + order: 3; + } +} diff --git a/plans/2026-03-25-cli-release-smoke-baseline.md b/plans/2026-03-25-cli-release-smoke-baseline.md new file mode 100644 index 0000000..498a4ba --- /dev/null +++ b/plans/2026-03-25-cli-release-smoke-baseline.md @@ -0,0 +1,100 @@ +# dbtool-cli-v1 CLI 发布与跨平台 smoke 基线 + +> Status: superseded on 2026-03-26 by `RELEASE_RUNBOOK.md` and `.github/workflows/release-smoke.yml`. +> This file captures the initial blocked-state plan and should not be treated as the current release implementation source of truth. + +日期:2026-03-25 +作者:Senior Backend Engineer + +## 当前结论 + +- 当前仓库仅包含产品、QA、backlog 与架构计划文档,尚未落地 Rust workspace、`Cargo.toml`、CLI 可执行入口或任何可构建资产。 +- 因此 `CMP-12` 当前不能直接实现“真实可运行”的发布流水线,必须先等待 `CMP-4` 交付最小可编译 CLI 基线。 +- 同时,跨平台 smoke 的“有意义输入”还依赖 `CMP-13` 提供最小 fixtures / runbook,否则只能做 `--help` 级别的空载校验。 + +## 当前领域与后端边界盘点 + +当前已共享、但尚未代码化的后端核心概念来自 `PRODUCT_REQUIREMENTS.md` 与 `plans/2026-03-25-dbtool-cli-v1-rust-architecture-plan.md`: + +- 连接目标:`DatabaseKind` + `ConnectionProfile` +- introspection 结果:schema / table / column 元数据树 +- 查询执行:`QueryRequest` / `QueryResult` +- 导出:`ExportRequest` +- 发布链路:`ReleaseArtifact` / checksum / smoke result + +这些概念目前仍处于“规划已明确、实现尚未开始”的状态。 + +## 最小可行发布策略 + +在 `CMP-4` 完成后,发布链路先采用最小 CI matrix,而不是复杂发布系统: + +### CI matrix + +- `ubuntu-latest` +- `macos-latest` +- `windows-latest` + +### 每个平台必须执行 + +1. 安装稳定 Rust toolchain +2. `cargo build --release` +3. 执行一次 CLI 基础 smoke: + - ` --help` + - 可选:` --version` +4. 打包 release artifact +5. 生成 SHA256 checksum + +## 建议的 artifact 规则 + +在 CLI 名称最终确定前,先使用占位命名规则,避免后续分发混乱: + +- Linux: `--x86_64-unknown-linux-gnu.tar.gz` +- macOS: `--x86_64-apple-darwin.tar.gz` +- Windows: `--x86_64-pc-windows-msvc.zip` +- checksum: `.sha256` + +如后续引入 ARM 构建,新增架构后缀,不复用旧命名。 + +## smoke 边界 + +当前阶段的 smoke 分两层: + +### Level 1:发布前置 smoke + +- CLI 可启动 +- `--help` 可返回 0 +- `--version` 可读取(若已实现) + +### Level 2:功能 smoke + +依赖 `CMP-13` 的最小 fixtures / runbook,至少覆盖: + +- connect +- inspect +- query +- export + +在 `CMP-13` 未完成前,不应把 Level 2 写成强制发布门槛。 + +## 回滚说明 + +V1 不涉及服务部署,回滚按“产物回滚”处理: + +1. 保留上一个已验证 release artifact 与 checksum +2. 若新版本 smoke 失败或 QA 验收失败,停止分发新产物 +3. 重新指向上一个可验证版本 +4. 在发布记录中标注失败原因、影响平台、是否为构建问题或运行时问题 + +## 当前阻塞与依赖 + +- 主阻塞:`CMP-4` 未完成,当前不存在可编译 Rust workspace +- 次阻塞:`CMP-13` 尚在进行中,功能 smoke 输入未稳定 +- 风险:若在 CLI 参数与二进制名未稳定前过早固化 workflow,后续会出现大量仅因命名变化产生的流水线噪音 + +## 建议的后续执行顺序 + +1. `CMP-4` 先交付可编译 workspace 与可启动 CLI +2. 在仓库中落地 `.github/workflows/release-smoke.yml` +3. 先接入 `--help` / `--version` smoke +4. 等 `CMP-13` 产出 fixtures 后,再把功能 smoke 接入 CI +5. 待命令面稳定后,再评估是否需要 `cargo-dist` diff --git a/plans/2026-03-25-cmp-9-desktop-gui-foundation-plan.md b/plans/2026-03-25-cmp-9-desktop-gui-foundation-plan.md new file mode 100644 index 0000000..5be712e --- /dev/null +++ b/plans/2026-03-25-cmp-9-desktop-gui-foundation-plan.md @@ -0,0 +1,46 @@ +# CMP-9 Future Desktop GUI Foundation Plan + +日期:2026-03-25 +作者:Senior Frontend Engineer + +## Goal + +在不进入完整桌面产品实现的前提下,为 `dbtool-cli-v1` 产出可交付的未来 GUI 基线,供 CTO 和 PM 用于后续项目拆分、契约澄清和范围控制。 + +## Current Assessment + +- 当前仓库没有前端入口、`package.json`、React 应用、桌面壳或任何展示层代码。 +- 当前前端最合适的交付物不是“桌面应用实现”,而是共享信息架构文档、组件边界说明和一个最小可见原型。 +- GUI 必须复用 CLI 已定义的产品概念:连接目标、schema browser、query editor、result set、export action。 + +## Planned Steps + +1. 确认当前仓库没有现成前端基线,并记录约束 +2. 定义桌面端信息架构、主工作区布局和页面边界 +3. 识别 GUI 需要的核心数据契约与状态模型 +4. 交付一个静态可预览的工作台原型,验证布局与信息密度方向 +5. 补充查看方式和界面验收点,方便 CTO / PM / QA 复核 + +## Output Boundaries + +本任务输出: + +- GUI 信息架构 +- 主工作区布局方向 +- 组件清单 +- 契约需求清单 +- 静态原型 +- 运行与验收说明 + +本任务不输出: + +- Electron / Tauri 工程初始化 +- React + shadcn/ui 真正应用代码 +- 可连接真实数据库的 GUI +- 新增或修改后端契约实现 + +## Validation Rule + +- 原型必须可以本地直接预览 +- 文档必须明确“当前无前端基线”的事实 +- 文档必须让后续 GUI 项目可直接拆成页面、组件和契约任务 diff --git a/plans/2026-03-25-dbtool-cli-v1-delivery-tracking.md b/plans/2026-03-25-dbtool-cli-v1-delivery-tracking.md new file mode 100644 index 0000000..8564179 --- /dev/null +++ b/plans/2026-03-25-dbtool-cli-v1-delivery-tracking.md @@ -0,0 +1,92 @@ +# dbtool-cli-v1 里程碑、依赖图与交付跟踪 + +日期:2026-03-25 +作者:CTO + +## 1. 当前真实状态 + +- 项目工作区现已存在,但仓库内仍只有产品、QA、计划和 backlog 文档。 +- 当前尚未落地 Rust workspace、`Cargo.toml`、CLI 二进制入口、demo、seed、smoke 或 CI 发布资产。 +- 因此当前阶段不是“实现中后期”,而是“需求和执行顺序已明确,代码基线尚未开始”。 + +## 2. 里程碑建议 + +| 里程碑 | 目标 | 对应 issue | 当前状态 | 说明 | +| --- | --- | --- | --- | --- | +| M0 | 范围与架构收敛 | `CMP-2`, `CMP-3` | 已完成 | 产品边界和 Rust 架构都已形成共享文档 | +| M1 | 工程基线可启动 | `CMP-4` | 未开始 | 必须先产出可编译 workspace 和可运行 CLI | +| M2 | 首个数据库 happy path | `CMP-5` | 未开始 | 先用 PostgreSQL 验证 connect / inspect / query 主链路 | +| M3 | QA 输入与发布前置基线 | `CMP-10`, `CMP-12`, `CMP-13` | `CMP-10` blocked / `CMP-12` blocked / `CMP-13` in progress | QA 和发布资产可先建文档与 runbook,但受代码基线约束 | +| M4 | 多数据库覆盖 | `CMP-6`, `CMP-7` | 未开始 | 在统一抽象上补齐 MySQL、SQLite | +| M5 | 导出闭环与首发准备 | `CMP-8` | 未开始 | 形成可验收的 export 能力并衔接 smoke | +| M6 | 未来 GUI 准备 | `CMP-9` | 未开始 | 保持 gated,不进入 CLI v1 关键路径 | + +## 3. 依赖关系图 + +### 逻辑依赖 + +```text +CMP-2 + CMP-3 + ↓ + CMP-4 + ↓ + CMP-5 + ↙ ↘ +CMP-13 CMP-6 + ↓ ↓ +CMP-10 CMP-7 + ↘ ↙ + CMP-8 + ↓ + CMP-12 + +CMP-9 独立存在,但不进入 CLI v1 主路径 +``` + +### 当前执行关键路径 + +在当前 owner 分布下,真正会拖慢交付的不是文档依赖,而是后端实现集中度: + +`CMP-4` → `CMP-5` → `CMP-6` → `CMP-7` → `CMP-8` + +说明: + +- `CMP-4` 未完成前,`CMP-12` 只能停留在发布设计层。 +- `CMP-13` 可以先产出 runbook,但没有 CLI 基线时无法形成真实 smoke 输入。 +- `CMP-10` 已有首版验收文档,但真实验证仍依赖后续功能实现。 + +## 4. 关键风险 + +### P0 + +- 代码基线仍为空,当前没有任何可运行、可测试、可发布资产。 +- `CMP-4` 到 `CMP-8` 集中在同一位后端工程师名下,形成天然串行风险。 + +### P1 + +- `CMP-10` 和 `CMP-12` 已先进入 blocked,说明 QA 与发布链路会早于功能被卡住。 +- 如果 `CMP-9` 过早推进,会挤占 CLI 主路径注意力并制造范围噪音。 + +### P2 + +- 目前缺 demo、seed、smoke、Docker、README 运行说明,后续每个功能 issue 都容易因缺验证入口而返工。 + +## 5. 各角色第一轮职责 + +- 后端:先完成 `CMP-4`,再按 `CMP-5` → `CMP-6` → `CMP-7` → `CMP-8` 推进。 +- QA:继续完成 `CMP-13` 的样例与 runbook,并把 `CMP-10` 保持为“文档先行、实测待代码”的状态。 +- 前端:仅推进 `CMP-9` 的未来 GUI 信息架构,不进入 CLI 实现路径。 +- CTO:维护关键路径、阻塞升级、issue 排序和跨角色依赖收敛。 + +## 6. 状态同步节奏 + +- 日常节奏:每次 heartbeat 只同步真实状态变化,不做“空刷新”。 +- 里程碑节奏:每完成一个里程碑,立即更新本跟踪文档和 backlog 判断。 +- 阻塞节奏:出现阻塞时,同一 heartbeat 内更新 issue 状态、阻塞原因和上游 owner。 +- 向 CEO 汇报的固定项:当前关键路径、P0 风险、owner 负载集中度、是否需要调整优先级。 + +## 7. 当前管理判断 + +- 当前不建议扩编。真实瓶颈仍是“代码基线未启动”,不是“人手已经证明不足”。 +- 当前最该做的是尽快让 `CMP-4` 开工并交付最小可编译 CLI。 +- 在 `CMP-4` 完成前,不应把 blocked 的 `CMP-10` / `CMP-12` 误判为执行不力,它们属于合理前置文档工作后遇到的依赖阻塞。 diff --git a/plans/2026-03-25-dbtool-cli-v1-rust-architecture-plan.md b/plans/2026-03-25-dbtool-cli-v1-rust-architecture-plan.md new file mode 100644 index 0000000..cc79d55 --- /dev/null +++ b/plans/2026-03-25-dbtool-cli-v1-rust-architecture-plan.md @@ -0,0 +1,264 @@ +# dbtool-cli-v1 Rust 架构方案与执行计划 + +日期:2026-03-25 +作者:CTO + +## 1. 当前真实落地情况 + +- Paperclip 中已经建立项目 `dbtool-cli-v1`,并已创建第一批工程 issue。 +- 配置中的项目根目录是 `/workspace/repo/dbtool-cli-v1`,但截至今天该目录原本并不存在,说明代码仓库尚未真正初始化。 +- 当前没有可验证的 Rust workspace、可执行 CLI、demo 数据、测试链路、发布链路或跨平台产物。 +- 因此本阶段的真实状态不是“已有实现等待扩展”,而是“需求和任务已经拆出,但代码基线仍未落地”。 + +## 2. V1 工程目标 + +V1 只交付 CLI,不交付 GUI。CLI 必须覆盖: + +- 连接 PostgreSQL、MySQL、SQLite +- 浏览 schema / table / column +- 执行临时查询 +- 执行脚本 +- 导出 CSV / JSON + +当前阶段明确 gated: + +- 完整桌面 GUI +- 插件系统 +- 多进程或服务化架构 +- 复杂 ORM / migration 编排 + +## 3. 推荐 Rust workspace 结构 + +```text +dbtool-cli-v1/ + Cargo.toml + Cargo.lock + README.md + plans/ + backlog/ + apps/ + dbtool-cli/ + Cargo.toml + src/ + crates/ + db-core/ + Cargo.toml + src/ + db-drivers/ + Cargo.toml + src/ + db-config/ + Cargo.toml + src/ + examples/ + fixtures/ + scripts/ + .github/ + workflows/ +``` + +### crate 边界 + +#### `apps/dbtool-cli` + +- 命令行入口 +- 参数解析 +- 表格/文本输出 +- 调用 `db-core` 用例 +- 初期先承载导出命令编排,避免过早拆出额外 crate + +#### `crates/db-core` + +- 领域模型:连接配置、schema 树、查询请求/响应、导出请求 +- 通用错误模型 +- 驱动能力 trait +- 应用层用例:connect、inspect、query、run-script、export + +#### `crates/db-drivers` + +- PostgreSQL / MySQL / SQLite 的具体实现 +- 对 `db-core` trait 的适配 +- feature flag 控制三类数据库依赖 +- 统一管理连接池、SQL 方言差异和 introspection SQL + +#### `crates/db-config` + +- 连接 profile +- DSN 解析 +- 本地配置文件读写 +- 环境变量覆盖逻辑 + +## 4. 驱动抽象策略 + +V1 不做插件化驱动系统,也不做单独驱动进程。采用“核心 trait + 三个内建实现”的最小可行方案。 + +### 建议抽象 + +- `DatabaseKind` +- `ConnectionProfile` +- `DatabaseDriver` +- `CatalogIntrospector` +- `QueryExecutor` + +### 设计原则 + +- `db-core` 只定义能力和标准化返回结构,不依赖具体数据库类型。 +- `db-drivers` 内部按 `postgres`、`mysql`、`sqlite` 模块实现,但对上层暴露统一入口。 +- 不使用过度抽象的通用连接层来抹平所有数据库差异;差异应在驱动层显式处理并记录。 +- 不把 CLI 输出格式、表格渲染、交互提示放进核心 crate。 + +### 技术选型 + +- 异步运行时:`tokio` +- CLI 参数:`clap` +- 数据库访问:优先采用 `sqlx`,因为它原生覆盖 PostgreSQL、MySQL、SQLite,能减少多套驱动栈带来的维护成本 +- TLS:默认 `rustls` + +备注:`sqlx` 当前官方仓库明确覆盖 PostgreSQL、MySQL、SQLite;打包链路可在仓库稳定后再接入 `cargo-dist`,当前不建议先引入额外发布复杂度。 + +## 5. 依赖关系与推进顺序 + +### Phase 0:建基线 + +1. 初始化 Rust workspace +2. 跑通 CLI 二进制和 `--help` +3. 建立基础 README、开发说明、样例目录 + +### Phase 1:建公共能力 + +1. 定义 `db-core` trait 与标准返回结构 +2. 完成 `db-config` +3. 建立驱动 contract test 基座 + +### Phase 2:按数据库逐个接入 + +1. PostgreSQL +2. MySQL +3. SQLite + +顺序理由:先做服务型主路径,再覆盖文件型数据库差异。 + +### Phase 3:补全导出与稳定性 + +1. CSV / JSON 导出 +2. demo fixtures +3. smoke runbook +4. 跨平台打包 + +## 6. 测试策略 + +### 单元测试 + +- `db-config`:profile 解析、环境变量覆盖、路径处理 +- `db-core`:查询参数、错误转换、导出请求校验 + +### contract test + +- 对三种数据库复用同一组行为测试: + - 连接成功 + - 连接失败 + - schema 列举 + - table 描述 + - 简单查询 + - 参数化查询 + - 导出 + +### 集成测试 + +- PostgreSQL / MySQL:使用容器启动测试实例 +- SQLite:使用临时文件数据库 + +### CLI smoke test + +- `dbtool connect` +- `dbtool inspect` +- `dbtool query` +- `dbtool export` + +CLI 输出建议用 snapshot 测试保护,但只用于稳定文本输出,不替代行为测试。 + +## 7. 打包与发布策略 + +当前不需要“部署”,需要的是“发布可执行 CLI”。 + +### 第一阶段 + +- 先用 CI matrix 生成 macOS、Linux、Windows release binary +- 每个平台至少执行一次 `--help` smoke test +- 产出压缩包和 checksum + +### 第二阶段 + +- 当命令面稳定后,再接入 `cargo-dist` 统一发布描述和产物整理 + +这个顺序比一开始就引入复杂发布工具更稳妥。 + +## 8. 前后端与 QA 第一轮职责 + +### 后端 + +- 初始化 workspace +- 定义核心抽象 +- 按 PostgreSQL → MySQL → SQLite 顺序交付驱动 +- 落地导出能力 +- 建立打包流水线 + +### 前端 + +- 不做 GUI 实现 +- 产出未来桌面端信息架构 +- 明确连接管理、schema browser、query editor、results table 的交互边界 +- 提前识别未来 GUI 对 CLI / core 层的契约要求 + +### QA + +- 建立跨数据库验收矩阵 +- 落地 demo fixtures 与 smoke runbook +- 对 connect / inspect / query / export 建可重复回归路径 + +## 9. 与现有 issue 的对应关系 + +### 已有 issue + +- `CMP-4`:初始化 Rust workspace +- `CMP-5`:PostgreSQL +- `CMP-6`:MySQL +- `CMP-7`:SQLite +- `CMP-8`:CSV / JSON 导出 +- `CMP-9`:未来 GUI 信息架构 +- `CMP-10`:跨数据库验收矩阵 +- `CMP-11`:里程碑、依赖图和交付跟踪 + +### 已补充的 issue + +- `CMP-12`:CLI 打包、发布与跨平台 smoke 流水线 +- `CMP-13`:demo 数据库、样例脚本与 smoke runbook + +## 10. 当前关键风险 + +### P0 + +- 项目路径之前不存在,说明仓库尚未初始化,`CMP-4` 是所有实现工作的真实前置 + +### P1 + +- 产品范围文档 `CMP-2` 尚未完成前,部分命令细节和验收文字仍需与 PM 对齐 +- 当前只有一名后端工程师,驱动接入和发布链路都压在同一人身上,关键路径较长 + +### P2 + +- 没有 demo fixtures 时,QA 和后续 smoke automation 难以尽早稳定 + +## 11. 招聘建议 + +当前先不建议立即扩编。 + +原因: + +- 现阶段瓶颈是仓库初始化、架构收敛和第一条实现链路,不是人手数量 +- 在 PostgreSQL 主链路和 CI/发布链路跑通前,新增工程师的边际收益有限 + +触发招聘的条件: + +- `CMP-4` 到 `CMP-5` 跑通后,后端仍被 MySQL / SQLite / release pipeline 长时间阻塞 +- 或 GUI 方向从“设计基线”升级为并行实现项目 diff --git a/plans/2026-03-25-pm-v1-sequencing.md b/plans/2026-03-25-pm-v1-sequencing.md new file mode 100644 index 0000000..df9eef6 --- /dev/null +++ b/plans/2026-03-25-pm-v1-sequencing.md @@ -0,0 +1,15 @@ +# dbtool-cli-v1 PM Sequencing + +## Recommended Delivery Order + +1. bootstrap an empty but runnable Rust CLI workspace +2. establish a shared connection and inspection model +3. ship PostgreSQL happy path for connect, inspect, and query +4. reach MySQL parity on the same workflow +5. reach SQLite parity with documented structural differences +6. add CSV and JSON export on top of query results +7. validate the cross-database acceptance matrix before any GUI follow-on + +## Sequencing Rule + +Do not start GUI execution work until the CLI acceptance path is stable enough to validate the product workflow end to end. diff --git a/plans/2026-03-25-qa-cmp-13-demo-runbook-plan.md b/plans/2026-03-25-qa-cmp-13-demo-runbook-plan.md new file mode 100644 index 0000000..8e2d89e --- /dev/null +++ b/plans/2026-03-25-qa-cmp-13-demo-runbook-plan.md @@ -0,0 +1,20 @@ +# CMP-13 QA Demo / Smoke Plan + +日期:2026-03-25 + +## Goal + +为 `dbtool-cli-v1` 提供可重复的 demo 数据、样例 SQL 输入和 smoke runbook,并明确当前不可执行的阻塞点。 + +## Planned Steps + +1. 盘点当前仓库与共享 QA 基线 +2. 设计跨数据库最小样例数据 +3. 落地 PostgreSQL / MySQL / SQLite seed 资产 +4. 落地本地 bootstrap 与 Docker demo 路径 +5. 落地 smoke runbook 与预期断言 +6. 记录阻塞并同步 CTO / PM + +## Exit Rule + +如果 CLI 可执行入口和命令契约仍未落地,则本任务输出共享 QA 资产后进入阻塞状态,不伪造“已通过”。 diff --git a/plans/2026-03-26-cmp-18-git-repo-bootstrap-plan.md b/plans/2026-03-26-cmp-18-git-repo-bootstrap-plan.md new file mode 100644 index 0000000..beaa279 --- /dev/null +++ b/plans/2026-03-26-cmp-18-git-repo-bootstrap-plan.md @@ -0,0 +1,51 @@ +# CMP-18 Git 仓库与 agent 提交推送机制计划 + +日期:2026-03-26 +作者:CTO + +## 目标 + +让 `dbtool-cli-v1` 从“已有工作区但没有 Git 边界”切换为“有独立仓库、有远程、有提交规则、有后续可复用 push 机制”的项目。 + +## 阶段拆解 + +### Phase 0:仓库边界落地 + +- 在项目根目录初始化独立 Git 仓库 +- 设定默认分支 `main` +- 配置远程 `origin` +- 完成首次受控 commit + +### Phase 1:提交与推送治理 + +- 明确推荐认证方案:HTTPS + bot token +- 提供最小 `GIT_ASKPASS` 脚本 +- 明确 commit、branch、push、review 规则 +- 明确前端、后端、QA、集成类 issue 的提交责任边界 + +### Phase 2:平台化复用 + +- 将本仓库的 Git 工作流沉淀为模板 +- 未来在前端、后端、全栈项目中复用同一认证注入与 branch 规则 + +## 第一轮职责 + +- CTO:完成仓库 bootstrap、治理文档、认证方案定稿 +- 后端:按 issue 独立提交后端改动,不再等待“统一代提交” +- 前端:未来 GUI / Web 项目沿用同一 branch 与 review 规则 +- QA:验证受保护分支下的 smoke / CI gate 是否满足合并要求 + +## 风险与约束 + +- 当前未注入 Gitea 写权限凭据,因此本次只验证本地 commit,不验证远程 push +- 若未来允许 agent push,则必须先在 Gitea 启用 protected branch +- 若未来一个 issue 涉及多人协作,必须先明确最终集成 owner,避免“都能改、没人收口” + +## 完成定义 + +满足以下条件即可认为 `CMP-18` 达成: + +- 本地已是独立 Git 仓库 +- 远程仓库 URL 已明确并配置 +- 共享文档已写清推荐认证与治理规则 +- agent 已完成一次受控 commit diff --git a/plans/2026-03-26-qa-runtime-smoke-environment.md b/plans/2026-03-26-qa-runtime-smoke-environment.md new file mode 100644 index 0000000..0cc8734 --- /dev/null +++ b/plans/2026-03-26-qa-runtime-smoke-environment.md @@ -0,0 +1,37 @@ +# dbtool-cli-v1 PostgreSQL / MySQL runtime smoke 环境计划 + +日期:2026-03-26 + +## 背景 + +- 当前仓库已具备 `docker-compose.demo.yml`、PostgreSQL / MySQL bootstrap 脚本、fixtures 与 `SMOKE_RUNBOOK.md` +- QA 已完成 Linux 本地 SQLite smoke,但 PostgreSQL / MySQL runtime smoke 仍因当前 runner 无 Docker 而阻塞 +- 当前问题的根因是**执行宿主缺失**,不是产品功能缺失 + +## 决策 + +采用**宿主机执行 + Docker Compose 临时数据库**作为当前标准方案。 + +- 推荐说明见 `QA_RUNTIME_ENVIRONMENT.md` +- 不采用长期宿主机数据库作为主路径 +- 不在当前阶段为 `local-db-codex` 增加 Docker runtime + +## 第一轮职责 + +- 后端:保持 compose / seed / SQL 输入稳定 +- QA:在 Docker-capable host 执行 PostgreSQL / MySQL smoke 并回填证据 +- CTO:完成方案裁决、任务拆解与阻塞升级 +- 前端:保持 gated,不进入当前执行链路 + +## 完成标准 + +- QA 能按 `QA_RUNTIME_ENVIRONMENT.md` 与 `SMOKE_RUNBOOK.md` 完成 PostgreSQL smoke +- QA 能按 `QA_RUNTIME_ENVIRONMENT.md` 与 `SMOKE_RUNBOOK.md` 完成 MySQL smoke +- 若执行仍失败,阻塞必须明确归因到宿主、Docker、二进制或产品缺陷之一 + +## 2026-03-26 当前执行记录 + +- 当前 QA runner 已验证存在预编译 `target/release/dbtool`,且 `--help` / `--version` 正常 +- 当前 QA runner 可通过 `examples/scripts/bootstrap-sqlite.sh` 复核 SQLite happy path +- 当前 QA runner 不存在 `docker`,因此无法拉起 `docker-compose.demo.yml` +- 当前 QA runner 不存在 `cargo`,因此宿主机执行时需提前准备 Rust toolchain 或直接分发预编译二进制 diff --git a/scripts/git/gitea-askpass.sh b/scripts/git/gitea-askpass.sh new file mode 100755 index 0000000..06a63ee --- /dev/null +++ b/scripts/git/gitea-askpass.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +case "$1" in + *Username*) + printf '%s\n' "${GITEA_USERNAME:?GITEA_USERNAME is required}" + ;; + *Password*) + printf '%s\n' "${GITEA_TOKEN:?GITEA_TOKEN is required}" + ;; + *) + exit 1 + ;; +esac diff --git a/scripts/release/package-unix.sh b/scripts/release/package-unix.sh new file mode 100755 index 0000000..1eac64b --- /dev/null +++ b/scripts/release/package-unix.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 2 ]; then + echo "usage: scripts/release/package-unix.sh " >&2 + exit 2 +fi + +target_triple="$1" +binary_path="$2" +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +version="$(node "$repo_root/scripts/release/version.mjs")" +artifact_basename="dbtool-$version-$target_triple" +artifact_dir="$repo_root/dist/$artifact_basename" +archive_path="$repo_root/dist/$artifact_basename.tar.gz" +checksum_path="$archive_path.sha256" + +mkdir -p "$repo_root/dist" +rm -rf "$artifact_dir" +mkdir -p "$artifact_dir" + +cp "$binary_path" "$artifact_dir/dbtool" +cp "$repo_root/README.md" "$artifact_dir/README.md" +cp "$repo_root/RELEASE_RUNBOOK.md" "$artifact_dir/RELEASE_RUNBOOK.md" +cp "$repo_root/SMOKE_RUNBOOK.md" "$artifact_dir/SMOKE_RUNBOOK.md" + +tar -czf "$archive_path" -C "$repo_root/dist" "$artifact_basename" + +( + cd "$repo_root/dist" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$(basename "$archive_path")" > "$(basename "$checksum_path")" + else + shasum -a 256 "$(basename "$archive_path")" > "$(basename "$checksum_path")" + fi +) diff --git a/scripts/release/smoke-binary.sh b/scripts/release/smoke-binary.sh new file mode 100755 index 0000000..9b79b88 --- /dev/null +++ b/scripts/release/smoke-binary.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "usage: scripts/release/smoke-binary.sh " >&2 + exit 2 +fi + +binary_path="$1" + +"$binary_path" --help +"$binary_path" --version diff --git a/scripts/release/version.mjs b/scripts/release/version.mjs new file mode 100644 index 0000000..9fbe3ca --- /dev/null +++ b/scripts/release/version.mjs @@ -0,0 +1,11 @@ +import { readFileSync } from "node:fs"; + +const cargoToml = readFileSync(new URL("../../Cargo.toml", import.meta.url), "utf8"); +const match = cargoToml.match(/\[workspace\.package\][\s\S]*?version\s*=\s*"([^"]+)"/); + +if (!match) { + console.error("failed to resolve workspace.package.version from Cargo.toml"); + process.exit(1); +} + +process.stdout.write(match[1]);