10 Commits

Author SHA1 Message Date
claude@clouddev1 bd5be5ecc7 fix(ci): read release version from Cargo.toml, not cargo metadata (ADR-0054)
ci / gate (push) Successful in 3m12s
release / test (push) Successful in 2m44s
release / build (aarch64-pc-windows-gnullvm) (push) Successful in 3m56s
release / build (aarch64-unknown-linux-musl) (push) Successful in 4m18s
release / build (x86_64-pc-windows-gnu) (push) Successful in 4m39s
release / build (x86_64-unknown-linux-musl) (push) Successful in 3m50s
The ADR-0054 version guard piped `nix develop -c cargo metadata` to node,
but the flake devShell prints a banner to stdout — corrupting the JSON
pipe, so the guard aborted under `set -e` and the v0.2.0 release failed
there (before building anything). Replace it with a toolchain-free
`grep -m1 '^version = ' Cargo.toml` (the anchor excludes dependency
`version =` keys). No real version mismatch occurred — the tagged commit
has version 0.2.0.
2026-06-17 21:58:32 +00:00
claude@clouddev1 88830ed06a chore(release): bump version to 0.2.0
ci / gate (push) Successful in 3m9s
release / test (push) Failing after 1m43s
release / build (aarch64-pc-windows-gnullvm) (push) Has been skipped
release / build (aarch64-unknown-linux-musl) (push) Has been skipped
release / build (x86_64-pc-windows-gnu) (push) Has been skipped
release / build (x86_64-unknown-linux-musl) (push) Has been skipped
First release carrying the version surfaces (--version / -V / the in-app
version command, ADR-0054), the curl|sh + PowerShell installers
(ADR-0055), crates.io/binstall readiness (ADR-0056), the verified hint
corpus, the Ctrl-G demo F1 alias, and the cargo fmt gate (#35). The
release guard checks this equals the v0.2.0 tag.
2026-06-17 21:46:52 +00:00
claude@clouddev1 ec3c7c304c ci: enable the cargo fmt --check gate (ADR-ci-002 Amendment 1)
ci / gate (push) Successful in 3m17s
Adds `cargo fmt --check` (stock defaults) to ci.yaml's gate, now that the
tree is rustfmt-clean (commit 41b7e9a). Records that reformat in
.git-blame-ignore-revs so `git blame` skips it. Amends ADR-ci-002 (the
deferred "revisit on main" fmt decision) + the ci ADR index.

Closes #35.
2026-06-17 21:40:58 +00:00
claude@clouddev1 41b7e9a049 style: format the whole tree with cargo fmt (stock defaults, #35)
One-time, mechanical reformat — no functional changes. The tree was not
rustfmt-clean (~1800 hunks across ~100 files); this brings it to stock
`cargo fmt` defaults so a `cargo fmt --check` CI gate can follow.
Behaviour-preserving: 2509 pass / 0 fail / 1 ignored (unchanged baseline),
clippy clean. A .git-blame-ignore-revs entry follows so `git blame`
skips this commit.
2026-06-17 21:39:19 +00:00
claude@clouddev1 e9606b5f6d feat(dist): crates.io + binstall + Windows install.ps1 + license files
ci / gate (push) Successful in 3m14s
Distribution prep on the road to public availability (plan steps 2–3a).

- Cargo.toml: publish-ready (drop publish=false; homepage/keywords/
  categories/exclude) + [package.metadata.binstall] with per-target
  overrides (linux-gnu->musl, windows-msvc->gnu/gnullvm). dry-run clean.
- scripts/install.ps1: Windows `irm | iex` one-liner — written but
  untested here (no PowerShell; validate on Windows). README Windows block.
- README.md (new); LICENSE-MIT + LICENSE-APACHE (dual, (c) Lazy
  Evaluation Ltd); CONTRIBUTING.md (inbound=outbound dual-license note).
- ADR-0055 Amendment 1 (install.ps1), ADR-0056 (crates.io/binstall),
  README index + plan updates.

The actual `cargo publish` remains a gated maintainer step (token,
irreversible) at a new tagged release; real cargo-binstall validation
pending.
2026-06-17 21:25:45 +00:00
claude@clouddev1 ef99e6c676 feat(install): curl|sh installer script (ADR-0055)
ci / gate (push) Successful in 3m19s
website / deploy (push) Successful in 1m58s
scripts/install.sh — POSIX sh, shellcheck-clean: detects uname OS/arch ->
target triple (Linux uses the static musl build; Windows rejected with a
Scoop/winget pointer), resolves the latest release (or RDBMS_VERSION),
downloads the asset + its .sha256 and verifies it, installs to
~/.local/bin with a PATH hint. RDBMS_OS/RDBMS_ARCH + --print-target are
testing seams. Verified end-to-end against the live public v0.1.0 (all
mappings, pinned + latest, checksum incl. tamper-rejection, install+run).

ADR-0055 + README index; plan-doc step 2 done + decisions recorded
(crates.io=yes, releases public, tracking via doc+ADR).
2026-06-17 19:41:34 +00:00
claude@clouddev1 c30a6114b9 feat(cli): --version/-V + in-app version command + release guard (ADR-0054)
Cargo.toml version is the single source of truth, surfaced by a
--version/-V CLI flag and an in-app `version` command (both via
cli::version_text -> cli.version_line). release.yaml gains a guard that
fails the release unless the v* tag equals v<CARGO_PKG_VERSION>, keeping
--version, the release name, and the asset in lockstep. New app command
wired across grammar/REGISTRY/dispatch/usage/help/hint-corpus/keys; 6
test-first tests. Also fixes a stale "macOS deferred" comment in
release.yaml. ADR-0054 + README index + plan-doc step 1.
2026-06-16 15:57:54 +00:00
claude@clouddev1 fe9d58e037 docs: plan the road to public availability (versioning, install, packaging) 2026-06-16 15:57:48 +00:00
claude@clouddev1 628b250db6 docs: reconcile docs after ci+website merges; gitignore wrangler/vscode
Post-merge documentation accuracy pass (CI and website branches both
merged to main; website deployed).

- CLAUDE.md: rewrite the stale repository-layout tree; add a Website &
  docs-site decision bullet (Astro+Starlight, Cloudflare Pages via Gitea
  Actions, ADR-website-001, the website branch stays open); update the CI
  note (merged to main; release-macos dispatchable + verified working).
- requirements.md: D1 — macOS targets now runtime-verified (release-macos
  dispatched end-to-end); DOC1 — canonical user docs now live on the
  deployed website.
- ADR-ci-003 (+ docs/ci/adr README): Amendment 2 — CI on main,
  release-macos dispatched + verified; macOS runtime-verified.
- docs/website/adr README: drop the stale "no CI yet".
- .gitignore: ignore .wrangler/ (Cloudflare Wrangler cache) and .vscode/;
  remove the tracked website/.vscode/ an Astro template had added.

D3 (package-manager manifests) + some install instructions remain open.
2026-06-16 15:06:49 +00:00
claude@clouddev1 784373a254 docs: handoff 73 — Ctrl-G demo-mode F1 alias (ADR-0047 Amendment 1) 2026-06-16 14:41:28 +00:00
129 changed files with 9475 additions and 5037 deletions
+7
View File
@@ -0,0 +1,7 @@
# Revisions to ignore in `git blame` — bulk, mechanical, no-behaviour-change
# commits whose authorship is noise. Enable locally with:
# git config blame.ignoreRevsFile .git-blame-ignore-revs
# (Forges that support it, e.g. GitHub, pick this up automatically.)
# style: format the whole tree with cargo fmt (stock defaults, #35)
41b7e9a04992cd9708f1775b57044de838b48b85
+6 -3
View File
@@ -2,9 +2,10 @@
# build-ci-image.yaml), so the pinned 1.95.0 toolchain is already warm — steps
# just enter the flake devShell and run cargo.
#
# Gate = clippy + test. fmt is deliberately NOT gated yet (ADR-ci-002: the tree
# isn't clean under stock rustfmt; revisit on main). The release job (static
# binary for D2) and the platform matrix layer on later, step by step.
# Gate = fmt + clippy + test. The fmt gate (`cargo fmt --check`, stock defaults)
# was enabled once the tree was reformatted on main (ADR-ci-002 Amendment 1 /
# issue #35). The release job (static binary for D2) and the platform matrix
# layer on later, step by step.
name: ci
on:
push:
@@ -39,6 +40,8 @@ jobs:
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
steps:
- uses: actions/checkout@v4
- name: fmt (check, stock defaults)
run: nix develop -c cargo fmt --check
- name: clippy (warnings denied)
run: nix develop -c cargo clippy --all-targets -- -D warnings
- name: test
+28 -3
View File
@@ -5,11 +5,15 @@
# Matrix (D1, cross-built from Linux x86_64 via cargo-zigbuild):
# x86_64-unknown-linux-musl aarch64-unknown-linux-musl (static, D2)
# x86_64-pc-windows-gnu aarch64-pc-windows-gnullvm (standalone .exe)
# macOS is deferred — its arboard/AppKit link needs Apple's SDK (see ADR-ci-001).
# D3 package-manager manifests layer on later.
# The two macOS targets are built separately by the dispatched
# release-macos.yaml (native Tart runner; ADR-ci-003 amendment), uploading to
# the same release. D3 package-manager manifests layer on later.
#
# Tests run once (host) before the matrix, so a tag can never publish untested
# code, even one pointing at a commit that was never gated on a branch.
# code, even one pointing at a commit that was never gated on a branch. The
# version guard (ADR-0054) refuses to publish a tag whose vX.Y.Z disagrees with
# Cargo.toml's version, keeping `--version`, the release name, and the asset in
# lockstep.
name: release
on:
push:
@@ -23,6 +27,27 @@ jobs:
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
steps:
- uses: actions/checkout@v4
- name: version guard — tag must equal Cargo.toml version (ADR-0054)
shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
# Read the [package] version straight from Cargo.toml — toolchain-free
# and robust. (An earlier `nix develop -c cargo metadata | node` version
# broke: the flake devShell prints a banner to stdout, corrupting the
# JSON pipe.) `^version = ` is anchored, so it matches only the package
# version, never the `version = ` inside dependency inline tables.
VER=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/^version = "(.*)"/\1/')
echo "tag=$TAG cargo=v$VER"
if [ -z "$VER" ]; then
echo "ERROR: could not read the package version from Cargo.toml" >&2
exit 1
fi
if [ "$TAG" != "v$VER" ]; then
echo "ERROR: release tag '$TAG' != 'v$VER' (Cargo.toml). Bump Cargo.toml to the release version, commit, then retag (ADR-0054)." >&2
exit 1
fi
- name: test
run: nix develop -c cargo test --no-fail-fast
+6
View File
@@ -12,7 +12,13 @@
*.snap.new
*.pending-snap
# Website tooling — Cloudflare Wrangler local cache/state (regenerable;
# CI deploys from website/, this dir only appears on a local wrangler run)
.wrangler/
# Editor / OS
.DS_Store
*.swp
*.swo
# Astro/template-seeded editor configs we don't track (e.g. website/.vscode)
.vscode/
+63 -34
View File
@@ -108,10 +108,11 @@ Current decisions at a glance (each backed by an ADR):
SQL `select` / `with` / `insert` / `update` / `delete`
(ADR-0039). `EXPLAIN QUERY PLAN` never executes, so
explaining a destructive command is safe.
- **Continuous integration & release** (built on the `ci` branch,
2026-06-15; decisions in `docs/ci/adr/` — **ADR-ci-001/002/003**,
a namespace kept separate from the main ADR sequence to avoid
cross-branch number collisions, like the website's): a self-hosted
- **Continuous integration & release** (developed on the `ci` branch,
**merged to `main` 2026-06-15**; decisions in `docs/ci/adr/` —
**ADR-ci-001/002/003**, a namespace kept separate from the main ADR
sequence to avoid cross-branch number collisions, like the website's):
a self-hosted
**Gitea Actions** pipeline built on a **nix flake** (pinned Rust
`1.95.0` — one source of toolchain for dev *and* CI) plus a
prebuilt CI image. **Gate** (`ci.yaml`): `clippy -D warnings` +
@@ -122,44 +123,72 @@ Current decisions at a glance (each backed by an ADR):
`release-macos.yaml` on a Tart Apple-Silicon runner (de-nix the
`libiconv` load path + ad-hoc re-sign). All published to a Gitea
release with `.sha256`s. **`fmt` is intentionally not gated yet**
(the tree isn't stock-`rustfmt`-clean). `workflow_dispatch` is
Gitea-default-branch-only, so `release-macos` is dispatchable once
this lands on `main`.
(the tree isn't stock-`rustfmt`-clean). Now that this is on `main`,
`release-macos` is dispatchable (`workflow_dispatch` is
Gitea-default-branch-only) — **dispatched and verified working**: the
macOS build + de-nix/re-sign + upload runs end-to-end and the binaries
launch.
- **Website & docs site** (developed on the `website` branch, **merged
to `main`**; the branch **stays open** for future staging deploys;
decisions in `docs/website/adr/` — **ADR-website-001**, its own
namespace like CI's): an **Astro + Starlight + Tailwind** marketing
landing page plus the **canonical** user docs, under `website/`.
Showcase demos are **asciinema casts** (a scripted-input driver, paced
+ re-recordable — *not* `history.log` replay; the `--demo` overlay,
ADR-0047, dresses them). **Deployed to Cloudflare Pages via Gitea
Actions** (`.gitea/workflows/website.yaml`; the crate gate is skipped
for website-only changes). Two copy rules bind user-facing text: **no
engine name** (continues ADR-0002) and **no "DSL"** (say "simple mode"
/ "advanced mode"). Install docs are still partial — package-manager
manifests + some installation instructions are pending (`requirements.md`
D3 / DOC1).
## Repository layout
```
.
├── Cargo.toml # dependencies, lints (nursery)
├── CLAUDE.md # this file
├── Cargo.toml / Cargo.lock # dependencies, lints (nursery)
├── CLAUDE.md # this file
├── flake.nix / flake.lock # pinned Rust toolchain, one source for dev + CI (ADR-ci-002)
├── rust-toolchain.toml # toolchain pin
├── .gitea/ # Gitea Actions workflows + prebuilt CI image (ADR-ci-001..003, website)
├── ci/ # CI build helpers (e.g. winstub/ — Windows link stub)
├── docs/
│ ├── adr/ # all decision records (read 0000 first)
│ ├── handoff/ # session-handover notes
── requirements.md # the Phase-1 checklist with progress
│ ├── adr/ # project-wide decision records (read 0000 first)
│ ├── ci/{adr,handoff}/ # CI subproject ADRs (ci-001..003) + handoffs
── website/{adr,plans}/ # website subproject ADRs (website-001) + plan
│ ├── handoff/ # session-handover notes
│ ├── plans/ # working plans
│ └── requirements.md # the Phase-1 checklist with progress
├── src/
│ ├── action.rs # Action enum (Quit / ExecuteDsl)
│ ├── app.rs # App state + pure update() + Tier-1 tests
│ ├── cli.rs # CLI args (--theme, --log-file)
│ ├── db.rs # rusqlite worker, all DDL/DML, metadata tables
│ ├── action.rs # Action enum (Quit / ExecuteDsl / …)
│ ├── app.rs # App state + pure update() + Tier-1 tests
│ ├── cli.rs # CLI args (--theme, --log-file, --demo, --no-undo, --resume, …)
│ ├── clipboard.rs # copy output to the system clipboard
│ ├── completion.rs # Tab completion + schema cache
│ ├── db.rs # rusqlite worker, all DDL/DML, metadata tables
│ ├── dsl/
│ │ ├── action.rs # ReferentialAction enum + parsing
│ │ ├── command.rs # Command AST + RelationshipSelector + RowFilter
│ │ ├── mod.rs # re-exports
│ ├── parser.rs # parse entry point → unified-grammar walker
│ ├── shortid.rs # base58 generator + validator
│ ├── types.rs # user-facing Type enum + fk_target_type
│ └── value.rs # Value/Bound + per-type validation
│ ├── event.rs # AppEvent (input + DSL outcomes)
│ ├── lib.rs # module re-exports for tests
│ ├── logging.rs # tracing setup, file-backed
│ ├── main.rs # binary entry; thin
│ ├── mode.rs # Simple/Advanced mode enum
│ ├── runtime.rs # Tokio loop, terminal setup, dispatch
│ ├── snapshots/ # insta snapshots for Tier-2 tests
── theme.rs # light/dark themes
│ └── ui.rs # ratatui rendering
└── tests/
└── walking_skeleton.rs # Tier-3 integration tests
│ │ ├── grammar/ # hand-rolled unified grammar nodes (DSL + SQL)
│ │ ├── walker/ # grammar walker (driver / context / highlight / outcome)
│ │ ├── command.rs parser.rs types.rs value.rs action.rs shortid.rs sql_functions.rs
├── echo.rs # command echo / SQL rendering
├── event.rs # AppEvent (input + DSL outcomes)
├── friendly/ # friendly-error layer + string catalog (strings/en-US.yaml) + keys
├── input_render.rs # input-field render + ambient hint classification
│ ├── output_render.rs # output-panel render helpers (incl. relationship diagrams)
│ ├── logging.rs main.rs mode.rs runtime.rs # tracing / entry / mode enum / Tokio loop
│ ├── persistence/ # csv + yaml + history IO + migrations
│ ├── project/ # open/create, lock, naming, prettifier
│ ├── seed/ # seed generators / heuristics / vocabulary (ADR-0048)
│ ├── snapshots/ # insta snapshots for Tier-2 tests
│ ├── theme.rs type_change.rs ui.rs undo.rs # themes / column type-change / render / undo ring
── lib.rs # module re-exports for tests
├── tests/
│ ├── it/ # Tier-3 integration tests (consolidated into one binary)
└── typing_surface_matrix.rs # typing-surface matrix (separate Tier-3 target)
└── website/ # Astro + Starlight docs/marketing site (ADR-website-001)
├── src/ public/ casts-src/ # pages + assets + asciinema cast sources
└── astro.config.mjs package.json … # deploys to Cloudflare Pages via Gitea Actions
```
Key invariants in the code:
+20
View File
@@ -0,0 +1,20 @@
# Contributing to rdbms-playground
Contributions are welcome — bug reports, ideas, and pull requests. The
project lives on Gitea at
<https://git.lazyeval.net/oli/rdbms-playground>; please file issues and
open pull requests there. It's approaching its first public release, so
the most useful contributions right now are bug reports and rough edges
you hit while learning.
## License of contributions
Unless you explicitly state otherwise, any contribution you intentionally
submit for inclusion in this project — as defined in the Apache-2.0
license — shall be **dual-licensed under `MIT OR Apache-2.0`** (the
project's licenses), without any additional terms or conditions.
This is the standard Rust "inbound = outbound" arrangement: your
contribution is offered under the same licenses the project distributes
under, so — via Apache-2.0 §5 — it carries the Apache-2.0 §3 patent grant
to all users. No separate CLA is required.
Generated
+1 -1
View File
@@ -1535,7 +1535,7 @@ dependencies = [
[[package]]
name = "rdbms-playground"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"anyhow",
"arboard",
+41 -2
View File
@@ -1,12 +1,21 @@
[package]
name = "rdbms-playground"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
description = "A cross-platform TUI playground for learning relational databases."
license = "MIT OR Apache-2.0"
repository = "https://git.lazyeval.net/oli/rdbms-playground"
homepage = "https://relplay.org"
readme = "README.md"
publish = false
keywords = ["database", "sql", "tui", "learning", "playground"]
categories = ["command-line-utilities", "database"]
# Keep the published crate to the code that builds the binary — the website,
# decision records, and CI plumbing are repo-only (ADR-0056).
exclude = ["/website", "/docs", "/.gitea", "/.codegraph"]
# `publish = false` removed (ADR-0056): the crate is intended for
# crates.io. The actual `cargo publish` is a deliberate, irreversible
# maintainer step (needs the crates.io token) — do it at a tagged release
# whose assets the binstall metadata below points at.
[dependencies]
anyhow = "1.0.102"
@@ -85,3 +94,33 @@ nursery = { level = "warn", priority = -1 }
module_name_repetitions = "allow"
missing_errors_doc = "allow"
missing_panics_doc = "allow"
# cargo-binstall (ADR-0056): let `cargo binstall rdbms-playground` fetch the
# prebuilt release binary instead of compiling from source. Our release assets
# are BARE binaries (no archive) named `rdbms-playground-v<version>-<target>`
# (`.exe` on Windows) with `.sha256` sidecars (ADR-ci-003), so `pkg-fmt = "bin"`.
# `{ version }` excludes the leading `v`, so the template spells `v{ version }`.
#
# Target mapping: macOS host triples match our asset triples directly. But we
# ship the fully-static *-linux-MUSL build (glibc hosts are *-linux-gnu) and
# *-windows-GNU/GNULLVM (most Windows hosts are *-msvc), so those common host
# triples need explicit overrides pointing at the asset we actually publish.
#
# NOTE: unverified against a real `cargo binstall` run (binstall isn't a dep and
# nothing is on crates.io yet) — validate at the first publish + matching release.
[package.metadata.binstall]
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-{ target }{ archive-suffix }"
pkg-fmt = "bin"
bin-dir = "{ bin }{ binary-ext }"
[package.metadata.binstall.overrides.x86_64-unknown-linux-gnu]
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-x86_64-unknown-linux-musl"
[package.metadata.binstall.overrides.aarch64-unknown-linux-gnu]
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-aarch64-unknown-linux-musl"
[package.metadata.binstall.overrides.x86_64-pc-windows-msvc]
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-x86_64-pc-windows-gnu.exe"
[package.metadata.binstall.overrides.aarch64-pc-windows-msvc]
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-aarch64-pc-windows-gnullvm.exe"
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Lazy Evaluation Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Lazy Evaluation Ltd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+98
View File
@@ -0,0 +1,98 @@
# rdbms-playground
A cross-platform terminal app for **learning relational database
concepts** — tables, columns, primary and foreign keys, relationships,
indexes, queries, and query plans — in a safe sandbox.
It's a teaching tool, not a database administration tool. It meets
beginners with guided, keyword-based commands (**simple mode**) and grows
with them to raw SQL (**advanced mode**), so the same playground works
from "what is a primary key?" through to writing real queries and reading
their execution plans.
Website & documentation: **<https://relplay.org>**
## Install
### One line (Linux / macOS)
```sh
curl -fsSL https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.sh | sh
```
Detects your OS and CPU, downloads the matching release binary, verifies
its SHA-256 checksum, and installs it to `~/.local/bin`. Set
`RDBMS_INSTALL_DIR` to install elsewhere, or `RDBMS_VERSION=vX.Y.Z` to
pin a version. (Prefer to read before you run? The script lives at
`scripts/install.sh`.)
### One line (Windows, PowerShell)
```powershell
irm https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.ps1 | iex
```
Downloads the matching `.exe`, verifies its checksum, installs to
`%LOCALAPPDATA%\Programs\rdbms-playground`, and adds it to your user
PATH. Or use a package manager (Scoop / winget) once those land.
### With `cargo binstall`
If you have [`cargo-binstall`](https://github.com/cargo-bins/cargo-binstall)
(install it first — it is not part of `cargo` itself):
```sh
cargo binstall rdbms-playground
```
### From source
```sh
cargo install rdbms-playground # from crates.io
# or, from a clone:
cargo build --release # binary at target/release/rdbms-playground
```
### Prebuilt binaries
Every release publishes static Linux, standalone Windows, and macOS
binaries (x86_64 and aarch64) with `.sha256` checksums on the
[releases page](https://git.lazyeval.net/oli/rdbms-playground/releases).
Windows users can also use the binary directly (package-manager support
is planned).
## A quick taste
```
create table Customers with pk id(serial)
add column Customers: name (text)
add column Customers: email (text)
insert into Customers values ('Ann', 'ann@example.io')
show data Customers
```
Press **F1** while typing for a contextual hint about the command you're
building, or type `help` for the full command list. Switch to raw SQL
with `mode advanced` (or prefix a single line with `:`).
## Project status
Approaching its first public release. See the website for current
features; installation via package managers (Homebrew, Scoop, winget) is
on the roadmap.
## License
Dual-licensed under either of
- MIT license ([LICENSE-MIT](LICENSE-MIT))
- Apache License 2.0 ([LICENSE-APACHE](LICENSE-APACHE))
at your option.
## Contribution
Unless you explicitly state otherwise, any contribution intentionally
submitted for inclusion in the work by you, as defined in the Apache-2.0
license, shall be dual-licensed as above, without any additional terms or
conditions. See [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -0,0 +1,73 @@
# ADR-0054: Release versioning policy + version surfaces (`--version` / `version`)
## Status
Accepted — **implemented 2026-06-16** (plan:
`docs/plans/20260616-public-availability.md`, step 1). First step on the
road to public availability. Adds a `--version` / `-V` CLI flag and an
in-app `version` command, both reporting `CARGO_PKG_VERSION`, plus a
release-CI guard that the `v*` tag equals that version. No prior issue or
`requirements.md` item existed for this — it was an untracked gap.
## Context
Before this, `Cargo.toml` carried `version = "0.1.0"`, the binary exposed
**no** way to report its version, and `release.yaml` named assets from the
**git tag** (`rdbms-playground-<tag>-<target>`) while building from
`Cargo.toml`. Tag and crate version were **decoupled**: tagging `v0.2.0`
would publish an asset named `…-v0.2.0-…` containing a binary that (had it
been able to say) reported `0.1.0`. On the way to public availability —
where users download a versioned artifact and file bug reports against "the
version I'm running" — that drift is a correctness problem.
## Decision
1. **`Cargo.toml` `version` is the single source of truth.** This is the
idiomatic Rust position and avoids making `Cargo.toml` lie. The version
is read at compile time via `env!("CARGO_PKG_VERSION")`; no build-time
injection of the tag into the crate.
2. **Two user-facing surfaces, one source:**
- **`--version` / `-V`** — CLI flag (hand-rolled parser, mirrors
`--help`): prints and exits before any other work (`main.rs`).
- **`version`** — an in-app app command (REGISTRY node `app::VERSION`,
`AppCommand::Version`), emitting the same line into the output panel.
Both go through `cli::version_text()` → the catalog key
`cli.version_line` (`"rdbms-playground {version}"`), so there is exactly
one rendered string and one version source.
3. **Release-CI discipline.** `release.yaml`'s pre-build `test` job gains a
**version guard**: it reads the `[package]` version directly from
`Cargo.toml` (`grep -m1 '^version = '` — toolchain-free; an earlier
`cargo metadata | node` form broke on the flake devShell's stdout banner)
and **fails the release** unless the pushed tag equals `v<that version>`.
So `--version`, the release name, and the downloaded asset are always in
lockstep — enforced by the machine, not by memory.
4. **The release ritual:** bump `Cargo.toml` → commit → tag `v<that
number>` → push the tag. The guard rejects any deviation.
### Rejected / deferred
- **Inject the tag into the build** (tag as source of truth): fiddly with
cargo and makes `Cargo.toml` a placeholder/lie. Rejected.
- **Git-hash + build-date enrichment** (a `build.rs` so dev builds read
`0.2.0 (a1b2c3d)`): useful for bug reports, but not needed for the
tag↔release↔`--version` consistency this ADR is about. Deferred; can be
added behind the same `version_text()` seam without changing the policy.
- **UI placement beyond the `version` command** (status-bar string, etc.):
the command + `help` listing is enough for now (user decision).
## Consequences
- A release can no longer ship a binary whose self-reported version
disagrees with its tag/filename.
- Cutting a release now *requires* a `Cargo.toml` bump commit — a small,
deliberate step (and a natural place to update a changelog later).
- New keys: `cli.version_line` (+ `help.app.version`, `parse.usage.version`,
`hint.cmd.version.what`/`.example`); a new REGISTRY command means the
comprehensiveness coverage test now also requires `hint.cmd.version`,
which is supplied. Tested: CLI flag parse (`--version`/`-V`/default-off),
`version_text()` carries `CARGO_PKG_VERSION`, the in-app command parses to
`AppCommand::Version` and emits the version.
- This is step 1 of `docs/plans/20260616-public-availability.md`; the
installer (`install.sh`) and package-manager work (D3) build on top.
+86
View File
@@ -0,0 +1,86 @@
# ADR-0055: `curl | sh` install script (`scripts/install.sh`)
## Status
Accepted — **implemented 2026-06-17** (plan:
`docs/plans/20260616-public-availability.md`, step 2). Step 2 on the road
to public availability, building on ADR-0054 (versioned releases) and
ADR-ci-001/003 (the Gitea releases it downloads from). Tracked by the
plan + this ADR (no Gitea issue — user decision, 2026-06-17).
## Context
Until now, installing meant: find the releases page, work out which of
the six assets matches your machine, download it, `chmod +x`, move it
onto `PATH`, and (on macOS) wonder about Gatekeeper. That is too much
friction for a teaching tool aimed at beginners. The Gitea releases are
**publicly downloadable** (confirmed), with deterministic asset names
(`rdbms-playground-<tag>-<target>[.exe]`) and `.sha256` sidecars
(ADR-ci-003), and a `releases/latest` API — enough to script a one-liner
install.
## Decision
Ship **`scripts/install.sh`**, run as
`curl -fsSL <gitea-raw>/scripts/install.sh | sh`:
- **POSIX `sh`** (no bashisms) — it runs under the `sh` of `curl | sh`;
kept **shellcheck-clean** (`-s sh`).
- **Platform detection** from `uname` → target triple: Linux →
`<arch>-unknown-linux-musl` (the fully-static build — one universal
Linux artifact, no glibc/version coupling), macOS → `<arch>-apple-darwin`;
`x86_64`/`amd64` and `aarch64`/`arm64` both map. **Windows is rejected**
with a pointer to Scoop/winget/the releases page (the binary is a `.exe`,
not a `curl|sh` target).
- **Version:** the `releases/latest` API tag by default; `RDBMS_VERSION`
pins a specific tag.
- **Integrity:** always download the `.sha256` sidecar and **verify**
(`sha256sum`/`shasum -a 256`); a mismatch aborts the install. HTTPS only.
- **Install location:** `~/.local/bin` by default (user-writable, no
sudo), overridable via `RDBMS_INSTALL_DIR`; prints a PATH hint if the
dir isn't on `PATH`.
- **macOS note:** a `curl` download is **not** Gatekeeper-quarantined, so
the binary runs as-is even while it is only ad-hoc-signed; proper
Developer-ID signing + notarization (for *browser* downloads) is a
separate, postponed task (see the plan's signing item).
- **Testing seams:** `RDBMS_OS`/`RDBMS_ARCH` force detection and
`--print-target` prints the resolved triple and exits — so the mapping
is checkable without a download.
### Rejected / deferred
- **Hosting the script on the website domain** (Cloudflare): nicer URL,
but adds a moving part; the **Gitea repo raw URL** is simplest and the
binaries live there anyway (user decision). The website may later
*reference* the same command.
- **Uploading `install.sh` as a release asset** for a stable link:
optional; the branch raw URL is fine for now.
## Amendment 1 — `install.ps1` (Windows) added (2026-06-17)
Windows was originally deferred to Scoop/winget; the user opted for **both**
a PowerShell one-liner now *and* package managers later. Added
**`scripts/install.ps1`** (`irm <url> | iex`): maps the host CPU to our
`*-windows-gnu`/`-gnullvm` `.exe`, resolves the latest release (or
`-Version`/`RDBMS_VERSION`), downloads + **SHA-256-verifies**, installs to
`%LOCALAPPDATA%\Programs\rdbms-playground` (`-InstallDir`/`RDBMS_INSTALL_DIR`
override), and adds that dir to the **user PATH**. **Caveat:** unlike
`install.sh` (verified end-to-end), this was **written but not tested from
this environment** (no PowerShell available) — validate on a real Windows
host. Scoop/winget (D3) remain the idiomatic package-manager routes.
## Consequences
- A first-time user runs one line and gets a checksum-verified binary on
`PATH`. The website's install copy (website branch, separate agent) can
point at this command.
- **Verified end-to-end** (2026-06-17) against the live public `v0.1.0`:
all four Linux/macOS platform mappings + Windows/unknown-arch rejection;
pinned and latest paths; checksum verification incl. a tamper-rejection
check; install + run on Linux x86_64. (The installed `v0.1.0` predates
`--version`, ADR-0054 — a non-issue, and the reason to cut a new
release.)
- **No automated regression guard in CI yet:** shellcheck isn't in the
flake, and there's no shell-test harness here (no bats). Recommended
follow-up: add a `shellcheck scripts/*.sh` gate (touches ADR-ci-002 —
needs shellcheck in the devShell). For now the guard is local
shellcheck + the documented end-to-end verification.
@@ -0,0 +1,88 @@
# ADR-0056: crates.io publish-readiness + `cargo binstall` metadata (D3)
## Status
Accepted — **prepared 2026-06-17** (plan:
`docs/plans/20260616-public-availability.md`, step 3a). The crate is made
**ready to publish** and carries `cargo-binstall` metadata. The actual
`cargo publish` is a gated maintainer step (see Ordering). First D3
package-manager mechanism; builds on ADR-0054 (versioned releases),
ADR-0055 (installer), ADR-ci-003 (release assets). Tracked by plan + ADR
(no Gitea issue — user decision).
## Context
`cargo binstall rdbms-playground` (and `cargo install`) need the crate on
**crates.io** (user decision, 2026-06-17). The manifest had
`publish = false`, a `readme = "README.md"` pointing at a **missing**
file, and no `keywords`/`categories`. Our release assets are **bare
binaries** (not archives) named `rdbms-playground-v<version>-<target>`
(`.exe` on Windows) with `.sha256` sidecars (ADR-ci-003); critically the
**release target triples differ from users' host triples** — we ship the
static `*-linux-musl` build (hosts are `*-linux-gnu`) and
`*-windows-gnu`/`-gnullvm` (hosts are `*-msvc`); only macOS matches.
## Decision
**Publish-readiness (this change):**
- Drop `publish = false`; add `homepage = "https://relplay.org"`,
`keywords`, `categories = ["command-line-utilities", "database"]`, and
an `exclude` (`/website`, `/docs`, `/.gitea`, `/.codegraph`) so the
published crate is code-only (585 files/8.3 MiB → 353/913 KiB
compressed).
- Author **`README.md`** (the `readme` target + crates.io front page;
engine-neutral and "simple/advanced mode" wording per ADR-0002 / the
website copy rules), with install instructions (curl|sh, binstall,
source, prebuilt).
- Add **`LICENSE-MIT`** and **`LICENSE-APACHE`** (the latter the verbatim
canonical text, added by the maintainer; both © Lazy Evaluation Ltd —
the publication entity), and a **`CONTRIBUTING.md`** stating the
"inbound = outbound" dual-license arrangement (so Apache-2.0 §5 makes
the §3 patent grant explicit on the self-hosted forge). Dual license
kept (not MIT-only) — user decision after reviewing the patent-grant
rationale.
**`cargo binstall` metadata** (`[package.metadata.binstall]`, syntax
verified against cargo-binstall SUPPORT.md):
- `pkg-fmt = "bin"` (bare binary), `bin-dir = "{ bin }{ binary-ext }"`,
and a base `pkg-url` using `v{ version }` (the `{ version }` placeholder
excludes the leading `v`).
- **Per-target `overrides`** mapping the common host triples to the asset
we actually publish: `x86_64`/`aarch64-unknown-linux-gnu` → the `-musl`
asset; `x86_64`/`aarch64-pc-windows-msvc` → the `-gnu`/`-gnullvm`
`.exe`. macOS needs no override (host triple == asset triple). The docs
do **not** promise automatic musl/gnu or msvc/gnu fallback, hence
explicit overrides.
**Ordering / gating (important):**
- `cargo publish` is **irreversible** (needs the crates.io token; a
version can't be un-published, only yanked) — a deliberate **maintainer
step**, not done here.
- binstall's `pkg-url` resolves to a **tagged release's** assets, so
publish **at a new tagged version whose release already exists**, and
publish **after** that release is built. **Do not publish `0.1.0`** — it
would diverge from the already-released `0.1.0` binaries (which predate
`--version`, ADR-0054). The clean path: bump → tag → release builds →
`cargo publish`.
## Verification
- `cargo publish --dry-run --allow-dirty` packages + verify-builds cleanly
(353 files, 913 KiB compressed; no metadata errors).
- `cargo metadata` confirms the `binstall` block + all four `overrides`
parse.
- **Unverified:** an actual `cargo binstall` run — cargo-binstall isn't a
dependency and nothing is on crates.io yet. **Validate at the first
publish + matching release** (especially the windows-msvc→gnu and
linux-gnu→musl overrides).
## Consequences
- The crate can be published at the next tagged release with `cargo
publish` (+ the token); `cargo install rdbms-playground` and `cargo
binstall rdbms-playground` then work.
- Remaining D3: Scoop, Homebrew (`lazyeval` tap), winget (komac/manual) —
each a manifest + a per-release bump, tracked in the plan.
- Remaining follow-up: run the real `cargo binstall` validation at the
first publish + matching release (the license files, © holder, and
CONTRIBUTING are now in place).
+3
View File
@@ -66,3 +66,6 @@ This directory contains the project's ADRs, recorded per
- [ADR-0051 — Bottom keybinding strip: context- and state-aware](0051-context-state-aware-keybinding-strip.md) — **Accepted + implemented 2026-06-13 (issue #27)**, closes Gitea **#27**. Repurposes the bottom status line into a **keystrokes-only, state-selected** strip (builds on ADR-0046 nav focus, ADR-0003 modes, ADR-0049 the #29 readline keys it now advertises, ADR-0022 the completion memo). A pure `status_bar_bindings(app) -> Vec<(key,label)>` chooses the strip by **priority, first match wins**: (1) **sidebar focus**`Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input`; (2) **completion memo live** (`last_completion`) → `Tab/Shift-Tab cycle · Esc cancel · Enter run`; (3) **history navigation** (new `App::is_browsing_history()` exposing the private `history_cursor`) → `↑↓ browse · Esc clear · Enter run`; (4) **editing** (input non-empty) → `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` (surfaces the #29 keys, closing ADR-0049's deferred advertisement); (5) **default** (empty) → `Ctrl-O sidebar · Tab complete · ↑ history · Enter run`. Priority is correct because Up clears the completion memo and Tab cancels history nav, so states 2/3 never co-occur, and the five are exhaustive for Input focus. **Typed-command words leave the strip** (`mode advanced`/`mode simple` switch, `:` one-shot) and **mode discovery moves to the empty-input hint** (`resolve_hint_lines`), **simple mode only**: `\`mode advanced\` for SQL` (the verb "type" omitted — the prompt implies it; advanced mode shows **no** pointer per a post-trial user decision — a switcher knows how they got there and `help` covers the way back). The one-shot's old `Backspace cancel one-shot` label is subsumed by the editing state (behaviour intact). Forks all user-chosen: **editing state shows the #29 keys** (vs unadvertised); **`Ctrl-C quit` omitted** from the strip (vs always shown); **no width-drop machinery** — the longest strip (~65 cols) fits all supported widths, so a **width-budget unit test** keeps it lean by construction instead (the user's own observation). Catalog: 12 new `shortcut.*` labels + the `panel.hint_mode_advanced` string added to `en-US.yaml`+`keys.rs` (validator-checked 1:1), 5 now-dead strip strings removed. **Modal-aware strip is OOS** (pre-existing: a modal owns the keyboard and carries its own hints; the strip under it is unchanged-in-kind, not worsened). Tests: 9 Tier-1 unit (per-state key sets — completion/history driven through real key events; width budget; mode-pointer presence/absence), 1 Tier-3 rewritten (`status_bar_is_keystroke_only_and_state_aware`), 15 full-panel snapshots re-accepted (reviewed — strip/hint only). **2467 pass / 0 fail / 0 skip (1 ignored), clippy clean.** OOS: modal-aware strip; a full-key cheatsheet overlay; Ctrl-K/U advertisement (editing strip shows the highest-value subset within the width budget)
- [ADR-0052 — Mode-tagged history for cross-mode recall](0052-mode-tagged-history-cross-mode-recall.md) — **Accepted + implemented 2026-06-13 (issue #30)**, closes Gitea **#30** — the feature (advanced history reusable in simple mode) **and** the bug in its comment (the `:` one-shot prefix lost across sessions). **Amends ADR-0034** (status field gains a `:adv` tag; **journaling moves from the worker to the dispatch layer**), **ADR-0015 §5/§6** (history.log leaves the worker transaction — `commit-db-last` now scopes yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure is best-effort, not fatal); references ADR-0003. **Root cause:** history carried no mode, and the in-memory ring stored the raw `:select 1` while the worker journalled the *stripped* `select 1`, so the `:` was lost on disk. **Fix:** record the submission mode per entry as a **`:adv` suffix on the status token** (`ok`/`ok:adv`/`err`/`err:adv`) — `source` stays last + canonical so replay is unaffected; the in-memory ring (still `Vec<String>`) stores advanced entries in their `: `-prefixed simple-mode runnable form (a leading `:` unambiguously marks advanced since simple DSL never starts with `:`); recall **strips the `:` in advanced mode** (runs as bare SQL) and keeps it in simple mode (runs via the one-shot escape); hydration reconstructs the `: `-prefix from the tag, so cross-session = in-session. **The architectural turn (user's call):** the first draft kept journaling in the worker + threaded the mode down (~30-site plumbing); on review the user asked why the journal is written deep in the worker when the *failure* path already journals at the top of the chain — it shouldn't (history.log is a journal, not state). So **success journaling moved up** to `spawn_dsl_dispatch` / `run_replay` / the app-command sites (next to the failure path), the worker's `finalize_persistence` now writes only yaml/csv, and the journal write became **best-effort** (the command is already committed — consistent with the failure path; a rare disk-full leaves a committed command unjournalled, state intact). **App commands** journal simple (dispatched outside the spawn) and `submit` excludes them from the ring's advanced flag, so `undo`/`mode advanced` recall bare. Forks user-chosen: status-tag format (vs 4th field / `:`-in-source); unified scope; **dispatch-layer best-effort journaling** (vs worker-coupled-fatal). Two `/runda` passes (the second drove the relocation + app-command exclusion). Tests: the 15 worker-level journaling tests retired (worker no longer journals — yaml/csv/operation checks kept), re-covered at the new layer (history.rs status-tag + `:`-reconstruct; app.rs recall matrix; the #30 cross-session regression in `iteration6`; replay tests cover `run_replay` journaling). **2471 pass / 0 fail / 0 skip (1 ignored), clippy clean.** replay re-journaling mode-fidelity (a replayed advanced line re-journals simple — not a regression). **Follow-up done 2026-06-14:** the vestigial worker `source` plumbing was fully unwound (compiler-guided, no behaviour change) — `_source` removed from `finalize_persistence`/`do_rebuild_from_text`, the three `*_request` wrappers inlined+deleted, the dead `source` param dropped from the ~30 forwarding worker handlers, and the `source` field removed from the `DescribeTable`/`QueryData`/`RunSelect` requests + their `DatabaseHandle` methods (~164 mostly-test call sites); the only worker `source` left is the snapshot/undo label (see ADR-0052 *Consequences*)
- [ADR-0053 — Contextual `hint` command and keybinding](0053-contextual-hint-command-and-keybinding.md) — **Accepted, implemented 2026-06-15** (Phases AD; closes **A1** + requirements **H2**). Settles the `hint` slot ADR-0003 left "ADR pending"; closes the last open piece of **A1** and tracks requirements **H2**. **Two surfaces:** an **F1 keybinding** that renders a deep hint for the *live* partial input without submitting (the primary path — a submitted `hint` command can't see the buffer it would help with, since Enter empties it), and a submitted **`hint` command** that expands on the *most recent error*. **No topic argument** (contextual only — `help <topic>` already owns explicit reference). Introduces a **tier-3 teaching layer**, deeper than the existing tier-1 (colour / error headline) and tier-2 (ambient one-liner; and the error `hint:`, which is shown **by default** since `Verbosity::Verbose` is the default — `messages short` is the opt-*out*); without it `hint` would just duplicate what's already on screen. Tier-3 content lives in the catalogue under `hint.cmd.<hint_id>` (per command form) and `hint.err.<class>` (per error/diagnostic class), each a structured `what`/`example`/`concept` block rendered via a new `note_hint*` family with `OutputStyleClass::Hint`. **Keyed per-form via a new `hint_ids: &[&str]` field on `CommandNode` mirroring `usage_ids`** (revised in Phase B): a per-*node* key proved too coarse — `add`/`drop`/`show`/`create` are each one node spanning many forms, and a live-input hint for `add 1:n relationship` must be specific to relationships; `hint_key_for_input_in_mode` reuses `usage_key_for_input_in_mode`'s form-word disambiguation, and covers the advanced-SQL forms whose `usage_ids` are empty. Not keyed off `help_id` (it is `None` on the advanced-SQL nodes purely to dedup the `help` list; that parallel gap is issue **#36**). **Clause-concept hints** (`on delete` actions, constraint slots, `with pk`, cardinality) are a recorded **deferred extension** (`hint.concept.<topic>`, issue **#37**) — per-form is the right tier-3 granularity, with position-awareness owned by tier-2 + the live `Next:` line. Runtime `translate_error` classes resolve via stored `last_error_hint_key` (`hint` command / empty-F1). (The second route — pre-submit `diagnostic.*` read live from the walker on the F1 path — is **deferred**, issue **#38**: `Diagnostic` carries no class key.) Adds `AppCommand::Hint`, a `HINT` grammar node + REGISTRY entry, the `hint_ids` field, and `last_error_hint_key`; F1 is a read-only overlay (buffer + completion memo untouched). **Content is the bulk of the work** (the mechanism is ~a day): v1 scope = ~37 command forms + 9 runtime error classes (comprehensive for those, ~57 blocks), authored **exemplars-first** (voice approved in this ADR's `/runda` review, then mass-authored in batches), enforced by a **comprehensiveness coverage test**, with graceful fall-back to tier-2 if a key is ever missing. The **pre-submit-diagnostic route + ~33 `diagnostic.*` blocks were deferred** (issue **#38**) — `Diagnostic` carries no class key, so the route needs a broad change for marginal value (tier-2 already surfaces diagnostics; many duplicate runtime classes). Forks user-chosen: two-surface model; **F1** (vs `?` / a chord); no-arg; comprehensive-for-commands-and-errors scope; exemplars-first; diagnostics deferred. OOS: per-topic `hint <topic>` (rejected — overlaps `help`); always-on tier-3 (rejected — keeps ambient terse); non-`en-US` locales + success-command teaching (deferred); clause-concept hints (issue #37); the diagnostic route (issue #38); the `help`-side advanced-SQL gap (issue #36)
- [ADR-0054 — Release versioning policy + version surfaces (`--version` / `version`)](0054-release-versioning-and-version-surfaces.md) — **Accepted + implemented 2026-06-16** (plan: `docs/plans/20260616-public-availability.md`, step 1 on the road to public availability; no prior issue/`requirements.md` item — an untracked gap). Fixes the **tag↔crate-version decoupling**: `Cargo.toml` built `0.1.0` while `release.yaml` named assets from the git tag, so a binary could report a version different from the asset it shipped in. **Decision:** `Cargo.toml` `version` is the **single source of truth** (read via `env!("CARGO_PKG_VERSION")`, no tag-injection); two surfaces report it through one `cli::version_text()` → catalog `cli.version_line` — a **`--version` / `-V`** CLI flag (mirrors `--help`, prints+exits in `main.rs`) and an in-app **`version`** command (REGISTRY node `app::VERSION`, `AppCommand::Version`, emits via `note_system`); and a **release-CI version guard** (`release.yaml` `test` job reads the `[package]` version from `Cargo.toml` and **fails the release** unless the `v*` tag equals `v<version>`; the guard's parse was later switched from `cargo metadata | node` to a `grep` on Cargo.toml after the former broke on the flake devShell's stdout banner). Release ritual: bump `Cargo.toml` → commit → tag → push. New keys `cli.version_line` + `help.app.version` + `parse.usage.version` + `hint.cmd.version.{what,example}` (the new REGISTRY command pulls in the comprehensiveness coverage gate). Rejected: tag-as-source (makes Cargo.toml lie). Deferred: git-hash/build-date enrichment (behind the same `version_text()` seam); UI placement beyond the command. Tested test-first: CLI parse (`--version`/`-V`/default-off), `version_text()` carries `CARGO_PKG_VERSION`, the in-app command parses + emits. Also corrected a stale `release.yaml` header comment ("macOS is deferred" → built by the dispatched `release-macos.yaml`).
- [ADR-0055 — `curl | sh` install script (`scripts/install.sh`)](0055-curl-sh-install-script.md) — **Accepted + implemented 2026-06-17** (plan: `docs/plans/20260616-public-availability.md`, step 2; tracked by plan + ADR, no Gitea issue — user decision). A one-line installer (`curl -fsSL <gitea-raw>/scripts/install.sh | sh`) so beginners don't hand-pick an asset + `chmod +x`. **POSIX `sh`** (shellcheck-clean), detects `uname` OS/arch → target triple (**Linux → the fully-static `*-musl`** build, macOS → `*-apple-darwin`; `amd64`/`arm64` aliased; **Windows rejected** → Scoop/winget/releases page), resolves the version from the **`releases/latest`** API (or `RDBMS_VERSION` to pin), downloads the asset **and its `.sha256` and verifies it** (mismatch aborts), installs to `~/.local/bin` (`RDBMS_INSTALL_DIR` override) with a PATH hint. Testing seams: `RDBMS_OS`/`RDBMS_ARCH` + `--print-target`. macOS note: `curl` downloads aren't Gatekeeper-quarantined so the ad-hoc binary runs as-is (Developer-ID + notarization is the postponed signing task). **Verified end-to-end against the live public `v0.1.0`** (all platform mappings, pinned + latest, checksum incl. tamper-rejection, install + run). Rejected: website-domain hosting (extra moving part; Gitea raw is simplest); deferred: uploading the script as a release asset, and a **shellcheck CI gate** (shellcheck isn't in the flake — touches ADR-ci-002). **Amendment 1 (2026-06-17):** added a Windows **`scripts/install.ps1`** (`irm | iex`; maps host CPU → our `*-windows-gnu`/`-gnullvm` `.exe`, SHA-256-verifies, installs to `%LOCALAPPDATA%\Programs\…` + user PATH) — user chose both a one-liner *and* Scoop/winget; **written but untested from this env** (no PowerShell — validate on Windows).
- [ADR-0056 — crates.io publish-readiness + `cargo binstall` metadata (D3)](0056-crates-io-and-cargo-binstall.md) — **Prepared 2026-06-17** (plan step 3a; tracked by plan + ADR). Makes the crate **ready to publish** to crates.io (user decision) and adds `cargo-binstall` metadata; the actual `cargo publish` is a **gated, irreversible maintainer step**. Manifest: drops `publish = false`; adds `homepage` (relplay.org), `keywords`, `categories`, and an `exclude` (`/website`,`/docs`,`/.gitea`,`/.codegraph`) trimming the crate from 585 files/8.3 MiB → **353/913 KiB compressed** (code-only). Authors **`README.md`** (engine-neutral, simple/advanced-mode wording; install via curl|sh/binstall/source/prebuilt) and **`LICENSE-MIT`** (© Lazy Evaluation Ltd — *confirm holder*); the canonical **`LICENSE-APACHE`** is deferred to the maintainer (don't ship retyped legal text) — the SPDX `license` field already satisfies crates.io. **binstall** (syntax verified vs cargo-binstall SUPPORT.md): `pkg-fmt = "bin"` (bare binaries), `pkg-url` spelled `v{ version }` (the placeholder omits the `v`), plus per-target **`overrides`** mapping the common host triples to the assets we ship — `*-linux-gnu` → the static `*-linux-musl` build, `*-pc-windows-msvc``*-gnu`/`-gnullvm` `.exe` (macOS matches directly; the docs promise no automatic fallback). **Ordering:** publish at a **new tagged version whose release exists**, after the release — **not `0.1.0`** (diverges from the already-released 0.1.0 binaries that predate `--version`). Verified: `cargo publish --dry-run` packages + verify-builds; `cargo metadata` confirms the binstall block + 4 overrides. **Unverified:** a real `cargo binstall` run (not a dep; nothing on crates.io yet) — validate at first publish. Rejected: cargo-dist (GitHub-centric). Maintainer follow-ups: confirm © holder, add canonical `LICENSE-APACHE`, real binstall validation.
+10
View File
@@ -133,3 +133,13 @@ declaration of the dev *and* build environment.
flake for `requirements.md` **TT5** (CI runs the tiers) and the
**D1/D2/D3** distribution items (the release uses a static musl target
built through this flake).
## Amendment 1 — 2026-06-17: `fmt` gate enabled (issue #35)
The deferred "revisit on `main`" is done. With the CI + website branches
merged and before the first public release, the tree was reformatted once
with **stock `cargo fmt`** (no `rustfmt.toml` — stable rustfmt supports no
meaningful customisation, and the pinned 1.95.0 toolchain makes
`fmt --check` deterministic) in a single mechanical commit (`41b7e9a`,
102 files, behaviour-preserving; recorded in `.git-blame-ignore-revs`).
`ci.yaml`'s gate is now **`fmt --check` + clippy + test**. Closes **#35**.
+11
View File
@@ -20,6 +20,17 @@ This ADR records the **cross-platform build strategy**; it sits on top of
**ADR-ci-002** (the nix flake, which now carries the cross toolchain) and
**ADR-ci-001** (the pipeline, whose release job this fills in).
## Amendment 2 — 2026-06-16: CI on `main`; `release-macos` dispatched + verified
The CI branch is **merged to `main`**, so `release-macos.yaml`
(`workflow_dispatch`, Gitea-default-branch-only) is now triggerable. It
has been **dispatched and verified end-to-end**: both `*-apple-darwin`
targets build on the Tart runner, the de-nix/re-sign step runs, the
assets upload to the tagged release, and the binaries launch. macOS is
therefore runtime-verified (alongside the original Linux x86_64 +
Windows aarch64); only **Linux aarch64** and **Windows x86_64** remain
link-clean / valid-format without a runtime smoke-test.
## Amendment — 2026-06-14: macOS implemented (closes D1)
macOS is no longer deferred. The two `*-apple-darwin` targets now build on a
+2 -2
View File
@@ -19,5 +19,5 @@ here too).
## Index
- [ADR-ci-001 — CI + release pipeline on Gitea Actions](20260612-adr-ci-001.md) — **Accepted 2026-06-12** (implemented the same day on the `ci` branch). Establishes the CI/release pipeline on the self-hosted Gitea instance's Actions runner (`ci-public`). **Runner model** (established by a throwaway probe): jobs execute *inside* a container (`catthehacker/ubuntu:act-22.04` by default), as root, so the runner host's nix is **not** reachable from steps. **Toolchain delivery:** a **baked CI image**`node:22-bookworm-slim` (satisfies the act_runner job-container contract: `/bin/sleep` keep-alive, `bash`, `node` for JS actions; a bare `nixos/nix` image lacks these and won't start) **+ single-user nix + the flake's devShell pre-warmed** — built by `build-ci-image.yaml` via DinD and pushed to the Gitea container registry as a **public** package, so CI runs `nix develop -c …` against the **same pinned toolchain as dev** (the flake, ADR-ci-002) with a warm store (~1.4 s to a ready toolchain). **Gate** (`ci.yaml`): `clippy -D warnings` + `cargo test` inside that image on branch pushes + PRs; **fmt deliberately not gated** (the tree isn't stock-rustfmt-clean — user decision, revisit on `main`; see ADR-ci-002). **Release** (`release.yaml`): on a `v*` tag, runs the tests, builds the **static `x86_64-unknown-linux-musl` binary** (D2: single static binary, no runtime deps — the glibc/nix build is non-portable), strips it, and publishes it + a `.sha256` to a Gitea release via the API and the auto-provided `GITEA_TOKEN`. **Triggers:** gate + image-build are scoped to **branch** pushes (`branches: ['**']`) so a release tag doesn't spuriously re-run them; the image-build additionally path-filters to its inputs (Dockerfile/flake/toolchain); the release owns tags. **Auth:** a dedicated PAT (`REGISTRY_USERNAME`/`REGISTRY_TOKEN` secrets) pushes the image; the auto `GITEA_TOKEN` publishes releases. **Scope:** the original release job was Linux x86_64 only; it's now the **four non-macOS D1 targets** (Linux + Windows × x86_64/aarch64) cross-built via cargo-zigbuild — see **ADR-ci-003**. macOS, D3 package-manager manifests, CI-speed dependency caching, and the website's static→Cloudflare deploy remain deferred, added step by step. Verified live: probe → runner facts; image built + checked locally; gate green (**2424 tests**); release exercised end-to-end (`v0.0.0-citest2` published with binary + checksum). Builds on **ADR-ci-002** (the nix flake, relocated here from main's ADR-0049 to avoid exactly this cross-branch collision).
- [ADR-ci-002 — Nix flake for a reproducible dev + build environment](20260612-adr-ci-002.md) — **Accepted 2026-06-12** (relocated from main's **ADR-0049** on the same day — content unchanged — to keep CI/dev-env decisions out of `main`'s integer sequence). The single, version-pinned declaration of the **dev *and* build toolchain** so CI never relies on whatever Rust is on the build machine — mirroring **datamage ADR 0046**, but far simpler (pure-Rust TUI). Root **Nix flake** with two outputs: **`devShells.default`** (pinned **Rust 1.95.0** via `rust-toolchain.toml` + `rust-overlay`, `cargo-sweep`, and the musl cc for the static release build) and **`packages.default`** (`rustPlatform.buildRustPackage` from the committed `Cargo.lock`; `doCheck = false`). Exact-pin (not floating `stable`) so `nix flake update` can't surprise-bump clippy past the `-D warnings` gate. System inputs near-empty by design (`libsqlite3-sys bundled` → stdenv cc only; `arboard``x11rb` pure-Rust). `.envrc` (`use flake`) for direnv parity. Verified through the flake: `nix build` yields a working binary, clippy clean, **2424 tests pass / 0 fail / 1 intentional ignored doctest**. Consumed by **ADR-ci-001** (the pipeline). Alternatives rejected: dev-shell-only; a standard `rust:1.95` CI image (a second toolchain definition = drift); `rustup` on the build host (non-reproducible).
- [ADR-ci-003 — Cross-platform release builds (the D1 matrix)](20260613-adr-ci-003.md) — **Accepted 2026-06-13** (implemented + a real matrix release verified the same day — tag `v.0.0.0-citest3` published 8 assets). Cross-compiles the **four non-macOS D1 targets** from the Linux x86_64 runner with **`cargo-zigbuild`** (Zig's bundled clang + libc as one universal cross cc/linker, incl. rusqlite's bundled SQLite C; added to the flake devShell, replacing the single-target musl cc): **`x86_64`/`aarch64-unknown-linux-musl`** (musl + crt-static → fully static, **D2**) and **`x86_64-pc-windows-gnu`** / **`aarch64-pc-windows-gnullvm`** (Zig statically links libc → standalone `.exe`). **Windows `synchronization` stub:** Rust std links `-lsynchronization` (WaitOnAddress thread-parking), an import lib rust-overlay's toolchain doesn't ship and Zig's mingw lacks; the symbols are forwarded by `kernel32`, so an **empty 8-byte stub** `libsynchronization.a` (`ci/winstub/`, wired via `.cargo/config.toml` for the Windows targets only) satisfies the linker. **Workflow:** `release.yaml` = **`test` once (host) → `build` matrix** over the four targets (`needs: test`, `fail-fast: false`); each job packages binary (`.exe` for Windows) + `.sha256` and uploads to the **shared release** via idempotent create-or-get. **macOS** (2026-06-14 amendment) — built natively on a **Tart (Apple-Silicon) runner** (`runs-on: macos`), which makes the SDK fully licensed and dissolves the grey-area/public-image problem; `release-macos.yaml` is **dispatch-only** (intermittent runner; becomes triggerable once CI is on `main`), de-nixes the binary's libiconv load path (`install_name_tool``/usr/lib`) + re-signs ad-hoc, and uploads to the tagged release. **D1 complete (all six targets).** Alternatives rejected: `cross` (no macOS, needs DinD), per-target nix cross (Windows-aarch64 unpackaged, macOS-from-Linux unsupported), a real `libsynchronization.a` (more machinery, doesn't cover Windows-aarch64). Runtime-verified by the user (2026-06-13): Linux x86_64 + Windows aarch64 run correctly; Linux aarch64 + Windows x86_64 are the outstanding runtime checks. Builds on ADR-ci-002 (flake) and fills in ADR-ci-001 §3 (Release).
- [ADR-ci-002 — Nix flake for a reproducible dev + build environment](20260612-adr-ci-002.md) — **Accepted 2026-06-12** (relocated from main's **ADR-0049** on the same day — content unchanged — to keep CI/dev-env decisions out of `main`'s integer sequence). The single, version-pinned declaration of the **dev *and* build toolchain** so CI never relies on whatever Rust is on the build machine — mirroring **datamage ADR 0046**, but far simpler (pure-Rust TUI). Root **Nix flake** with two outputs: **`devShells.default`** (pinned **Rust 1.95.0** via `rust-toolchain.toml` + `rust-overlay`, `cargo-sweep`, and the musl cc for the static release build) and **`packages.default`** (`rustPlatform.buildRustPackage` from the committed `Cargo.lock`; `doCheck = false`). Exact-pin (not floating `stable`) so `nix flake update` can't surprise-bump clippy past the `-D warnings` gate. System inputs near-empty by design (`libsqlite3-sys bundled` → stdenv cc only; `arboard``x11rb` pure-Rust). `.envrc` (`use flake`) for direnv parity. Verified through the flake: `nix build` yields a working binary, clippy clean, **2424 tests pass / 0 fail / 1 intentional ignored doctest**. Consumed by **ADR-ci-001** (the pipeline). Alternatives rejected: dev-shell-only; a standard `rust:1.95` CI image (a second toolchain definition = drift); `rustup` on the build host (non-reproducible). **Amendment 1 (2026-06-17, issue #35):** the deferred `fmt` gate is enabled — the tree was reformatted once with **stock `cargo fmt`** (no `rustfmt.toml`; pinned toolchain makes `fmt --check` deterministic) in a single mechanical commit (`41b7e9a`, 102 files, behaviour-preserving, in `.git-blame-ignore-revs`), and `ci.yaml`'s gate is now **`fmt --check` + clippy + test**.
- [ADR-ci-003 — Cross-platform release builds (the D1 matrix)](20260613-adr-ci-003.md) — **Accepted 2026-06-13** (implemented + a real matrix release verified the same day — tag `v.0.0.0-citest3` published 8 assets). Cross-compiles the **four non-macOS D1 targets** from the Linux x86_64 runner with **`cargo-zigbuild`** (Zig's bundled clang + libc as one universal cross cc/linker, incl. rusqlite's bundled SQLite C; added to the flake devShell, replacing the single-target musl cc): **`x86_64`/`aarch64-unknown-linux-musl`** (musl + crt-static → fully static, **D2**) and **`x86_64-pc-windows-gnu`** / **`aarch64-pc-windows-gnullvm`** (Zig statically links libc → standalone `.exe`). **Windows `synchronization` stub:** Rust std links `-lsynchronization` (WaitOnAddress thread-parking), an import lib rust-overlay's toolchain doesn't ship and Zig's mingw lacks; the symbols are forwarded by `kernel32`, so an **empty 8-byte stub** `libsynchronization.a` (`ci/winstub/`, wired via `.cargo/config.toml` for the Windows targets only) satisfies the linker. **Workflow:** `release.yaml` = **`test` once (host) → `build` matrix** over the four targets (`needs: test`, `fail-fast: false`); each job packages binary (`.exe` for Windows) + `.sha256` and uploads to the **shared release** via idempotent create-or-get. **macOS** (2026-06-14 amendment) — built natively on a **Tart (Apple-Silicon) runner** (`runs-on: macos`), which makes the SDK fully licensed and dissolves the grey-area/public-image problem; `release-macos.yaml` is **dispatch-only** (intermittent runner), de-nixes the binary's libiconv load path (`install_name_tool``/usr/lib`) + re-signs ad-hoc, and uploads to the tagged release. **D1 complete (all six targets).** Alternatives rejected: `cross` (no macOS, needs DinD), per-target nix cross (Windows-aarch64 unpackaged, macOS-from-Linux unsupported), a real `libsynchronization.a` (more machinery, doesn't cover Windows-aarch64). **Amendment 2 (2026-06-16):** CI is **merged to `main`**, so `release-macos` is now triggerable (`workflow_dispatch` is default-branch-only) and has been **dispatched + verified end-to-end** (build → de-nix/re-sign → upload, binaries launch). Runtime-verified by the user: Linux x86_64, Windows aarch64, **and both macOS targets**; Linux aarch64 + Windows x86_64 are the outstanding runtime checks. Builds on ADR-ci-002 (flake) and fills in ADR-ci-001 §3 (Release).
+112
View File
@@ -0,0 +1,112 @@
# Session handoff — 2026-06-16 (73)
Short, focused handover. Continues from handoff-72 (which completed the
H2 hint-corpus verification pass). This session shipped one small
feature — a **`Ctrl-G` demo-mode alias for F1** — plus follow-on doc
hygiene. Commit `4016c3e`.
## §1. State
**Branch:** `main`, clean, all committed (local; **push pending** — your
step; the backlog now spans the CI merge, H2, the hint-corpus fixes,
handoffs 71/72/73, and this Ctrl-G commit). **2503 pass / 0 fail / 1
ignored** (the long-standing `friendly` doctest), **clippy clean**
(nursery, all targets). Open Gitea issues unchanged: **#35#38**.
## §2. Why Ctrl-G (the problem)
Casts are recorded with **`autocast`** (ADR-0047 demo mode: `--demo` /
`RDBMS_PLAYGROUND_DEMO`). The contextual hint overlay (ADR-0053 / H2)
opens on **F1** — but F1 reaches the app only as a terminal **escape
sequence** (`\eOP` / `\e[11~`), and **autocast cannot emit escape
sequences**. So the single most teaching-relevant overlay was
unreachable in recordings (and in presenter/teacher sessions, which also
run `--demo`). Same wall that pushed step-captions onto `Ctrl+]` (a
single control byte) rather than `Ctrl+!`.
### Chord choice — why Ctrl-G, why not Ctrl-1
The user's first instinct was `Ctrl-1` (mnemonic, near F1). **Not
possible:** in a legacy terminal `Ctrl`+digit has no control byte —
`Ctrl-1` arrives as a bare `1` (would type "1" into the buffer). The
kitty keyboard protocol *would* encode it, but only as an escape
sequence (the very thing autocast can't send), and this app deliberately
does **not** push `KeyboardEnhancementFlags` (`runtime.rs` does only
`enable_raw_mode` + `EnterAlternateScreen`). So the usable space is
exactly **`Ctrl`+letter** (single legacy control bytes). After excluding
taken chords (`Ctrl-C` quit, `Ctrl-O` nav, `Ctrl+]` caption,
`Ctrl-A/E/W/K/U` readline per ADR-0049), byte-collisions
(`Ctrl-H/I/J/M/[`), flow-control (`Ctrl-S/Q`), and likely-future
(`Ctrl-R/P/N/Y/L/V`), **`Ctrl-G`** is the clean survivor (BEL/"abort" in
line editors — nothing destructive to shadow).
## §3. What shipped (commit `4016c3e`)
ADR-0047 **Amendment 1**. **In demo mode only**, `Ctrl-G` aliases F1:
- Runs the *exact* F1 hint logic (`hint_key` guard in
`App::handle_key`, `src/app.rs` ~line 1226 — `key.code == F(1) ||
(self.demo_mode && Ctrl-G)`), so live-input → form hint, empty input →
recent-error / getting-started.
- **Badges as `[F1]`** (not `[CTRL-G]`): `demo_badge_label` maps
`Ctrl-G → Some("[F1]")` (consulted only in demo mode — the caller
gates). So a recorded cast is **visually identical to a real F1
press**.
- **Demo-gated:** the shipped keymap stays F1-only. Outside demo mode
`Ctrl-G` falls through to the inert catch-all (the `Char(c)` insert arm
excludes CONTROL, so no `g` is typed).
- The keybinding strip (ADR-0051) is **not** changed — F1 stays the
advertised key; `Ctrl-G` is a recorder aid and the badge already reads
`[F1]`.
**Tests (test-first, `src/app.rs` Tier-1):** `ctrl_g_in_demo_mode_-
aliases_f1_on_input`, `…_on_empty_input`, `ctrl_g_outside_demo_mode_-
is_inert`, plus a `Ctrl-G → [F1]` assertion added to
`demo_badge_label_maps_the_invisible_keys`. All confirmed red→green; the
"inert" test passed on the pre-change code, proving the demo-gate.
### Using it in a cast
With `--demo` active, send **`Ctrl-G`** in the autocast script wherever
you want the hint overlay to appear; the viewer sees the `[F1]` badge.
## §4. Doc hygiene done alongside (same commit)
- **ADR-0047 file:** removed two stray `</content>` / `</invoke>` lines
(tool-call residue accidentally committed when the ADR was authored).
- **CLAUDE.md "Things deliberately deferred":** dropped three **stale**
entries — **I1b** readline shortcuts (done, ADR-0049), **I3** tab
completion, and **I4** syntax highlighting (both done; requirements.md
even carries a 2026-06-07 reconciliation note that I3/I4 were
"shipped but marked not yet"). Only **I1** (multi-line input) remains —
it is genuinely still open (`[ ]` in requirements.md). *(This follows
the handoff-72 cleanup that removed the equally-stale m:n/C4 +
project-storage entries.)*
## §5. Open / next (unchanged from handoff-72 §5)
The hint corpus is trustworthy and the cast-F1 gap is closed. Roadmap:
1. **Push** (your step).
2. **#35 (cargo fmt gate)** — precondition (CI merged) is met; the user
wants it done once, before first publication. Needs a `rustfmt.toml`-
vs-defaults decision first; tree is ~1800 hunks dirty.
3. Other open `requirements.md` items: **I1** multi-line input, **I5/B3**
in-flight cancellation, **TT4** PTY tier-4 (unwired), **DOC1**/**E2**
user docs (partial), **TT5** Windows-execution + Tier-4-in-CI, **D3**
packaging manifests. Design-first (`[~]`): **V4** session journal,
**TU1** tutorial, **C3a**, **V3**.
4. Hint follow-ups if wanted: **#36** `help` advanced-SQL, **#37** hint
clause-concepts, **#38** hint diagnostic route.
## §6. How to take over
1. Read handoffs 71 → 72 → 73, `CLAUDE.md`, `docs/requirements.md`.
2. Confirm green: `cargo test` (**2503 / 1 ignored**) + `cargo clippy
--all-targets` (clean).
3. For demo-mode / casting, read **ADR-0047** (+ its Amendment 1); for
the hint overlay it aliases, **ADR-0053**.
4. Workflow unchanged: phased, test-first, `/runda` + DA before commits,
ADR amendment + README index-upkeep for decided-area changes, confirm
commit messages with the user.
5. Consider a `cargo sweep` at this milestone (`target/` grows; see
CLAUDE.md "Build hygiene").
+193
View File
@@ -0,0 +1,193 @@
# Plan — road to public availability (versioning + install + packaging)
Status: **planning** (2026-06-16). Captures the decisions taken in
discussion plus the open questions, so the work can proceed in steps.
The versioning piece is being implemented first and is recorded as a
decision in **ADR-0054**; the install-script and package-manager pieces
are roadmap items here until each is built.
Scope note: this repo lives on the self-hosted Gitea at
`git.lazyeval.net` (`oli/rdbms-playground`); releases publish there
(ADR-ci-001/003). Publication branding uses the company name **Lazy
Evaluation Ltd → `lazyeval`**, not the personal `oli` username, for
anything user-facing (taps, buckets).
---
## 1. Versioning + version surfaces — DECIDED (→ ADR-0054, building now)
- **`Cargo.toml` `version` is the single source of truth.** `--version`
reads `env!("CARGO_PKG_VERSION")` (compile-time, no deps).
- **Two surfaces:** a `--version` / `-V` CLI flag (prints + exits, like
`--help`), and an in-app **`version`** app command.
- **CI discipline:** `release.yaml` gains a guard that asserts the pushed
tag `vX.Y.Z` equals `CARGO_PKG_VERSION`, and **fails the release on
mismatch** — so the binary's self-reported version, the release name,
and the downloaded asset always agree.
- **Release ritual:** bump `Cargo.toml` → commit → tag `v<that number>`
→ push tag. (The guard enforces it.)
- Optional enrichment (not decided): a `build.rs` baking a git short-hash
+ build date so non-tagged dev builds read `0.2.0 (a1b2c3d)`. Good for
bug reports; can be added later.
---
## 2. `install.sh` (curl | sh) — DONE 2026-06-17 (ADR-0055)
**Shipped** `scripts/install.sh` (POSIX sh, shellcheck-clean). Verified
end-to-end against the live public `v0.1.0` release: platform mappings
(Linux/macOS × x86_64/aarch64; Windows + unknown arch error cleanly),
pinned (`RDBMS_VERSION`) and latest (`releases/latest`) paths, SHA-256
verification (incl. a tamper-rejection check), install to
`~/.local/bin`, PATH hint. **`install.ps1` (Windows) added 2026-06-17**
(user chose both a one-liner *and* Scoop/winget; ADR-0055 Amendment 1):
`irm | iex`, maps host CPU → our `*-windows-gnu`/`-gnullvm` `.exe`,
SHA-256-verifies, installs to `%LOCALAPPDATA%\Programs\…` + user PATH —
**written but untested from this env** (no PowerShell; validate on
Windows). The website copy that references these commands is the
**website branch's** job (separate agent), later.
A **shellcheck CI gate** for `scripts/` is a recommended follow-up (not
added — shellcheck isn't in the flake yet; touches ADR-ci-002).
Original decided shape (for reference):
- **Hosted from the Gitea repo URL** on `git.lazyeval.net` (simplest):
`curl -fsSL https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.sh | sh`
(exact raw-URL form to confirm against the Gitea version).
- **Behaviour:** POSIX `sh`; detect `uname` OS+arch → target triple
(Linux → the **musl static** build, our universal Linux artifact);
query the latest release via the Gitea API
(`/repos/oli/rdbms-playground/releases/latest`) → tag → deterministic
asset name `rdbms-playground-<tag>-<target>`; download + **verify the
`.sha256`**; install to `~/.local/bin` (fallback `/usr/local/bin` with
a sudo prompt); `chmod +x`; print a PATH hint if needed.
- **macOS:** binaries are signed (see §4 signing note); a `curl`
download does **not** apply the Gatekeeper quarantine xattr, so the
installed binary runs without `xattr` faff.
- **Windows:** not `curl | sh` — provide a PowerShell `install.ps1`
(`irm … | iex`) and/or steer to Scoop/winget (§3).
- **Security posture:** HTTPS only; in-script checksum verification;
document the download-inspect-run alternative (`curl|sh` is a trust
tradeoff).
- **Deliverables we own now:** `scripts/install.sh` (+ later
`install.ps1`); confirm releases are **publicly downloadable**; decide
whether to also upload `install.sh` as a release asset for a stable
link. Website copy referencing the command is the **website branch's**
job (separate agent), later.
---
## 3. D3 — package managers (roadmap; each layers on the release assets)
Common thread: a manifest pointing at our checksummed assets + a
per-release step to bump it. Ordered cheapest → most gatekept.
### 3a. `cargo binstall` + crates.io — PREPARED 2026-06-17 (ADR-0056)
**Done (prep):** crate made publish-ready (dropped `publish = false`;
added `homepage`/`keywords`/`categories`/`exclude`; authored `README.md`
+ `LICENSE-MIT`); `[package.metadata.binstall]` added with per-target
overrides (linux-gnu→musl, windows-msvc→gnu/gnullvm; macOS direct);
`cargo publish --dry-run` clean (913 KiB compressed). Dual license kept;
`LICENSE-MIT` + `LICENSE-APACHE` (© Lazy Evaluation Ltd) + `CONTRIBUTING.md`
(inbound=outbound) all in place. **Gated / remaining:** the actual `cargo
publish` (token, irreversible) at a **new tagged release** (not 0.1.0); a
real `cargo binstall` validation.
- **Bootstrapping matters (user-flagged):** `binstall` is **not** a
built-in cargo subcommand — users must install **`cargo-binstall`**
first (its own `curl|sh`/PowerShell installer, or
`cargo install cargo-binstall`). **Our instructions must say this.**
- Add `[package.metadata.binstall]` to `Cargo.toml` (pkg-url template →
our Gitea release assets; our naming already fits).
- **DECIDED (2026-06-17): publish to crates.io** — so the frictionless
`cargo binstall rdbms-playground` resolves the crate, and the project
is discoverable there. (A crates.io publish is its own small task:
metadata completeness — description/license/repository/keywords/readme
— and `cargo publish`; the `[package.metadata.binstall]` URL template
points binstall at our Gitea release assets.) *(Verify current
cargo-binstall behaviour when wiring.)*
### 3b. Scoop (Windows)
- A **bucket** repo under `lazyeval` on Gitea with a JSON manifest
(`.exe` URL + hash + `autoupdate`). Release job commits the bump.
- Users: `scoop bucket add lazyeval <gitea-url>; scoop install rdbms-playground`.
### 3c. Homebrew (macOS/Linux)
- A **company-branded tap**`lazyeval/homebrew-tap` (on Gitea) — with a
Ruby formula (per-arch `url` + `sha256`). Release job commits the bump.
- Users: `brew tap lazyeval/tap https://git.lazyeval.net/lazyeval/homebrew-tap`
then `brew install lazyeval/tap/rdbms-playground` (the explicit-URL tap
form, since the `user/repo` shorthand assumes GitHub).
- *"Notability bars"* = the acceptance criteria for the default
**homebrew-core** tap (must be sufficiently popular/maintained). Our
own `lazyeval` tap sidesteps that entirely — no review gate.
### 3d. winget (Windows)
- Manifests are YAML PR'd to `microsoft/winget-pkgs` (reviewed by MS).
- **`wingetcreate` is Windows-only** (.NET) — no good without a Windows
runner. **Automatic path to evaluate first: `komac`** — a
cross-platform (Rust) winget manifest creator/submitter that runs on
our **Linux** CI. *(Verify komac's current capabilities/auth model.)*
- **Fallback:** a manual YAML PR per release — acceptable given releases
are infrequent (user-confirmed).
### Cross-cutting (3a3d)
- Two extra repos (tap + bucket) under `lazyeval`, with CI push
credentials — setup TBD (user: "we'll figure that out").
- **`cargo-dist`/"dist"** automates installers + Homebrew + CI, but is
**GitHub-Actions/Releases-centric**; on self-hosted Gitea it won't drop
in cleanly (installer-script generation might be reusable). Likely
hand-roll the manifests + a small "update on release" job instead.
*(Verify cargo-dist's current Gitea support before fully ruling out.)*
---
## Open decisions
1. **crates.io:** **RESOLVED 2026-06-17 — yes, publish.** (See §3a.)
2. **Tracking:** **RESOLVED 2026-06-17 — doc + ADR only, no Gitea
issues.**
3. **Release downloads public:** **CONFIRMED 2026-06-17** — the Gitea
releases are publicly downloadable (no auth); `install.sh` relies on
it and was verified against the live `v0.1.0`.
### Still open / postponed
- **macOS signing — CONFIRMED BUG (2026-06-16), POSTPONED by the user
(2026-06-17)** pending the correct signing ID. Details:
- `release-macos.yaml` does `codesign --force --sign -` (ad-hoc) and has
**no signing scaffolding at all** (no keychain import, no secrets) —
so a downloaded binary is *not* properly signed (user-verified).
- **The credential the user has is the wrong type:** `Apple Development:
Oliver Sturm (W687M898E4)` is a *development* cert (Gatekeeper won't
trust it for distribution). Distribution needs a **`Developer ID
Application`** cert (same format, different type). Signing under the
company name *"Lazy Evaluation Ltd"* would need an **Organization**
Apple Developer account; a personal account signs as "Oliver Sturm".
- **Notarization** (required with Developer ID for non-quarantined trust
on browser downloads): after signing, `xcrun notarytool submit`. Creds
= an **App Store Connect API key** (Issuer ID + Key ID + `.p8`,
recommended for CI) *or* Apple ID + app-specific password + Team ID.
A bare CLI binary can't be *stapled* (only bundles/dmg/pkg) — Gatekeeper
does an online check instead.
- **Urgency caveat:** the `curl|sh` path doesn't need any of this (curl
downloads aren't quarantined); signing matters for browser downloads
from the releases page. Fix when the right cert + creds exist; corrects
the ad-hoc docs once landed.
## Sequencing
1. ✅ **Version discipline** (ADR-0054) — `--version`/`-V` + `version`
command + CI tag-match guard + tests.
2. ✅ **`scripts/install.sh`** (ADR-0055) — built + verified against the
live public release.
3. Package managers, cheapest first:
- ✅ **`cargo binstall` + crates.io** — *prepared* (ADR-0056);
publish gated on a new tagged release + the token.
- **← next:** Scoop (`lazyeval` bucket) → Homebrew (`lazyeval` tap) →
winget (komac / manual). Two `lazyeval` repos (tap + bucket) + CI
push creds to set up.
4. **Cut a release at a new version** — bump `Cargo.toml` (0.1.0 →
0.1.1/0.2.0; the ADR-0054 guard checks the tag), tag, push; the four
Linux/Windows targets build immediately. (macOS leg awaits signing.)
+9 -3
View File
@@ -70,8 +70,11 @@ since ADR-0027.)
natively on a Tart Apple-Silicon runner via the dispatched
`release-macos.yaml`. All uploaded to the Gitea release with a
`.sha256` each. Decisions in `docs/ci/adr/` (ADR-ci-001/002/003).
Runtime-verified by the user: Linux x86_64 + Windows aarch64; the
others are link-clean / valid format.)*
Runtime-verified by the user: Linux x86_64, Windows aarch64, and both
macOS targets (the `release-macos.yaml` dispatch — now triggerable
since CI is on `main` — was run end-to-end and the binaries launch);
Linux aarch64 + Windows x86_64 remain link-clean / valid-format
only.)*
- [x] **D2** Single static binary, no runtime dependencies.
*(Done 2026-06-15, per platform: **Linux** is fully static (musl +
`crt-static`); **Windows** is a standalone `.exe` (Zig statically
@@ -888,7 +891,10 @@ since ADR-0027.)
exists (~55 lines, covers the WHERE-expression and
table-creation boundaries). **Missing:** a DSL
command-surface reference and a standalone type-system
reference under `docs/`.)*
reference under `docs/`. **Note (2026-06-16):** the **canonical**
user docs now live on the **website** (ADR-website-001, deployed) —
it covers the full feature set; the in-repo `docs/` reference pieces
named here remain the outstanding part of DOC1.)*
## Testing (per ADR-0008)
+1 -1
View File
@@ -16,4 +16,4 @@ applies here too).
## Index
- [ADR-website-001 — Public website and documentation site](20260604-adr-website-001.md) — **Accepted 2026-06-04** (formerly ADR-0044 in the main index; moved here 2026-06-10 to end recurring cross-branch number collisions). The first public website: a marketing landing page plus the **canonical** user docs. Stack **Astro 6 + Starlight + Tailwind v4** (chosen over SvelteKit + Tailwind for a docs-heavy + marketing site; interactive bits as Astro islands). Showcase demos are **asciinema** `.cast` recordings (scripted-input driver for paced, re-recordable sessions — *not* `history.log` replay), reused inline in docs. The **in-page WASM playground is deferred** (OOS: deferred) behind a stable `Demo` seam, with the portable-core vs native-edge boundary recorded for a future ADR + iteration plan. Portable **static build** (**Cloudflare** target via **Gitea Actions**, host-agnostic); **no CI yet**; **monorepo** (`website/`). Docs cover the **full supported feature set** with "planned" callouts for the unshipped minority; two wording rules bind user-facing copy — **no engine name** (continues ADR-0002) and **no "DSL"** ("simple mode" / "advanced mode"). Install docs cover **prebuilt binaries + package managers** (D1D3 track the release tooling). Plan: [`docs/website/plans/20260604-website-implementation-plan.md`](../plans/20260604-website-implementation-plan.md)
- [ADR-website-001 — Public website and documentation site](20260604-adr-website-001.md) — **Accepted 2026-06-04** (formerly ADR-0044 in the main index; moved here 2026-06-10 to end recurring cross-branch number collisions). The first public website: a marketing landing page plus the **canonical** user docs. Stack **Astro 6 + Starlight + Tailwind v4** (chosen over SvelteKit + Tailwind for a docs-heavy + marketing site; interactive bits as Astro islands). Showcase demos are **asciinema** `.cast` recordings (scripted-input driver for paced, re-recordable sessions — *not* `history.log` replay), reused inline in docs. The **in-page WASM playground is deferred** (OOS: deferred) behind a stable `Demo` seam, with the portable-core vs native-edge boundary recorded for a future ADR + iteration plan. Portable **static build** (**Cloudflare** target via **Gitea Actions**, host-agnostic); **monorepo** (`website/`). **§4 update (CI implemented):** the static→Cloudflare Pages deploy now runs via Gitea Actions (`.gitea/workflows/website.yaml`; the crate gate is skipped for website-only changes); both `website` and `main` are merged and the site is **deployed**. Docs cover the **full supported feature set** with "planned" callouts for the unshipped minority; two wording rules bind user-facing copy — **no engine name** (continues ADR-0002) and **no "DSL"** ("simple mode" / "advanced mode"). Install docs cover **prebuilt binaries + package managers** (D1D3 track the release tooling). Plan: [`docs/website/plans/20260604-website-implementation-plan.md`](../plans/20260604-website-implementation-plan.md)
+94
View File
@@ -0,0 +1,94 @@
<#
.SYNOPSIS
Download and install a prebuilt rdbms-playground binary (Windows).
.DESCRIPTION
The Windows counterpart of scripts/install.sh. Detects the CPU
architecture, downloads the matching release .exe from the Gitea
releases, verifies its SHA-256 checksum, installs it to
%LOCALAPPDATA%\Programs\rdbms-playground, and adds that directory to
your user PATH.
Quick start:
irm https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.ps1 | iex
We ship gnu / gnullvm Windows builds (x86_64 / aarch64); this maps the
host architecture to the right asset.
.PARAMETER Version
Install a specific tag (e.g. v0.2.0) instead of the latest release.
Defaults to $env:RDBMS_VERSION, else the latest release.
.PARAMETER InstallDir
Install directory. Defaults to $env:RDBMS_INSTALL_DIR, else
%LOCALAPPDATA%\Programs\rdbms-playground.
.NOTES
Written but NOT tested on Windows from this environment (no PowerShell
here) — validate on a real Windows host. The verified sibling is
install.sh (Linux/macOS).
#>
[CmdletBinding()]
param(
[string]$Version = $env:RDBMS_VERSION,
[string]$InstallDir = $(if ($env:RDBMS_INSTALL_DIR) { $env:RDBMS_INSTALL_DIR } else { "$env:LOCALAPPDATA\Programs\rdbms-playground" })
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$Repo = 'https://git.lazyeval.net/oli/rdbms-playground'
$Api = 'https://git.lazyeval.net/api/v1/repos/oli/rdbms-playground'
$Bin = 'rdbms-playground'
# Map the host CPU to the target triple we publish for Windows.
$osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
switch ($osArch) {
'X64' { $target = 'x86_64-pc-windows-gnu' }
'Arm64' { $target = 'aarch64-pc-windows-gnullvm' }
default { throw "install: unsupported CPU architecture: $osArch" }
}
# Resolve the release tag (explicit -Version, else the latest release).
if (-not $Version) {
$Version = (Invoke-RestMethod -Uri "$Api/releases/latest").tag_name
if (-not $Version) { throw 'install: could not determine the latest release tag' }
}
$asset = "$Bin-$Version-$target.exe"
$url = "$Repo/releases/download/$Version/$asset"
$tmp = Join-Path $env:TEMP ([System.Guid]::NewGuid().ToString())
New-Item -ItemType Directory -Path $tmp -Force | Out-Null
try {
$exe = Join-Path $tmp "$Bin.exe"
$shaFile = "$exe.sha256"
Write-Host "downloading $asset ..."
Invoke-WebRequest -Uri $url -OutFile $exe
Invoke-WebRequest -Uri "$url.sha256" -OutFile $shaFile
# The sidecar is "<hash> <name>"; compare just the hash.
$expected = ((Get-Content -Raw $shaFile) -split '\s+')[0].ToLower()
$actual = (Get-FileHash -Algorithm SHA256 -Path $exe).Hash.ToLower()
if ($expected -ne $actual) {
throw "install: checksum mismatch (expected $expected, got $actual) — refusing to install"
}
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
$dest = Join-Path $InstallDir "$Bin.exe"
Move-Item -Path $exe -Destination $dest -Force
Write-Host "installed $Bin $Version -> $dest"
# Add the install dir to the user PATH (persistent) if it's not there.
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if (-not $userPath) { $userPath = '' }
if (($userPath -split ';') -notcontains $InstallDir) {
$newPath = if ($userPath) { "$userPath;$InstallDir" } else { $InstallDir }
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
Write-Host "added $InstallDir to your user PATH — restart your shell to pick it up"
}
}
finally {
Remove-Item -Path $tmp -Recurse -Force -ErrorAction SilentlyContinue
}
+166
View File
@@ -0,0 +1,166 @@
#!/bin/sh
# install.sh — download and install a prebuilt rdbms-playground binary.
#
# Quick start (Linux / macOS):
# curl -fsSL https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.sh | sh
#
# What it does: detects your OS + CPU, downloads the matching release binary
# from the Gitea releases (Linux uses the fully-static musl build), verifies
# its SHA-256 checksum, and installs it to ~/.local/bin.
#
# Environment overrides:
# RDBMS_VERSION install a specific tag (e.g. v0.2.0) instead of the latest
# RDBMS_INSTALL_DIR install directory (default: $HOME/.local/bin)
# RDBMS_OS force the OS (testing): Linux | Darwin
# RDBMS_ARCH force the CPU (testing): x86_64 | aarch64
#
# Flags:
# --print-target print the resolved target triple and exit (no download)
# -h, --help print this help and exit
#
# Notes:
# * Windows is not installable via this script (the binary is a .exe) —
# use Scoop/winget (planned) or download the .exe from the releases page.
# * macOS: a curl download is not quarantined by Gatekeeper, so the binary
# runs without extra steps. (Developer-ID signing + notarization is a
# separate, planned improvement for browser downloads.)
#
# POSIX sh — no bashisms, so it runs under the `sh` of `curl | sh`.
set -eu
REPO_BASE="https://git.lazyeval.net/oli/rdbms-playground"
API_BASE="https://git.lazyeval.net/api/v1/repos/oli/rdbms-playground"
BIN_NAME="rdbms-playground"
PRINT_TARGET=0
err() {
printf 'install: %s\n' "$1" >&2
exit 1
}
info() { printf '%s\n' "$1" >&2; }
usage() {
# Lines 2..(first blank) of this file are the human-readable header.
sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//'
}
# Resolve the Rust target triple for the current (or forced) platform.
# Linux -> <arch>-unknown-linux-musl (the fully-static build)
# macOS -> <arch>-apple-darwin
detect_target() {
os="${RDBMS_OS:-$(uname -s)}"
arch="${RDBMS_ARCH:-$(uname -m)}"
case "$os" in
Linux | linux) os_part="unknown-linux-musl" ;;
Darwin | darwin | macos | macOS) os_part="apple-darwin" ;;
MINGW* | MSYS* | CYGWIN* | *Windows* | *windows*)
err "Windows is not supported by this installer — use Scoop/winget (planned) or download the .exe from $REPO_BASE/releases" ;;
*) err "unsupported operating system: $os" ;;
esac
case "$arch" in
x86_64 | amd64) arch_part="x86_64" ;;
aarch64 | arm64) arch_part="aarch64" ;;
*) err "unsupported CPU architecture: $arch" ;;
esac
printf '%s-%s' "$arch_part" "$os_part"
}
# Download $1 to file $2 (curl or wget).
download() {
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$1" -o "$2"
elif command -v wget >/dev/null 2>&1; then
wget -qO "$2" "$1"
else
err "need either curl or wget on PATH"
fi
}
# Fetch $1 to stdout (curl or wget).
fetch() {
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$1"
elif command -v wget >/dev/null 2>&1; then
wget -qO- "$1"
else
err "need either curl or wget on PATH"
fi
}
# The release tag to install: $RDBMS_VERSION if set, else the latest release.
resolve_version() {
if [ -n "${RDBMS_VERSION:-}" ]; then
printf '%s' "$RDBMS_VERSION"
return
fi
json=$(fetch "$API_BASE/releases/latest") ||
err "could not query the latest release from $API_BASE"
# Portable JSON scrape (no jq): the latest-release object carries exactly
# one "tag_name": "<tag>" field.
tag=$(printf '%s' "$json" |
grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' |
head -1 | sed 's/.*"\([^"]*\)"$/\1/')
[ -n "$tag" ] || err "could not parse the latest release tag"
printf '%s' "$tag"
}
# Verify file $1 against sha256 sidecar $2 (format: "<hash> <name>").
verify_checksum() {
expected=$(awk '{print $1; exit}' "$2")
if command -v sha256sum >/dev/null 2>&1; then
actual=$(sha256sum "$1" | awk '{print $1}')
elif command -v shasum >/dev/null 2>&1; then
actual=$(shasum -a 256 "$1" | awk '{print $1}')
else
info "warning: no sha256 tool found — skipping checksum verification"
return 0
fi
[ "$expected" = "$actual" ] ||
err "checksum mismatch (expected $expected, got $actual) — refusing to install"
}
main() {
while [ $# -gt 0 ]; do
case "$1" in
--print-target) PRINT_TARGET=1 ;;
-h | --help)
usage
exit 0
;;
*) err "unknown argument: $1 (try --help)" ;;
esac
shift
done
target=$(detect_target)
if [ "$PRINT_TARGET" = "1" ]; then
printf '%s\n' "$target"
exit 0
fi
version=$(resolve_version)
asset="$BIN_NAME-$version-$target"
url="$REPO_BASE/releases/download/$version/$asset"
dir="${RDBMS_INSTALL_DIR:-$HOME/.local/bin}"
tmp=$(mktemp -d 2>/dev/null) || err "could not create a temporary directory"
trap 'rm -rf "$tmp"' EXIT INT TERM
info "downloading $asset ..."
download "$url" "$tmp/$BIN_NAME" || err "download failed: $url"
download "$url.sha256" "$tmp/$BIN_NAME.sha256" || err "checksum download failed: $url.sha256"
verify_checksum "$tmp/$BIN_NAME" "$tmp/$BIN_NAME.sha256"
mkdir -p "$dir" || err "could not create install directory: $dir"
chmod +x "$tmp/$BIN_NAME"
mv "$tmp/$BIN_NAME" "$dir/$BIN_NAME" || err "could not install to $dir"
info "installed $BIN_NAME $version -> $dir/$BIN_NAME"
case ":${PATH:-}:" in
*":$dir:"*) ;;
*) info "note: $dir is not on your PATH. Add it, e.g.: export PATH=\"$dir:\$PATH\"" ;;
esac
}
main "$@"
+238 -187
View File
@@ -509,7 +509,10 @@ pub enum LoadPickerSubMode {
/// Switched to via `b`. Same input/cursor surface as
/// `PathEntryModal`; kept inline so the picker can flip
/// back to List with `Esc`.
PathEntry { input: String, cursor: usize },
PathEntry {
input: String,
cursor: usize,
},
}
const PAGE_SCROLL_LINES: usize = 5;
@@ -697,9 +700,7 @@ impl App {
// `trimmed[1..].trim()`.
let leading_ws = self.input.len() - self.input.trim_start().len();
let mut offset = leading_ws + 1; // past the `:`
while offset < self.input.len()
&& self.input.as_bytes()[offset].is_ascii_whitespace()
{
while offset < self.input.len() && self.input.as_bytes()[offset].is_ascii_whitespace() {
offset += 1;
}
let view = &self.input[offset..];
@@ -727,8 +728,7 @@ impl App {
pub fn input_validity_verdict(&self) -> Option<crate::dsl::walker::Severity> {
let mode = match self.effective_mode() {
EffectiveMode::Simple => Mode::Simple,
EffectiveMode::AdvancedPersistent
| EffectiveMode::AdvancedOneShot => Mode::Advanced,
EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => Mode::Advanced,
};
// Strip the `:` one-shot prefix so the walker verdicts the SQL
// itself, not the escape marker (which it can't parse).
@@ -1037,10 +1037,7 @@ impl App {
Vec::new()
}
AppEvent::ExportSucceeded { path } => {
self.note_system(crate::t!(
"project.export_ok",
path = path.display()
));
self.note_system(crate::t!("project.export_ok", path = path.display()));
Vec::new()
}
AppEvent::ExportFailed { error } => {
@@ -1056,11 +1053,7 @@ impl App {
// `[ok] replay — N command(s)` summary is payload-bearing
// (the count) and stays.
self.mark_oldest_pending_echo(EchoStatus::Ok);
self.note_system(crate::t!(
"replay.completed",
path = path,
count = count
));
self.note_system(crate::t!("replay.completed", path = path, count = count));
// ADR-0034: surface `[skip]` warnings for app-lifecycle
// commands whose omission can leave the replayed state
// incomplete (`import`, nested `replay`).
@@ -1084,11 +1077,7 @@ impl App {
// it, mirroring how the interactive `running: …`
// path renders source-line context above an error.
if line_number == 0 {
self.note_error(crate::t!(
"replay.failed_open",
path = path,
error = error
));
self.note_error(crate::t!("replay.failed_open", path = path, error = error));
} else {
self.note_error(crate::t!(
"replay.failed_at_line",
@@ -1097,10 +1086,7 @@ impl App {
error = error
));
if !command.is_empty() {
self.note_error(crate::t!(
"replay.command_echo",
command = command
));
self.note_error(crate::t!("replay.command_echo", command = command));
}
}
Vec::new()
@@ -1237,8 +1223,7 @@ impl App {
// stays F1-only.
let hint_key = key.code == KeyCode::F(1)
|| (self.demo_mode
&& (key.code, key.modifiers)
== (KeyCode::Char('g'), KeyModifiers::CONTROL));
&& (key.code, key.modifiers) == (KeyCode::Char('g'), KeyModifiers::CONTROL));
if hint_key {
if self.input.trim().is_empty() {
self.note_hint_for_recent_error();
@@ -1369,8 +1354,8 @@ impl App {
/// against crossterm 0.29). Only active in demo mode (the caller
/// gates on `self.demo_mode`).
fn handle_demo_caption_key(&mut self, key: KeyEvent) -> Option<Vec<Action>> {
let is_toggle = key.code == KeyCode::Char('5')
&& key.modifiers.contains(KeyModifiers::CONTROL);
let is_toggle =
key.code == KeyCode::Char('5') && key.modifiers.contains(KeyModifiers::CONTROL);
if self.demo_caption_capturing {
if is_toggle {
@@ -1379,8 +1364,7 @@ impl App {
self.demo_caption_capturing = false;
let text = std::mem::take(&mut self.demo_caption_buffer);
let trimmed = text.trim();
self.demo_caption =
(!trimmed.is_empty()).then(|| trimmed.to_string());
self.demo_caption = (!trimmed.is_empty()).then(|| trimmed.to_string());
} else {
match key.code {
// Plain characters accumulate invisibly; the prompt
@@ -1553,7 +1537,10 @@ impl App {
&self.schema_cache,
self.effective_mode().as_mode(),
)?;
comp.replaced_range = (comp.replaced_range.0 + offset, comp.replaced_range.1 + offset);
comp.replaced_range = (
comp.replaced_range.0 + offset,
comp.replaced_range.1 + offset,
);
Some(comp)
}
@@ -1581,8 +1568,7 @@ impl App {
idx: usize,
) -> crate::completion::LastCompletion {
let inserted = comp.candidates[idx].text.clone();
let original_text =
self.input[comp.replaced_range.0..comp.replaced_range.1].to_string();
let original_text = self.input[comp.replaced_range.0..comp.replaced_range.1].to_string();
self.input
.replace_range(comp.replaced_range.0..comp.replaced_range.1, &inserted);
let new_end = comp.replaced_range.0 + inserted.len();
@@ -1777,7 +1763,10 @@ impl App {
// teaching echo (ADR-0038) on an advanced effective mode.
let (submission_mode, effective_input) =
if self.mode == Mode::Simple && trimmed.starts_with(':') {
(EffectiveMode::AdvancedOneShot, trimmed[1..].trim().to_string())
(
EffectiveMode::AdvancedOneShot,
trimmed[1..].trim().to_string(),
)
} else if self.mode == Mode::Advanced {
(EffectiveMode::AdvancedPersistent, trimmed.to_string())
} else {
@@ -1845,11 +1834,7 @@ impl App {
/// simple and advanced modes; the parse-first refactor
/// (round-5) routes app commands here before the
/// mode-specific DSL/SQL paths.
fn dispatch_app_command(
&mut self,
cmd: crate::dsl::AppCommand,
source: &str,
) -> Vec<Action> {
fn dispatch_app_command(&mut self, cmd: crate::dsl::AppCommand, source: &str) -> Vec<Action> {
use crate::dsl::{AppCommand, MessagesValue, ModeValue};
debug!(command = ?cmd, "dispatch app command");
match cmd {
@@ -1868,6 +1853,12 @@ impl App {
self.note_hint_for_recent_error();
Vec::new()
}
// ADR-0054: the in-app twin of `--version`. Reports the same
// single source of truth (`CARGO_PKG_VERSION`, via cli::version_text).
AppCommand::Version => {
self.note_system(crate::cli::version_text());
Vec::new()
}
AppCommand::Rebuild => vec![Action::PrepareRebuild],
AppCommand::Save => self.handle_save_command(false),
AppCommand::SaveAs => self.handle_save_command(true),
@@ -2003,11 +1994,8 @@ impl App {
// mode so the walker gates SQL-only forms — simple-mode
// `select` returns the "this is SQL" hint as a normal
// parse error and is rendered through the Err arm below.
match crate::dsl::parser::parse_command_with_schema_in_mode(
input,
&self.schema_cache,
mode,
) {
match crate::dsl::parser::parse_command_with_schema_in_mode(input, &self.schema_cache, mode)
{
Ok(Command::Replay { path }) => {
// `replay` is parsed as a DSL command for the
// sake of grammar uniformity, but its execution
@@ -2121,15 +2109,9 @@ impl App {
.get(..*position)
.map_or(*position, |s| s.chars().count());
let pad = prefix.chars().count() + chars_before;
self.note_error(crate::t!(
"parse.caret",
padding = " ".repeat(pad)
));
self.note_error(crate::t!("parse.caret", padding = " ".repeat(pad)));
}
self.note_error(crate::t!(
"parse.error",
detail = parse_error_message(&err)
));
self.note_error(crate::t!("parse.error", detail = parse_error_message(&err)));
// ADR-0033 Amendment 3: combine the DSL error with a
// pointer to advanced mode when the same line would
// run as SQL there. Only in simple mode (a one-shot
@@ -2222,7 +2204,11 @@ impl App {
| Command::AddRelationship { .. }
| Command::DropRelationship { .. }
) {
debug!(verb = command.verb(), width = self.last_output_width, "render: relationship diagrams (ADR-0044)");
debug!(
verb = command.verb(),
width = self.last_output_width,
"render: relationship diagrams (ADR-0044)"
);
for line in crate::output_render::render_structure_with_diagrams(
desc,
self.last_output_width,
@@ -2246,11 +2232,7 @@ impl App {
}
}
fn handle_dsl_explain_success(
&mut self,
command: &Command,
plan: &crate::db::QueryPlan,
) {
fn handle_dsl_explain_success(&mut self, command: &Command, plan: &crate::db::QueryPlan) {
self.note_ok_summary(command);
// ADR-0028 §3: the display SQL, then the plan tree.
// `render_explain_plan` returns ready-built `OutputLine`s
@@ -2344,11 +2326,7 @@ impl App {
}
}
fn handle_dsl_add_column_success(
&mut self,
command: &Command,
result: AddColumnResult,
) {
fn handle_dsl_add_column_success(&mut self, command: &Command, result: AddColumnResult) {
self.note_ok_summary(command);
// ADR-0018 §9 / ADR-0038 §6 category 3: emit auto-fill note(s)
// before the structure render so the pedagogical "the tool did
@@ -2363,19 +2341,12 @@ impl App {
self.current_table = Some(result.description);
}
fn handle_dsl_drop_column_success(
&mut self,
command: &Command,
result: DropColumnResult,
) {
fn handle_dsl_drop_column_success(&mut self, command: &Command, result: DropColumnResult) {
self.note_ok_summary(command);
// ADR-0025: when `--cascade` removed covering indexes,
// name each one so the learner sees the side effect.
for index in &result.dropped_indexes {
self.note_system(crate::t!(
"ok.index_dropped_with_column",
index = index,
));
self.note_system(crate::t!("ok.index_dropped_with_column", index = index,));
}
for line in crate::output_render::render_structure(&result.description) {
self.note_system(line);
@@ -2409,10 +2380,7 @@ impl App {
lossy = note.lossy
)
} else {
crate::t!(
"client_side.transformed",
count = note.transformed
)
crate::t!("client_side.transformed", count = note.transformed)
};
self.push_category_three_prose(line);
}
@@ -2577,9 +2545,7 @@ impl App {
(Operation::RenameTable, Some(table.as_str()), None)
}
},
C::SqlCreateTable { name, .. } => {
(Operation::CreateTable, Some(name.as_str()), None)
}
C::SqlCreateTable { name, .. } => (Operation::CreateTable, Some(name.as_str()), None),
C::DropTable { name } => (Operation::DropTable, Some(name.as_str()), None),
C::SqlDropTable { name, .. } => (Operation::DropTable, Some(name.as_str()), None),
C::AddColumn { table, column, .. } => (
@@ -2629,9 +2595,7 @@ impl App {
// SQL `CREATE [UNIQUE] INDEX` shares the add-index operation
// (it reuses `do_add_index`); route engine/validation errors
// through it with the parsed table.
C::SqlCreateIndex { table, .. } => {
(Operation::AddIndex, Some(table.as_str()), None)
}
C::SqlCreateIndex { table, .. } => (Operation::AddIndex, Some(table.as_str()), None),
C::AddConstraint { table, column, .. } => (
Operation::AddConstraint,
Some(table.as_str()),
@@ -2694,19 +2658,13 @@ impl App {
// `dispatch_input` routes them through
// `dispatch_app_command` before the DSL execution
// pipeline that this context builder feeds.
C::App(_) => unreachable!(
"App commands are dispatched before reaching dsl execution"
),
C::App(_) => unreachable!("App commands are dispatched before reaching dsl execution"),
};
TranslateContext {
operation: Some(operation),
table: facts
.table
.or_else(|| fallback_table.map(str::to_string)),
column: facts
.column
.or_else(|| fallback_column.map(str::to_string)),
table: facts.table.or_else(|| fallback_table.map(str::to_string)),
column: facts.column.or_else(|| fallback_column.map(str::to_string)),
child_table: facts.child_table,
parent_table: facts.parent_table,
parent_column: facts.parent_column,
@@ -2812,11 +2770,7 @@ impl App {
}
}
fn handle_path_entry_key(
&mut self,
key: KeyEvent,
mut state: PathEntryModal,
) -> Vec<Action> {
fn handle_path_entry_key(&mut self, key: KeyEvent, mut state: PathEntryModal) -> Vec<Action> {
match key.code {
KeyCode::Esc => {
self.modal = None;
@@ -2898,11 +2852,7 @@ impl App {
}
}
fn handle_load_picker_key(
&mut self,
key: KeyEvent,
mut state: LoadPickerModal,
) -> Vec<Action> {
fn handle_load_picker_key(&mut self, key: KeyEvent, mut state: LoadPickerModal) -> Vec<Action> {
match &mut state.sub_mode {
LoadPickerSubMode::List => match key.code {
KeyCode::Esc => {
@@ -3192,7 +3142,10 @@ impl App {
.map(|c| c.text.clone())
.collect::<Vec<_>>()
.join(", ");
self.push_category_three_prose(crate::t!("hint.ambient_expected", expected = names));
self.push_category_three_prose(crate::t!(
"hint.ambient_expected",
expected = names
));
}
None => self.note_getting_started(),
}
@@ -3407,10 +3360,7 @@ fn render_usage_block(input: &str, mode: Mode) -> String {
.into_iter()
.map(|w| format!("`{w}`"))
.collect();
crate::t!(
"parse.available_commands",
commands = names.join(", ")
)
crate::t!("parse.available_commands", commands = names.join(", "))
}
fn render_cascade_effect(effect: &CascadeEffect) -> String {
@@ -3418,9 +3368,7 @@ fn render_cascade_effect(effect: &CascadeEffect) -> String {
let action_key = match effect.action {
ReferentialAction::Cascade => "db.cascade.action_deleted",
ReferentialAction::SetNull => "db.cascade.action_set_null",
ReferentialAction::Restrict | ReferentialAction::NoAction => {
"db.cascade.action_blocked"
}
ReferentialAction::Restrict | ReferentialAction::NoAction => "db.cascade.action_blocked",
};
crate::t!(
"db.cascade.summary",
@@ -3458,7 +3406,10 @@ mod tests {
fn demo_badge_label_maps_the_invisible_keys() {
let none = KeyModifiers::NONE;
assert_eq!(demo_badge_label(&ke(KeyCode::Tab, none)), Some("[TAB]"));
assert_eq!(demo_badge_label(&ke(KeyCode::BackTab, KeyModifiers::SHIFT)), Some("[SHIFT-TAB]"));
assert_eq!(
demo_badge_label(&ke(KeyCode::BackTab, KeyModifiers::SHIFT)),
Some("[SHIFT-TAB]")
);
assert_eq!(demo_badge_label(&ke(KeyCode::Enter, none)), Some("[ENTER]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Esc, none)), Some("[ESC]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Up, none)), Some("[UP]"));
@@ -3468,8 +3419,14 @@ mod tests {
assert_eq!(demo_badge_label(&ke(KeyCode::Home, none)), Some("[HOME]"));
assert_eq!(demo_badge_label(&ke(KeyCode::End, none)), Some("[END]"));
assert_eq!(demo_badge_label(&ke(KeyCode::PageUp, none)), Some("[PGUP]"));
assert_eq!(demo_badge_label(&ke(KeyCode::PageDown, none)), Some("[PGDN]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Backspace, none)), Some("[BKSP]"));
assert_eq!(
demo_badge_label(&ke(KeyCode::PageDown, none)),
Some("[PGDN]")
);
assert_eq!(
demo_badge_label(&ke(KeyCode::Backspace, none)),
Some("[BKSP]")
);
assert_eq!(demo_badge_label(&ke(KeyCode::Delete, none)), Some("[DEL]"));
assert_eq!(
demo_badge_label(&ke(KeyCode::Char('o'), KeyModifiers::CONTROL)),
@@ -3487,12 +3444,24 @@ mod tests {
#[test]
fn demo_badge_label_none_for_glyphs_and_excluded_chords() {
// Plain characters render their own glyph — no badge.
assert_eq!(demo_badge_label(&ke(KeyCode::Char('a'), KeyModifiers::NONE)), None);
assert_eq!(demo_badge_label(&ke(KeyCode::Char(' '), KeyModifiers::NONE)), None);
assert_eq!(
demo_badge_label(&ke(KeyCode::Char('a'), KeyModifiers::NONE)),
None
);
assert_eq!(
demo_badge_label(&ke(KeyCode::Char(' '), KeyModifiers::NONE)),
None
);
// Quit and the (Phase C) caption toggle are deliberately excluded.
assert_eq!(demo_badge_label(&ke(KeyCode::Char('c'), KeyModifiers::CONTROL)), None);
assert_eq!(
demo_badge_label(&ke(KeyCode::Char('c'), KeyModifiers::CONTROL)),
None
);
// Ctrl+] decodes to Char('5')+CONTROL — must not badge.
assert_eq!(demo_badge_label(&ke(KeyCode::Char('5'), KeyModifiers::CONTROL)), None);
assert_eq!(
demo_badge_label(&ke(KeyCode::Char('5'), KeyModifiers::CONTROL)),
None
);
}
#[test]
@@ -3600,7 +3569,10 @@ mod tests {
assert!(app.demo_caption_capturing, "still capturing");
assert_eq!(app.demo_caption_buffer, "note");
assert_eq!(app.input, "");
assert_eq!(app.demo_badge, None, "inert keys raise no badge while capturing");
assert_eq!(
app.demo_badge, None,
"inert keys raise no badge while capturing"
);
}
#[test]
@@ -4205,7 +4177,9 @@ mod tests {
type_str(&mut app, "copy sideways");
let actions = submit(&mut app);
assert!(
!actions.iter().any(|a| matches!(a, Action::CopyToClipboard(_))),
!actions
.iter()
.any(|a| matches!(a, Action::CopyToClipboard(_))),
"an unknown target does not copy",
);
let rendered = app
@@ -4393,7 +4367,10 @@ mod tests {
);
// … names the table's columns so the user can see what's needed …
assert!(
out.contains("Name") && out.contains("Age") && out.contains("id") && out.contains("SerNo"),
out.contains("Name")
&& out.contains("Age")
&& out.contains("id")
&& out.contains("SerNo"),
"missing the column-name list in: {out}",
);
// … and shows the column-list override targeting the non-auto columns.
@@ -4417,9 +4394,15 @@ mod tests {
let _ = submit(&mut app);
let out = error_lines(&app);
// The teaching line names the user-supplied columns …
assert!(out.contains("Name") && out.contains("Age"), "missing non-auto column names in: {out}");
assert!(
out.contains("Name") && out.contains("Age"),
"missing non-auto column names in: {out}"
);
// … the auto-generated columns …
assert!(out.contains("id") && out.contains("SerNo"), "missing auto column names in: {out}");
assert!(
out.contains("id") && out.contains("SerNo"),
"missing auto column names in: {out}"
);
// … signals the contract …
assert!(
out.contains("auto-generated"),
@@ -4514,10 +4497,7 @@ mod tests {
let mut app = App::new();
install_customers_schema_two_serials(&mut app);
app.mode = Mode::Advanced;
type_str(
&mut app,
"insert into Customers values (13, 'Oli', 42, 13)",
);
type_str(&mut app, "insert into Customers values (13, 'Oli', 42, 13)");
let actions = submit(&mut app);
assert!(
actions
@@ -4546,7 +4526,9 @@ mod tests {
type_str(&mut app, "insert into Customers values ('Oli', 52, 3)");
let actions = submit(&mut app);
assert!(
!actions.iter().any(|a| matches!(a, Action::ExecuteDsl { .. })),
!actions
.iter()
.any(|a| matches!(a, Action::ExecuteDsl { .. })),
"simple-mode Form B count mismatch must NOT dispatch; got: {actions:?}",
);
}
@@ -4559,7 +4541,9 @@ mod tests {
type_str(&mut app, "insert into Customers values ('Oli')");
let actions = submit(&mut app);
assert!(
!actions.iter().any(|a| matches!(a, Action::ExecuteDsl { .. })),
!actions
.iter()
.any(|a| matches!(a, Action::ExecuteDsl { .. })),
"simple-mode Form B under-supply must NOT dispatch; got: {actions:?}",
);
}
@@ -4574,7 +4558,9 @@ mod tests {
type_str(&mut app, "insert into Customers (Name, Age) values ('Oli')");
let actions = submit(&mut app);
assert!(
!actions.iter().any(|a| matches!(a, Action::ExecuteDsl { .. })),
!actions
.iter()
.any(|a| matches!(a, Action::ExecuteDsl { .. })),
"simple-mode Form A count mismatch must NOT dispatch; got: {actions:?}",
);
}
@@ -4610,9 +4596,7 @@ mod tests {
for c in &tc {
app.schema_cache.columns.push(c.name.clone());
}
app.schema_cache
.table_columns
.insert("T".to_string(), tc);
app.schema_cache.table_columns.insert("T".to_string(), tc);
type_str(&mut app, "insert into T values (1, 2), (3, 4)");
let actions = submit(&mut app);
assert!(
@@ -4643,11 +4627,11 @@ mod tests {
// advanced-mode hint at all, so we look for any line carrying
// the "mode advanced" actionable fragment that the pointer
// always emits.
let has_pointer = app
.output
.iter()
.any(|l| l.text.contains("mode advanced"));
assert!(!has_pointer, "unknown command must not point at advanced mode");
let has_pointer = app.output.iter().any(|l| l.text.contains("mode advanced"));
assert!(
!has_pointer,
"unknown command must not point at advanced mode"
);
}
#[test]
@@ -4696,7 +4680,11 @@ mod tests {
app.mode = mode;
type_str(&mut app, input);
match submit(&mut app).as_slice() {
[Action::ExecuteDsl { submission_mode, .. }] => *submission_mode,
[
Action::ExecuteDsl {
submission_mode, ..
},
] => *submission_mode,
other => panic!("expected one ExecuteDsl; got {other:?}"),
}
};
@@ -4727,7 +4715,9 @@ mod tests {
app.update(AppEvent::DslSucceeded {
command: cmd.clone(),
description: None,
echo: Some(vec!["CREATE TABLE Other (id serial PRIMARY KEY)".to_string()]),
echo: Some(vec![
"CREATE TABLE Other (id serial PRIMARY KEY)".to_string(),
]),
});
let texts: Vec<&str> = app.output.iter().map(|l| l.text.as_str()).collect();
// ADR-0040: no `[ok]` summary; with no preceding `running:` echo
@@ -4786,7 +4776,10 @@ mod tests {
.position(|t| t.contains("Executing SQL:"))
.expect("an echo line");
assert_eq!(echo_idx, 0, "teaching echo leads the output: {texts:?}");
assert!(texts[echo_idx].contains(expected), "echo carries the SQL: {texts:?}");
assert!(
texts[echo_idx].contains(expected),
"echo carries the SQL: {texts:?}"
);
// ADR-0038 §4 polish: every success arm now wires the echo as
// `OutputKind::TeachingEcho` so `ui::render_output_line` fires
// the dim-prefix + advanced-lex custom branch. Pinning this
@@ -4905,7 +4898,9 @@ mod tests {
description: sample_description("T"),
client_side: None,
},
echo: Some(vec!["ALTER TABLE T ALTER COLUMN c SET DATA TYPE text".to_string()]),
echo: Some(vec![
"ALTER TABLE T ALTER COLUMN c SET DATA TYPE text".to_string(),
]),
dont_convert_caveat: false,
});
assert_echo_beneath_ok(&app, "ALTER TABLE T ALTER COLUMN c SET DATA TYPE text");
@@ -5120,9 +5115,7 @@ mod tests {
dont_convert_caveat: false,
});
assert!(
!app.output
.iter()
.any(|l| l.text.contains("--dont-convert")),
!app.output.iter().any(|l| l.text.contains("--dont-convert")),
"no caveat in simple mode (no echo to refer to)",
);
}
@@ -5192,7 +5185,10 @@ mod tests {
);
// Pin the `Executing SQL:` prefix repeats once per statement
// (the plain-rendering shape until the styled-runs polish lands).
let exec_count = texts.iter().filter(|t| t.contains("Executing SQL:")).count();
let exec_count = texts
.iter()
.filter(|t| t.contains("Executing SQL:"))
.count();
assert_eq!(exec_count, 3, "one Executing SQL: per statement: {texts:?}");
}
@@ -5217,19 +5213,13 @@ mod tests {
// (could be the caret line, the parse-error detail
// line, or the usage line). Scan for the friendly
// "unknown mode" anchor phrase.
let anywhere = app
.output
.iter()
.any(|l| l.text.contains("unknown mode"));
let anywhere = app.output.iter().any(|l| l.text.contains("unknown mode"));
assert!(
anywhere,
"expected 'unknown mode' somewhere in output: {:?}",
app.output.iter().map(|l| &l.text).collect::<Vec<_>>(),
);
let any_error = app
.output
.iter()
.any(|l| l.kind == OutputKind::Error);
let any_error = app.output.iter().any(|l| l.kind == OutputKind::Error);
assert!(any_error, "expected at least one Error line");
}
@@ -5319,11 +5309,17 @@ mod tests {
app.schema_cache.tables = vec!["Orders".into(), "Customers".into()];
app.schema_cache.table_columns.insert(
"Orders".into(),
vec![TableColumn::new("id", Type::Serial), TableColumn::new("customer_id", Type::Int)],
vec![
TableColumn::new("id", Type::Serial),
TableColumn::new("customer_id", Type::Int),
],
);
app.schema_cache.table_columns.insert(
"Customers".into(),
vec![TableColumn::new("id", Type::Serial), TableColumn::new("name", Type::Text)],
vec![
TableColumn::new("id", Type::Serial),
TableColumn::new("name", Type::Text),
],
);
for t in app.schema_cache.tables.clone() {
for c in &app.schema_cache.table_columns[&t] {
@@ -5484,10 +5480,7 @@ mod tests {
detail: "SCAN Customers".to_string(),
}],
};
app.update(AppEvent::DslExplainSucceeded {
command: cmd,
plan,
});
app.update(AppEvent::DslExplainSucceeded { command: cmd, plan });
// ADR-0040: no `[ok] explain …` header — the (no-echo here)
// command's success shows via the marker; the plan output
// itself carries the content.
@@ -5543,7 +5536,11 @@ mod tests {
.iter()
.find(|l| l.kind == OutputKind::Echo)
.expect("dispatch pushed an echo");
assert_eq!(echo.status, Some(EchoStatus::Pending), "pending before result");
assert_eq!(
echo.status,
Some(EchoStatus::Pending),
"pending before result"
);
app.update(AppEvent::DslSucceeded {
command: Command::CreateTable {
name: "T".to_string(),
@@ -5633,8 +5630,14 @@ mod tests {
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("[ok] replay"), "summary present:\n{text}");
assert!(text.contains("import a.zip"), "import skip warning rendered:\n{text}");
assert!(text.contains("nested `replay x`"), "nested-replay skip warning rendered:\n{text}");
assert!(
text.contains("import a.zip"),
"import skip warning rendered:\n{text}"
);
assert!(
text.contains("nested `replay x`"),
"nested-replay skip warning rendered:\n{text}"
);
}
#[test]
@@ -5730,13 +5733,36 @@ mod tests {
#[test]
fn hint_command_parses_to_app_hint() {
use crate::dsl::{parse_command, AppCommand, Command};
use crate::dsl::{AppCommand, Command, parse_command};
assert!(matches!(
parse_command("hint"),
Ok(Command::App(AppCommand::Hint))
));
}
// ── ADR-0054: in-app `version` command ──────────────────────────
#[test]
fn version_command_parses_to_app_version() {
use crate::dsl::{AppCommand, Command, parse_command};
assert!(matches!(
parse_command("version"),
Ok(Command::App(AppCommand::Version))
));
}
#[test]
fn version_command_emits_the_cargo_version() {
let mut app = App::new();
type_str(&mut app, "version");
submit(&mut app);
assert!(
output_contains(&app, env!("CARGO_PKG_VERSION")),
"in-app `version` should print CARGO_PKG_VERSION: {}",
error_lines(&app),
);
}
#[test]
fn hint_command_with_no_recent_error_shows_getting_started() {
let mut app = App::new();
@@ -5772,7 +5798,10 @@ mod tests {
let mut app = App::new();
type_str(&mut app, "show ");
app.update(key(KeyCode::Tab));
assert!(app.last_completion.is_some(), "precondition: Tab sets the memo");
assert!(
app.last_completion.is_some(),
"precondition: Tab sets the memo"
);
let input = app.input.clone();
f1(&mut app);
assert!(app.last_completion.is_some(), "F1 must not clear the memo");
@@ -5798,8 +5827,14 @@ mod tests {
let input = app.input.clone();
let before = app.output.len();
app.update(ctrl_g());
assert_eq!(app.input, input, "Ctrl-G must not change the buffer (no `g` typed)");
assert!(app.output.len() > before, "Ctrl-G must emit the same hint F1 does");
assert_eq!(
app.input, input,
"Ctrl-G must not change the buffer (no `g` typed)"
);
assert!(
app.output.len() > before,
"Ctrl-G must emit the same hint F1 does"
);
}
#[test]
@@ -5826,7 +5861,11 @@ mod tests {
let before = app.output.len();
app.update(ctrl_g());
assert_eq!(app.input, input, "Ctrl-G must not insert a `g`");
assert_eq!(app.output.len(), before, "Ctrl-G does nothing when demo mode is off");
assert_eq!(
app.output.len(),
before,
"Ctrl-G does nothing when demo mode is off"
);
}
#[test]
@@ -5920,7 +5959,10 @@ mod tests {
#[test]
fn f1_on_add_relationship_renders_the_relationship_block() {
let mut app = App::new();
type_str(&mut app, "add 1:n relationship from Customers.id to Orders.cust ");
type_str(
&mut app,
"add 1:n relationship from Customers.id to Orders.cust ",
);
f1(&mut app);
assert!(
output_contains(&app, "one parent, many children"),
@@ -6113,14 +6155,8 @@ mod tests {
let mut app = App::new();
let cmd = Command::Update {
table: "Customers".to_string(),
assignments: vec![(
"id".to_string(),
crate::dsl::Value::Number("7".to_string()),
)],
filter: crate::dsl::RowFilter::eq(
"name",
crate::dsl::Value::Text("Bob".to_string()),
),
assignments: vec![("id".to_string(), crate::dsl::Value::Number("7".to_string()))],
filter: crate::dsl::RowFilter::eq("name", crate::dsl::Value::Text("Bob".to_string())),
};
let err = crate::db::DbError::Sqlite {
message: "UNIQUE constraint failed: Customers.id".to_string(),
@@ -6680,7 +6716,10 @@ mod tests {
app.update(key(KeyCode::Backspace));
let actions = app.update(key(KeyCode::Enter));
assert_eq!(app.input, "select", "input untouched in navigation mode");
assert!(actions.is_empty(), "Enter does not submit in navigation mode");
assert!(
actions.is_empty(),
"Enter does not submit in navigation mode"
);
}
#[test]
@@ -6691,7 +6730,10 @@ mod tests {
app.update(key(KeyCode::Down));
app.update(key(KeyCode::Down));
assert_eq!(app.tables_scroll, 2);
assert_eq!(app.relationships_scroll, 0, "only the focused panel scrolls");
assert_eq!(
app.relationships_scroll, 0,
"only the focused panel scrolls"
);
app.update(key(KeyCode::Up));
assert_eq!(app.tables_scroll, 1);
// Up saturates at the top.
@@ -6969,7 +7011,8 @@ mod tests {
for round in 0..3 {
app.update(key(KeyCode::Up));
assert_eq!(
app.input, "insert into Thing values (1)",
app.input,
"insert into Thing values (1)",
"Up #{} should recall the newest entry",
round + 1,
);
@@ -7191,8 +7234,7 @@ mod tests {
has_default: false,
}],
);
app.input =
"select * from products where price like 5".to_string();
app.input = "select * from products where price like 5".to_string();
assert_eq!(
app.input_validity_verdict(),
Some(crate::dsl::walker::Severity::Warning),
@@ -7321,8 +7363,10 @@ mod tests {
"directly-deleted count surfaced: {texts:?}",
);
assert!(
texts.iter().any(|t| t.contains("2 row(s) deleted in `Orders`")
&& t.contains("relationship `places`")),
texts
.iter()
.any(|t| t.contains("2 row(s) deleted in `Orders`")
&& t.contains("relationship `places`")),
"per-relationship cascade summary surfaced: {texts:?}",
);
}
@@ -7357,11 +7401,15 @@ mod tests {
});
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
assert!(
texts.iter().any(|t| t.contains("20 row(s) seeded into users")),
texts
.iter()
.any(|t| t.contains("20 row(s) seeded into users")),
"seeded-row count surfaced: {texts:?}",
);
assert!(
texts.iter().any(|t| t.contains("status") && t.contains("generic text")),
texts
.iter()
.any(|t| t.contains("status") && t.contains("generic text")),
"the advisory names the enum-ish column: {texts:?}",
);
}
@@ -7395,8 +7443,9 @@ mod tests {
});
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
assert!(
texts.iter().any(|t| t.contains("4 row(s) seeded into J")
&& t.contains("of 10 requested")),
texts
.iter()
.any(|t| t.contains("4 row(s) seeded into J") && t.contains("of 10 requested")),
"the cap note surfaces requested vs produced: {texts:?}",
);
}
@@ -7435,7 +7484,9 @@ mod tests {
});
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
assert!(
texts.iter().any(|t| t.contains("2 row(s) deleted in `Orders`")),
texts
.iter()
.any(|t| t.contains("2 row(s) deleted in `Orders`")),
"cascade summary still surfaces alongside RETURNING: {texts:?}",
);
assert!(
+37 -37
View File
@@ -30,9 +30,7 @@ use std::path::{Component, Path, PathBuf};
use tracing::{debug, info};
use zip::{CompressionMethod, ZipArchive, ZipWriter, write::SimpleFileOptions};
use crate::project::{
HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML, naming::today_local,
};
use crate::project::{HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML, naming::today_local};
/// File names excluded from `export` zips. These are either
/// derived (`playground.db`), per-process (`.lock`),
@@ -118,20 +116,14 @@ impl std::fmt::Display for ArchiveError {
limit = format_args!("{limit:02}"),
))
}
Self::InvalidZip(detail) => f.write_str(&crate::t!(
"archive.invalid_zip",
detail = detail,
)),
Self::NotAProjectArchive => {
f.write_str(&crate::t!("archive.not_a_project_archive"))
Self::InvalidZip(detail) => {
f.write_str(&crate::t!("archive.invalid_zip", detail = detail,))
}
Self::MultipleTopFolders => {
f.write_str(&crate::t!("archive.multiple_top_folders"))
Self::NotAProjectArchive => f.write_str(&crate::t!("archive.not_a_project_archive")),
Self::MultipleTopFolders => f.write_str(&crate::t!("archive.multiple_top_folders")),
Self::UnsafeEntry(entry) => {
f.write_str(&crate::t!("archive.unsafe_entry", entry = entry,))
}
Self::UnsafeEntry(entry) => f.write_str(&crate::t!(
"archive.unsafe_entry",
entry = entry,
)),
}
}
}
@@ -216,13 +208,7 @@ pub fn export_project(
.unix_permissions(0o644);
add_directory_entry(&mut writer, project_name, dst_zip)?;
add_directory_recursive(
&mut writer,
project_path,
project_name,
&options,
dst_zip,
)?;
add_directory_recursive(&mut writer, project_path, project_name, &options, dst_zip)?;
writer.finish().map_err(|e| ArchiveError::Zip {
path: dst_zip.to_path_buf(),
@@ -392,10 +378,7 @@ pub struct ZipInspection {
///
/// Returns the resolved target path and the suffix that was
/// applied (0 if the original name was free, 2..=99 otherwise).
pub fn resolve_import_target(
parent: &Path,
name: &str,
) -> Result<(PathBuf, u32), ArchiveError> {
pub fn resolve_import_target(parent: &Path, name: &str) -> Result<(PathBuf, u32), ArchiveError> {
let direct = parent.join(name);
if !direct.exists() {
return Ok((direct, 0));
@@ -495,10 +478,12 @@ pub fn extract_into(
source,
})?;
let mut buf = Vec::with_capacity(entry.size() as usize);
entry.read_to_end(&mut buf).map_err(|source| ArchiveError::Io {
path: dst_path.clone(),
source,
})?;
entry
.read_to_end(&mut buf)
.map_err(|source| ArchiveError::Io {
path: dst_path.clone(),
source,
})?;
out.write_all(&buf).map_err(|source| ArchiveError::Io {
path: dst_path.clone(),
source,
@@ -523,7 +508,11 @@ mod tests {
fs::write(p.join(PROJECT_YAML), "version: 1\nproject:\n created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n").unwrap();
fs::create_dir_all(p.join("data")).unwrap();
fs::write(p.join("data/Customers.csv"), "Name\nAlice\nBob\n").unwrap();
fs::write(p.join(HISTORY_LOG), "T|ok|create table Customers with pk id(serial)\n").unwrap();
fs::write(
p.join(HISTORY_LOG),
"T|ok|create table Customers with pk id(serial)\n",
)
.unwrap();
fs::write(p.join(PLAYGROUND_DB), [0u8; 32]).unwrap();
fs::write(p.join(GITIGNORE), "/playground.db\n").unwrap();
// Stray atomic-write staging file — must be excluded.
@@ -536,7 +525,9 @@ mod tests {
)
.unwrap();
fs::write(
p.join(crate::undo::SNAPSHOTS_DIR).join("0").join(PLAYGROUND_DB),
p.join(crate::undo::SNAPSHOTS_DIR)
.join("0")
.join(PLAYGROUND_DB),
[0u8; 16],
)
.unwrap();
@@ -618,11 +609,15 @@ mod tests {
let zip_path = tmp.path().join("notaproject.zip");
let f = fs::File::create(&zip_path).unwrap();
let mut w = ZipWriter::new(f);
w.start_file("foo/bar.txt", SimpleFileOptions::default()).unwrap();
w.start_file("foo/bar.txt", SimpleFileOptions::default())
.unwrap();
w.write_all(b"hi").unwrap();
w.finish().unwrap();
let err = inspect_zip(&zip_path).expect_err("must refuse");
assert!(matches!(err, ArchiveError::NotAProjectArchive), "got: {err:?}");
assert!(
matches!(err, ArchiveError::NotAProjectArchive),
"got: {err:?}"
);
}
#[test]
@@ -631,13 +626,18 @@ mod tests {
let zip_path = tmp.path().join("multi.zip");
let f = fs::File::create(&zip_path).unwrap();
let mut w = ZipWriter::new(f);
w.start_file("a/project.yaml", SimpleFileOptions::default()).unwrap();
w.start_file("a/project.yaml", SimpleFileOptions::default())
.unwrap();
w.write_all(b"x").unwrap();
w.start_file("b/project.yaml", SimpleFileOptions::default()).unwrap();
w.start_file("b/project.yaml", SimpleFileOptions::default())
.unwrap();
w.write_all(b"x").unwrap();
w.finish().unwrap();
let err = inspect_zip(&zip_path).expect_err("must refuse");
assert!(matches!(err, ArchiveError::MultipleTopFolders), "got: {err:?}");
assert!(
matches!(err, ArchiveError::MultipleTopFolders),
"got: {err:?}"
);
}
#[test]
+93 -24
View File
@@ -30,6 +30,10 @@ pub struct Args {
/// `--help` / `-h`: print usage to stdout and exit. The
/// runtime checks this flag before doing any other work.
pub help: bool,
/// `--version` / `-V`: print the version (`CARGO_PKG_VERSION`,
/// the single source of truth — ADR-0054) and exit. Checked
/// alongside `--help` before any other work.
pub version: bool,
/// `--no-undo`: disable the auto-snapshot / undo machinery for
/// this run (ADR-0006 Amendment 1). When set, no snapshots are
/// taken — zero per-command overhead — and `undo` / `redo`
@@ -62,6 +66,17 @@ pub fn help_text() -> String {
crate::t!("help.cli_banner")
}
/// Version line printed by `--version` / `-V` and the in-app `version`
/// command (ADR-0054).
///
/// `CARGO_PKG_VERSION` is the single source of truth — it equals the `v*`
/// release tag (the release CI guards that), so what the binary reports
/// always matches the downloaded artifact.
#[must_use]
pub fn version_text() -> String {
crate::t!("cli.version_line", version = env!("CARGO_PKG_VERSION"))
}
#[derive(Debug)]
pub enum ArgsError {
MissingValue(&'static str),
@@ -81,10 +96,7 @@ pub enum ArgsError {
impl std::fmt::Display for ArgsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingValue(flag) => f.write_str(&crate::t!(
"cli.missing_value",
flag = flag,
)),
Self::MissingValue(flag) => f.write_str(&crate::t!("cli.missing_value", flag = flag,)),
Self::InvalidValue {
flag,
value,
@@ -95,10 +107,7 @@ impl std::fmt::Display for ArgsError {
value = value,
expected = expected,
)),
Self::Unknown(arg) => f.write_str(&crate::t!(
"cli.unknown_argument",
arg = arg,
)),
Self::Unknown(arg) => f.write_str(&crate::t!("cli.unknown_argument", arg = arg,)),
Self::MultiplePaths { first, second } => f.write_str(&crate::t!(
"cli.multiple_paths",
first = first,
@@ -129,6 +138,7 @@ impl Args {
let mut project_path: Option<PathBuf> = None;
let mut resume = false;
let mut help = false;
let mut version = false;
let mut no_undo = false;
let mut mode: Option<Mode> = None;
// Demonstration mode (ADR-0047): the env var is the default,
@@ -143,6 +153,9 @@ impl Args {
"--help" | "-h" => {
help = true;
}
"--version" | "-V" => {
version = true;
}
"--resume" => {
resume = true;
}
@@ -208,6 +221,7 @@ impl Args {
project_path,
resume,
help,
version,
no_undo,
mode,
demo,
@@ -241,7 +255,11 @@ fn default_theme() -> Theme {
// Standard convention: 0..=6 and 8 are dark backgrounds,
// 7 and 9..=15 are light. ITerm emits 15 for white-ish.
let is_dark = matches!(code, 0..=6 | 8);
return if is_dark { Theme::dark() } else { Theme::light() };
return if is_dark {
Theme::dark()
} else {
Theme::light()
};
}
Theme::default()
}
@@ -294,10 +312,19 @@ mod tests {
#[test]
fn mode_flag_simple_and_advanced() {
assert_eq!(Args::parse(["--mode", "simple"]).unwrap().mode, Some(Mode::Simple));
assert_eq!(Args::parse(["--mode", "advanced"]).unwrap().mode, Some(Mode::Advanced));
assert_eq!(
Args::parse(["--mode", "simple"]).unwrap().mode,
Some(Mode::Simple)
);
assert_eq!(
Args::parse(["--mode", "advanced"]).unwrap().mode,
Some(Mode::Advanced)
);
// Case-insensitive, like the `mode` command.
assert_eq!(Args::parse(["--mode", "ADVANCED"]).unwrap().mode, Some(Mode::Advanced));
assert_eq!(
Args::parse(["--mode", "ADVANCED"]).unwrap().mode,
Some(Mode::Advanced)
);
}
#[test]
@@ -330,7 +357,10 @@ mod tests {
#[test]
fn data_dir_flag_parses() {
let args = Args::parse(["--data-dir", "/tmp/playground-data"]).unwrap();
assert_eq!(args.data_dir.as_deref(), Some(std::path::Path::new("/tmp/playground-data")));
assert_eq!(
args.data_dir.as_deref(),
Some(std::path::Path::new("/tmp/playground-data"))
);
}
#[test]
@@ -350,13 +380,11 @@ mod tests {
#[test]
fn data_dir_and_positional_can_coexist() {
let args = Args::parse([
"--data-dir",
"/tmp/data",
"/home/me/MyProject",
])
.unwrap();
assert_eq!(args.data_dir.as_deref(), Some(std::path::Path::new("/tmp/data")));
let args = Args::parse(["--data-dir", "/tmp/data", "/home/me/MyProject"]).unwrap();
assert_eq!(
args.data_dir.as_deref(),
Some(std::path::Path::new("/tmp/data"))
);
assert_eq!(
args.project_path.as_deref(),
Some(std::path::Path::new("/home/me/MyProject"))
@@ -366,7 +394,10 @@ mod tests {
#[test]
fn two_positional_paths_error() {
let err = Args::parse(["/a", "/b"]).unwrap_err();
assert!(matches!(err, ArgsError::MultiplePaths { .. }), "got: {err:?}");
assert!(
matches!(err, ArgsError::MultiplePaths { .. }),
"got: {err:?}"
);
}
#[test]
@@ -435,7 +466,10 @@ mod tests {
// Absent `--demo` (and absent env var in the test runner),
// demo mode is off — zero footprint for real users.
let args = Args::parse(std::iter::empty::<&str>()).unwrap();
assert!(!args.demo, "demo is off unless --demo or the env var is given");
assert!(
!args.demo,
"demo is off unless --demo or the env var is given"
);
}
#[test]
@@ -464,7 +498,10 @@ mod tests {
}
// Disabling values.
for v in ["", " ", "0", "false", "False", "no", "off", "OFF"] {
assert!(!demo_value_is_truthy(v), "{v:?} should not enable demo mode");
assert!(
!demo_value_is_truthy(v),
"{v:?} should not enable demo mode"
);
}
}
@@ -473,6 +510,38 @@ mod tests {
// Make sure the path-vs-flag distinction is robust:
// unknown flags don't get silently swallowed as paths.
let err = Args::parse(["--bogus", "/some/path"]).unwrap_err();
assert!(matches!(&err, ArgsError::Unknown(s) if s == "--bogus"), "got: {err:?}");
assert!(
matches!(&err, ArgsError::Unknown(s) if s == "--bogus"),
"got: {err:?}"
);
}
// ---- ADR-0054: --version / -V ----
#[test]
fn version_long_flag_parses() {
assert!(Args::parse(["--version"]).unwrap().version);
}
#[test]
fn version_short_flag_parses() {
assert!(Args::parse(["-V"]).unwrap().version);
}
#[test]
fn version_defaults_off() {
assert!(!Args::parse(std::iter::empty::<&str>()).unwrap().version);
}
#[test]
fn version_text_carries_the_cargo_version() {
// The binary's self-reported version IS Cargo.toml's (the
// single source of truth, ADR-0054) — and the release CI guards
// that the `v*` tag equals it.
let text = version_text();
assert!(
text.contains(env!("CARGO_PKG_VERSION")),
"version line should embed CARGO_PKG_VERSION; got {text:?}"
);
}
}
+254 -210
View File
@@ -15,10 +15,10 @@
//! `app.rs`; this module owns the candidate computation.
use crate::dsl::grammar::IdentSource;
use crate::dsl::parser::parse_command_with_schema_in_mode;
use crate::dsl::types::Type;
use crate::dsl::walker::outcome::Expectation;
use crate::dsl::{ParseError, parse_command};
use crate::dsl::parser::parse_command_with_schema_in_mode;
use crate::mode::Mode;
/// Composite literal candidates whose lexed shape is more than
@@ -275,11 +275,7 @@ pub struct Completion {
/// (case-insensitive starts-with), combined, sorted, and
/// deduplicated.
#[must_use]
pub fn candidates_at_cursor(
input: &str,
cursor: usize,
cache: &SchemaCache,
) -> Option<Completion> {
pub fn candidates_at_cursor(input: &str, cursor: usize, cache: &SchemaCache) -> Option<Completion> {
candidates_at_cursor_in_mode(input, cursor, cache, Mode::Advanced)
}
@@ -358,7 +354,11 @@ pub fn candidates_at_cursor_with_in_mode(
let word_boundary = run == 0 || bytes[run - 1].is_ascii_whitespace();
if run < cursor && bytes[run] == b'-' && word_boundary && run < start {
let pre = crate::dsl::walker::completion_probe_in_mode(&input[..run], cache, mode);
if pre.expected.iter().any(|e| matches!(e, Expectation::Flag(_))) {
if pre
.expected
.iter()
.any(|e| matches!(e, Expectation::Flag(_)))
{
start = run;
}
}
@@ -473,22 +473,19 @@ pub fn candidates_at_cursor_with_in_mode(
// walk's `current_table_columns`; fall back to "the union of
// the look-ahead from_scope's bindings' columns" when leading
// produced no in-scope columns. Phase-1 DSL paths unaffected.
let lookahead_union_columns: Vec<TableColumn> =
if probe.current_table_columns.is_none() {
let mut out: Vec<TableColumn> = Vec::new();
for binding in resolution_from_scope {
for col in &binding.columns {
if !out.iter().any(|c| {
c.name.eq_ignore_ascii_case(&col.name)
}) {
out.push(col.clone());
}
let lookahead_union_columns: Vec<TableColumn> = if probe.current_table_columns.is_none() {
let mut out: Vec<TableColumn> = Vec::new();
for binding in resolution_from_scope {
for col in &binding.columns {
if !out.iter().any(|c| c.name.eq_ignore_ascii_case(&col.name)) {
out.push(col.clone());
}
}
out
} else {
Vec::new()
};
}
out
} else {
Vec::new()
};
let lookahead_slice: Option<&[TableColumn]> = if lookahead_union_columns.is_empty() {
None
} else {
@@ -507,30 +504,23 @@ pub fn candidates_at_cursor_with_in_mode(
// column list (the structural error path surfaces the
// unresolved-prefix message).
let prefix_qualifier = peek_back_qualifier(input, start);
let qualified_columns: Option<Vec<String>> = prefix_qualifier
.as_ref()
.map(|q| {
// ADR-0033 §9: `excluded.|` inside an `INSERT … ON
// CONFLICT … DO UPDATE` completes to the target table's
// columns — `excluded` mirrors the would-be-inserted row.
// The target's columns are the INSERT's
// `current_table_columns` (set by the target-table slot).
// The diagnostic pass enforces the strict DO-UPDATE
// byte-range; completion is the softer surface and offers
// the columns whenever the INSERT target is in hand.
if q.eq_ignore_ascii_case("excluded")
&& let Some(cols) = current_table_columns
{
cols.iter().map(|c| c.name.clone()).collect()
} else {
resolve_qualifier_columns_in(
q,
resolution_from_scope,
resolution_cte_bindings,
cache,
)
}
});
let qualified_columns: Option<Vec<String>> = prefix_qualifier.as_ref().map(|q| {
// ADR-0033 §9: `excluded.|` inside an `INSERT … ON
// CONFLICT … DO UPDATE` completes to the target table's
// columns — `excluded` mirrors the would-be-inserted row.
// The target's columns are the INSERT's
// `current_table_columns` (set by the target-table slot).
// The diagnostic pass enforces the strict DO-UPDATE
// byte-range; completion is the softer surface and offers
// the columns whenever the INSERT target is in hand.
if q.eq_ignore_ascii_case("excluded")
&& let Some(cols) = current_table_columns
{
cols.iter().map(|c| c.name.clone()).collect()
} else {
resolve_qualifier_columns_in(q, resolution_from_scope, resolution_cte_bindings, cache)
}
});
let expected = if probe.expected.is_empty() {
expected_at(leading, mode)
@@ -574,8 +564,7 @@ pub fn candidates_at_cursor_with_in_mode(
Some(crate::dsl::grammar::HintMode::ProseOnly(_))
);
if partial_prefix.is_empty()
&& (prose_only_slot
|| (is_value_literal_signature(&expected) && !has_schema_ident))
&& (prose_only_slot || (is_value_literal_signature(&expected) && !has_schema_ident))
{
return None;
}
@@ -646,7 +635,13 @@ pub fn candidates_at_cursor_with_in_mode(
// shortid). The walker surfaces this as
// `Expectation::Ident { source: Types }`.
let type_names: Vec<String> = if expected.iter().any(|e| {
matches!(e, Expectation::Ident { source: IdentSource::Types, .. })
matches!(
e,
Expectation::Ident {
source: IdentSource::Types,
..
}
)
}) {
Type::all()
.iter()
@@ -725,7 +720,13 @@ pub fn candidates_at_cursor_with_in_mode(
// filtered like every other source; empty prefix offers the whole
// set. Tagged `CandidateKind::Function` for its own colour.
let has_sql_expr_slot = expected.iter().any(|e| {
matches!(e, Expectation::Ident { role: "sql_expr_ident", .. })
matches!(
e,
Expectation::Ident {
role: "sql_expr_ident",
..
}
)
});
let mut functions: Vec<String> = if has_sql_expr_slot {
crate::dsl::sql_functions::KNOWN_SQL_FUNCTIONS
@@ -741,9 +742,15 @@ pub fn candidates_at_cursor_with_in_mode(
// curated vocabulary is offered so a learner can discover `email` /
// `product` / … by Tab. Same `Function` kind / `tok_function` colour
// as SQL functions (no new theme colour — ADR-0048 §Grammar).
let has_generator_slot = expected
.iter()
.any(|e| matches!(e, Expectation::Ident { source: IdentSource::Generators, .. }));
let has_generator_slot = expected.iter().any(|e| {
matches!(
e,
Expectation::Ident {
source: IdentSource::Generators,
..
}
)
});
if has_generator_slot {
functions.extend(
crate::seed::KNOWN_GENERATORS
@@ -765,38 +772,36 @@ pub fn candidates_at_cursor_with_in_mode(
// (the `typing_over_diag` path) — keeps the alias from flashing as
// a bogus "unknown column" while typing. Mixed into `identifiers`
// so it sorts/dedups/colours uniformly with column candidates.
let alias_candidates: Vec<String> =
if has_sql_expr_slot && prefix_qualifier.is_none() {
// Once the partial *exactly* matches an in-scope qualifier,
// discoverability is served — the learner has a whole alias
// in hand and now needs the "add `.column`" hint
// (`diagnostic.alias_used_as_column`), not sibling aliases
// that merely share the prefix. Offering them would also let
// the `typing_over_diag` path suppress that very hint. So in
// the exact-match case we emit no alias candidates and let
// the targeted diagnostic surface.
let partial_is_exact_alias = resolution_from_scope.iter().any(|b| {
let q = b.alias.as_deref().unwrap_or(b.table.as_str());
q.eq_ignore_ascii_case(&partial_prefix)
});
if partial_is_exact_alias {
Vec::new()
} else {
let mut out: Vec<String> = Vec::new();
for binding in resolution_from_scope {
let qualifier =
binding.alias.as_deref().unwrap_or(binding.table.as_str());
if matches_prefix(qualifier)
&& !out.iter().any(|q| q.eq_ignore_ascii_case(qualifier))
{
out.push(qualifier.to_string());
}
}
out
}
} else {
let alias_candidates: Vec<String> = if has_sql_expr_slot && prefix_qualifier.is_none() {
// Once the partial *exactly* matches an in-scope qualifier,
// discoverability is served — the learner has a whole alias
// in hand and now needs the "add `.column`" hint
// (`diagnostic.alias_used_as_column`), not sibling aliases
// that merely share the prefix. Offering them would also let
// the `typing_over_diag` path suppress that very hint. So in
// the exact-match case we emit no alias candidates and let
// the targeted diagnostic surface.
let partial_is_exact_alias = resolution_from_scope.iter().any(|b| {
let q = b.alias.as_deref().unwrap_or(b.table.as_str());
q.eq_ignore_ascii_case(&partial_prefix)
});
if partial_is_exact_alias {
Vec::new()
};
} else {
let mut out: Vec<String> = Vec::new();
for binding in resolution_from_scope {
let qualifier = binding.alias.as_deref().unwrap_or(binding.table.as_str());
if matches_prefix(qualifier)
&& !out.iter().any(|q| q.eq_ignore_ascii_case(qualifier))
{
out.push(qualifier.to_string());
}
}
out
}
} else {
Vec::new()
};
// Source 2: schema identifiers — accumulated across every
// matching schema-listable `Ident { source }` expectation.
@@ -811,9 +816,7 @@ pub fn candidates_at_cursor_with_in_mode(
let mut identifiers: Vec<String> = expected
.iter()
.filter_map(|e| match e {
Expectation::Ident { source, .. } if source.completes_from_schema() => {
Some(*source)
}
Expectation::Ident { source, .. } if source.completes_from_schema() => Some(*source),
_ => None,
})
.flat_map(|source| {
@@ -1007,11 +1010,7 @@ fn resolve_qualifier_columns_in(
.iter()
.find(|c| c.name.eq_ignore_ascii_case(&binding.table))
{
return cte
.columns
.iter()
.filter_map(|c| c.name.clone())
.collect();
return cte.columns.iter().filter_map(|c| c.name.clone()).collect();
}
}
// Second: table-name match in the active from_scope.
@@ -1026,11 +1025,7 @@ fn resolve_qualifier_columns_in(
.iter()
.find(|c| c.name.eq_ignore_ascii_case(&binding.table))
{
return cte
.columns
.iter()
.filter_map(|c| c.name.clone())
.collect();
return cte.columns.iter().filter_map(|c| c.name.clone()).collect();
}
}
// Third: direct cte_bindings match (cte_alias.|).
@@ -1038,11 +1033,7 @@ fn resolve_qualifier_columns_in(
.iter()
.find(|c| c.name.eq_ignore_ascii_case(qualifier))
{
return cte
.columns
.iter()
.filter_map(|c| c.name.clone())
.collect();
return cte.columns.iter().filter_map(|c| c.name.clone()).collect();
}
// Fourth: a bare table name from the schema cache — DSL
// paths reach this for `from <Table>.<col>` shapes where
@@ -1287,7 +1278,13 @@ pub fn invalid_ident_at_cursor_in_mode(
// column. So `select Agx` warns at typing time again, while
// `select sum` does not.
let has_sql_expr_slot = expected.iter().any(|e| {
matches!(e, Expectation::Ident { role: "sql_expr_ident", .. })
matches!(
e,
Expectation::Ident {
role: "sql_expr_ident",
..
}
)
});
if has_sql_expr_slot && crate::dsl::sql_functions::is_known_function_prefix(partial) {
return None;
@@ -1318,9 +1315,15 @@ pub fn invalid_ident_at_cursor_in_mode(
// schema-column check below would never see it. A partial that
// prefix-matches a known generator is an in-progress name; anything
// else is an unknown generator → flag it `[ERR]` while typing.
let has_generator_slot = expected
.iter()
.any(|e| matches!(e, Expectation::Ident { source: IdentSource::Generators, .. }));
let has_generator_slot = expected.iter().any(|e| {
matches!(
e,
Expectation::Ident {
source: IdentSource::Generators,
..
}
)
});
if has_generator_slot {
if crate::seed::is_known_generator_prefix(partial) {
return None;
@@ -1335,9 +1338,7 @@ pub fn invalid_ident_at_cursor_in_mode(
let sources: Vec<IdentSource> = expected
.iter()
.filter_map(|e| match e {
Expectation::Ident { source, .. } if source.completes_from_schema() => {
Some(*source)
}
Expectation::Ident { source, .. } if source.completes_from_schema() => Some(*source),
_ => None,
})
.collect();
@@ -1412,13 +1413,15 @@ mod tests {
use pretty_assertions::assert_eq;
fn cands(input: &str, cursor: usize) -> Vec<String> {
candidates_at_cursor(input, cursor, &SchemaCache::default())
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect())
candidates_at_cursor(input, cursor, &SchemaCache::default()).map_or_else(Vec::new, |c| {
c.candidates.into_iter().map(|c| c.text).collect()
})
}
fn cands_with(input: &str, cursor: usize, cache: &SchemaCache) -> Vec<String> {
candidates_at_cursor(input, cursor, cache)
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect())
candidates_at_cursor(input, cursor, cache).map_or_else(Vec::new, |c| {
c.candidates.into_iter().map(|c| c.text).collect()
})
}
/// Simple-mode completion candidates — the DSL surface
@@ -1429,7 +1432,9 @@ mod tests {
/// Advanced mode surfaces the SQL grammar's completions instead.
fn cands_simple(input: &str, cursor: usize) -> Vec<String> {
candidates_at_cursor_in_mode(input, cursor, &SchemaCache::default(), Mode::Simple)
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect())
.map_or_else(Vec::new, |c| {
c.candidates.into_iter().map(|c| c.text).collect()
})
}
fn cand_kinds_with(
@@ -1438,10 +1443,7 @@ mod tests {
cache: &SchemaCache,
) -> Vec<(String, CandidateKind)> {
candidates_at_cursor(input, cursor, cache).map_or_else(Vec::new, |c| {
c.candidates
.into_iter()
.map(|c| (c.text, c.kind))
.collect()
c.candidates.into_iter().map(|c| (c.text, c.kind)).collect()
})
}
@@ -1503,12 +1505,21 @@ mod tests {
// Simple-only (column, relationship, constraint).
let cs = cands("drop ", 5);
for kw in ["table", "index", "column", "relationship", "constraint"] {
assert!(cs.contains(&kw.to_string()), "`drop ` should offer `{kw}`; got {cs:?}");
assert!(
cs.contains(&kw.to_string()),
"`drop ` should offer `{kw}`; got {cs:?}"
);
}
// Both-mode continuations block before the simple-only ones.
let pos = |k: &str| cs.iter().position(|c| c == k).unwrap();
assert!(pos("table") < pos("column"), "Both block precedes Simple block: {cs:?}");
assert!(pos("index") < pos("relationship"), "Both block precedes Simple block: {cs:?}");
assert!(
pos("table") < pos("column"),
"Both block precedes Simple block: {cs:?}"
);
assert!(
pos("index") < pos("relationship"),
"Both block precedes Simple block: {cs:?}"
);
}
#[test]
@@ -1631,8 +1642,14 @@ mod tests {
let c = candidates_at_cursor(input, input.len(), &SchemaCache::default())
.expect("a `-` at a flag position offers candidates");
let texts: Vec<&str> = c.candidates.iter().map(|x| x.text.as_str()).collect();
assert!(texts.contains(&"--create-fk"), "should offer --create-fk: {texts:?}");
assert!(!texts.contains(&"on"), "must NOT offer `on` after a dash: {texts:?}");
assert!(
texts.contains(&"--create-fk"),
"should offer --create-fk: {texts:?}"
);
assert!(
!texts.contains(&"on"),
"must NOT offer `on` after a dash: {texts:?}"
);
assert_eq!(
c.replaced_range,
(input.len() - 1, input.len()),
@@ -1643,13 +1660,9 @@ mod tests {
#[test]
fn double_dash_replaces_both_dashes_on_accept() {
let input = "delete from T --";
let c = candidates_at_cursor_in_mode(
input,
input.len(),
&SchemaCache::default(),
Mode::Simple,
)
.expect("`--` offers the flag");
let c =
candidates_at_cursor_in_mode(input, input.len(), &SchemaCache::default(), Mode::Simple)
.expect("`--` offers the flag");
assert!(c.candidates.iter().any(|x| x.text == "--all-rows"));
assert_eq!(
c.replaced_range,
@@ -1668,9 +1681,7 @@ mod tests {
s.tables.push("T".into());
s.columns.push("x".into());
let input = "show data T where x = -5";
if let Some(c) =
candidates_at_cursor_in_mode(input, input.len(), &s, Mode::Simple)
{
if let Some(c) = candidates_at_cursor_in_mode(input, input.len(), &s, Mode::Simple) {
assert!(
!c.candidates.iter().any(|x| x.text.starts_with("--")),
"no flags at a value position: {:?}",
@@ -1715,8 +1726,8 @@ mod tests {
// App-lifecycle commands now appear alongside DSL
// commands in the entry-keyword set.
for expected in &[
"quit", "help", "rebuild", "save", "new", "load", "export",
"import", "mode", "messages", "undo", "redo", "copy",
"quit", "help", "rebuild", "save", "new", "load", "export", "import", "mode",
"messages", "undo", "redo", "copy",
] {
assert!(
cs.contains(&expected.to_string()),
@@ -1943,7 +1954,10 @@ mod tests {
// opening a sub-shape) becomes a Tab candidate.
let input = "add column to table T";
let cs = cands(input, input.len());
assert!(cs.is_empty(), "trailing-content punct should not surface: {cs:?}");
assert!(
cs.is_empty(),
"trailing-content punct should not surface: {cs:?}"
);
}
#[test]
@@ -1957,10 +1971,7 @@ mod tests {
assert!(cs.contains(&"(".to_string()), "got {cs:?}");
}
fn schema_with_table(
table: &str,
columns: &[(&str, crate::dsl::types::Type)],
) -> SchemaCache {
fn schema_with_table(table: &str, columns: &[(&str, crate::dsl::types::Type)]) -> SchemaCache {
let mut cache = SchemaCache::default();
cache.tables.push(table.to_string());
let cols: Vec<TableColumn> = columns
@@ -2002,8 +2013,14 @@ mod tests {
let cache = two_table_alias_cache();
let input = "select a.id from a o join b z on o.id = z.id group by ";
let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"o".to_string()), "alias `o` must be offered; got {cs:?}");
assert!(cs.contains(&"z".to_string()), "alias `z` must be offered; got {cs:?}");
assert!(
cs.contains(&"o".to_string()),
"alias `o` must be offered; got {cs:?}"
);
assert!(
cs.contains(&"z".to_string()),
"alias `z` must be offered; got {cs:?}"
);
}
#[test]
@@ -2015,8 +2032,14 @@ mod tests {
let cache = two_table_alias_cache();
let input = "select a.id from a aa join b ab on aa.id = ab.id group by a";
let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"aa".to_string()), "alias `aa` must be offered; got {cs:?}");
assert!(cs.contains(&"ab".to_string()), "alias `ab` must be offered; got {cs:?}");
assert!(
cs.contains(&"aa".to_string()),
"alias `aa` must be offered; got {cs:?}"
);
assert!(
cs.contains(&"ab".to_string()),
"alias `ab` must be offered; got {cs:?}"
);
// Exact-alias partial: the alias source steps aside.
let exact = "select aa.id from a aa join b ab on aa.id = ab.id group by aa";
@@ -2046,19 +2069,20 @@ mod tests {
// SchemaCache.columns has columns from many tables, but
// at `update Customers set ` only Customers' columns
// should appear.
let mut cache = schema_with_table(
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
// Pretend the global flat list has columns from a second
// table that aren't in Customers.
cache.columns.push("OrderTotal".to_string());
cache.columns.push("Stock".to_string());
cache
.table_columns
.insert("Orders".to_string(), vec![
TableColumn { name: "OrderTotal".to_string(), user_type: Type::Real, not_null: false, has_default: false },
]);
cache.table_columns.insert(
"Orders".to_string(),
vec![TableColumn {
name: "OrderTotal".to_string(),
user_type: Type::Real,
not_null: false,
has_default: false,
}],
);
cache.tables.push("Orders".to_string());
let cs = cands_with("update Customers set ", 21, &cache);
// Customers's columns should appear:
@@ -2079,10 +2103,7 @@ mod tests {
// *before* ORDER BY (the FROM's JOIN options, WHERE /
// GROUP BY / HAVING, set-ops). Those used to shove the
// columns off-screen.
let cache = schema_with_table(
"Things",
&[("Name", Type::Text), ("Qty", Type::Int)],
);
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
let input = "select Name from Things order by ";
let cs = cands_with(input, input.len(), &cache);
// The columns the user wants are offered:
@@ -2090,8 +2111,19 @@ mod tests {
assert!(cs.contains(&"Qty".to_string()), "got {cs:?}");
// Preceding-clause keywords must not leak in:
for kw in [
"where", "group", "having", "join", "union", "intersect",
"except", "left", "right", "full", "cross", "inner", "as",
"where",
"group",
"having",
"join",
"union",
"intersect",
"except",
"left",
"right",
"full",
"cross",
"inner",
"as",
] {
assert!(
!cs.contains(&kw.to_string()),
@@ -2108,10 +2140,7 @@ mod tests {
// sort item the direction keywords surface as
// continuations (previously discarded at the Repeated
// boundary, so completion offered neither).
let cache = schema_with_table(
"Things",
&[("Name", Type::Text), ("Qty", Type::Int)],
);
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
let input = "select Name from Things order by Name ";
let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"asc".to_string()), "got {cs:?}");
@@ -2123,10 +2152,7 @@ mod tests {
use crate::dsl::types::Type;
// walk_repeated trailing-optional fix: after a complete
// projection item the `as` alias keyword surfaces.
let cache = schema_with_table(
"Things",
&[("Name", Type::Text), ("Qty", Type::Int)],
);
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
let input = "select Name ";
let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"as".to_string()), "got {cs:?}");
@@ -2153,16 +2179,13 @@ mod tests {
// ADR-0022 Amendment 2: at an expression position offering
// both column names and keywords, every column precedes
// every keyword so the names stay visible by default.
let cache = schema_with_table(
"Things",
&[("Name", Type::Text), ("Qty", Type::Int)],
);
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
let input = "select * from Things where ";
let cs = cands_with(input, input.len(), &cache);
let pos = |needle: &str| {
cs.iter().position(|c| c == needle).unwrap_or_else(|| {
panic!("{needle:?} not in candidates: {cs:?}")
})
cs.iter()
.position(|c| c == needle)
.unwrap_or_else(|| panic!("{needle:?} not in candidates: {cs:?}"))
};
// Both columns come before any expression-start keyword.
let last_ident = pos("Name").max(pos("Qty"));
@@ -2176,13 +2199,9 @@ mod tests {
#[test]
fn update_where_offers_only_current_table_columns() {
use crate::dsl::types::Type;
let mut cache = schema_with_table(
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
cache.columns.push("OrderTotal".to_string());
let cs =
cands_with("update Customers set Email='x' where ", 37, &cache);
let cs = cands_with("update Customers set Email='x' where ", 37, &cache);
assert!(cs.contains(&"id".to_string()), "got {cs:?}");
assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}");
@@ -2208,7 +2227,11 @@ mod tests {
use crate::dsl::types::Type;
let cache = schema_with_table(
"Customers",
&[("id", Type::Int), ("Email", Type::Text), ("Name", Type::Text)],
&[
("id", Type::Int),
("Email", Type::Text),
("Name", Type::Text),
],
);
let cs = cands_with("insert into Customers (", 23, &cache);
// The user is at Form A's column-list position. All
@@ -2222,10 +2245,7 @@ mod tests {
#[test]
fn insert_into_open_paren_does_not_offer_unrelated_columns() {
use crate::dsl::types::Type;
let mut cache = schema_with_table(
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
cache.columns.push("OrderTotal".to_string());
let cs = cands_with("insert into Customers (", 23, &cache);
assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}");
@@ -2239,13 +2259,9 @@ mod tests {
// table's columns. `OrderTotal` belongs to no table in
// this cache's `table_columns`, so it must not leak.
use crate::dsl::types::Type;
let mut cache = schema_with_table(
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
cache.columns.push("OrderTotal".to_string());
let cs =
cands_with("drop column from Customers: ", 28, &cache);
let cs = cands_with("drop column from Customers: ", 28, &cache);
assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
assert!(cs.contains(&"id".to_string()), "got {cs:?}");
assert!(
@@ -2271,8 +2287,8 @@ mod tests {
#[test]
fn cursor_mid_keyword_replaces_only_the_partial_prefix() {
let comp = candidates_at_cursor("cre", 3, &SchemaCache::default())
.expect("some completion");
let comp =
candidates_at_cursor("cre", 3, &SchemaCache::default()).expect("some completion");
assert_eq!(comp.replaced_range, (0, 3));
assert_eq!(comp.partial_prefix, "cre");
assert_eq!(comp.candidates.len(), 1);
@@ -2282,8 +2298,8 @@ mod tests {
#[test]
fn cursor_at_word_boundary_has_empty_partial_prefix() {
let comp = candidates_at_cursor("create ", 7, &SchemaCache::default())
.expect("some completion");
let comp =
candidates_at_cursor("create ", 7, &SchemaCache::default()).expect("some completion");
assert_eq!(comp.replaced_range, (7, 7));
assert_eq!(comp.partial_prefix, "");
}
@@ -2517,8 +2533,8 @@ mod tests {
// inside `Name`, and substituting any name there
// produces a complete command. No useful "next after
// name" hint.
let t = typing_name_at_cursor("add column to table T: Name (text)", 27)
.expect("should fire");
let t =
typing_name_at_cursor("add column to table T: Name (text)", 27).expect("should fire");
assert_eq!(t.next_after_name, None);
}
@@ -2534,8 +2550,8 @@ mod tests {
assert!(invalid_ident_at_cursor("show data Cust", 14, &cache).is_none());
// `show data Cust` plus a typo: `show data Custp`. No
// table starts with "Custp" → invalid.
let invalid = invalid_ident_at_cursor("show data Custp", 15, &cache)
.expect("should be invalid");
let invalid =
invalid_ident_at_cursor("show data Custp", 15, &cache).expect("should be invalid");
assert_eq!(invalid.range, (10, 15));
assert_eq!(invalid.found, "Custp");
assert_eq!(invalid.source, IdentSource::Tables);
@@ -2600,7 +2616,11 @@ mod tests {
!cs.iter().any(|c| c == "Existing" || c == "AlsoExisting"),
"NewName slot must not surface schema candidates; got {cs:?}"
);
assert_eq!(cs, vec!["if".to_string()], "only the advanced IF NOT EXISTS keyword");
assert_eq!(
cs,
vec!["if".to_string()],
"only the advanced IF NOT EXISTS keyword"
);
}
fn keyword_cand(text: &str) -> Candidate {
@@ -2791,8 +2811,10 @@ mod tests {
let cands = candidates_at_cursor(input, input.len(), &cache)
.expect("some completion")
.candidates;
let count_entries: Vec<_> =
cands.iter().filter(|c| c.text.eq_ignore_ascii_case("count")).collect();
let count_entries: Vec<_> = cands
.iter()
.filter(|c| c.text.eq_ignore_ascii_case("count"))
.collect();
assert_eq!(
count_entries.len(),
1,
@@ -2805,7 +2827,9 @@ mod tests {
);
// A non-colliding function at the same slot is unaffected.
assert!(
cands.iter().any(|c| c.text == "coalesce" && c.kind == CandidateKind::Function),
cands
.iter()
.any(|c| c.text == "coalesce" && c.kind == CandidateKind::Function),
"non-colliding functions still surface; got {cands:?}",
);
}
@@ -2875,8 +2899,10 @@ mod tests {
let mut s = SchemaCache::default();
s.tables.push("OrderLines".into());
s.columns.push("count".into());
s.table_columns
.insert("OrderLines".into(), vec![TableColumn::new("count", Type::Int)]);
s.table_columns.insert(
"OrderLines".into(),
vec![TableColumn::new("count", Type::Int)],
);
let input = "select sum(ol.count) from OrderLines ol";
let cursor = input.find("ol.count").unwrap() + 2; // right after `ol`
assert!(
@@ -2938,15 +2964,35 @@ mod tests {
s.table_columns.insert(
"a".to_string(),
vec![
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false },
TableColumn { name: "name".to_string(), user_type: Type::Text, not_null: false, has_default: false },
TableColumn {
name: "id".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
},
TableColumn {
name: "name".to_string(),
user_type: Type::Text,
not_null: false,
has_default: false,
},
],
);
s.table_columns.insert(
"b".to_string(),
vec![
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false },
TableColumn { name: "total".to_string(), user_type: Type::Real, not_null: false, has_default: false },
TableColumn {
name: "id".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
},
TableColumn {
name: "total".to_string(),
user_type: Type::Real,
not_null: false,
has_default: false,
},
],
);
s
@@ -3191,5 +3237,3 @@ mod tests {
assert!(candidates_at_cursor_with("create ", 7, &cache, empty_ranker).is_none());
}
}
+1140 -796
View File
File diff suppressed because it is too large Load Diff
+15 -11
View File
@@ -549,14 +549,16 @@ pub enum AppCommand {
/// word like `insert` / `create` / `show`, or `types`), the
/// focused detail for that command (or command group sharing
/// the entry word).
Help {
topic: Option<String>,
},
Help { topic: Option<String> },
/// Show a contextual tier-3 hint (H2 / ADR-0053). No argument:
/// when submitted, it expands on the most recent runtime error
/// (the buffer is empty post-submit). The live-input surface is
/// the F1 keybinding, handled in `App::handle_key`, not here.
Hint,
/// Print the application version (ADR-0054): the in-app twin of the
/// `--version` / `-V` CLI flag. Emits `CARGO_PKG_VERSION` — the same
/// single source of truth — into the output panel.
Version,
/// Rebuild `playground.db` from `project.yaml` + data/, with
/// confirmation modal.
Rebuild,
@@ -576,7 +578,10 @@ pub enum AppCommand {
/// Unpack a zip into a new project and switch to it.
/// `target` overrides the project name (default: taken from
/// the zip).
Import { path: String, target: Option<String> },
Import {
path: String,
target: Option<String>,
},
/// Switch the persistent input mode.
Mode { value: ModeValue },
/// Show or set the messages verbosity.
@@ -787,9 +792,7 @@ impl PartialEq for Operand {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Column { name: a, .. }, Self::Column { name: b, .. }) => a == b,
(Self::Literal { value: a, .. }, Self::Literal { value: b, .. }) => {
a == b
}
(Self::Literal { value: a, .. }, Self::Literal { value: b, .. }) => a == b,
_ => false,
}
}
@@ -813,7 +816,9 @@ pub enum CompareOp {
/// a single row in the metadata table.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RelationshipSelector {
Named { name: String },
Named {
name: String,
},
Endpoints {
parent_table: String,
parent_column: String,
@@ -1019,6 +1024,7 @@ impl Command {
AppCommand::Quit => "quit",
AppCommand::Help { .. } => "help",
AppCommand::Hint => "hint",
AppCommand::Version => "version",
AppCommand::Rebuild => "rebuild",
AppCommand::Save => "save",
AppCommand::SaveAs => "save as",
@@ -1151,9 +1157,7 @@ impl Command {
parent_column,
child_table,
child_column,
} => format!(
"from {parent_table}.{parent_column} to {child_table}.{child_column}"
),
} => format!("from {parent_table}.{parent_column} to {child_table}.{child_column}"),
},
// A constraint command's subject is the dotted
// `<table>.<column>` it acts on (ADR-0029 §2.2).
+52 -29
View File
@@ -9,8 +9,7 @@
use crate::dsl::command::{AppCommand, Command, CopyScope, MessagesValue, ModeValue};
use crate::dsl::grammar::{
CommandNode, HintMode, IdentSource, IdentValidator, Node, ValidationError,
Word,
CommandNode, HintMode, IdentSource, IdentValidator, Node, ValidationError, Word,
};
use crate::dsl::walker::outcome::{MatchedKind, MatchedPath};
@@ -60,19 +59,16 @@ const IMPORT_TARGET_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const IMPORT_TARGET: Node = Node::Hinted {
mode: HintMode::ForceProse("hint.ambient_typing_name"),
inner: &IMPORT_TARGET_IDENT,
};
const IMPORT_AS_TARGET: Node = Node::Seq(&[
Node::Word(Word::keyword("as")),
IMPORT_TARGET,
]);
const IMPORT_AS_TARGET: Node = Node::Seq(&[Node::Word(Word::keyword("as")), IMPORT_TARGET]);
const IMPORT_AS_TARGET_OPT: Node = Node::Optional(&IMPORT_AS_TARGET);
const IMPORT_PATH_AND_TARGET: Node = Node::Seq(&[Node::BarePath, IMPORT_AS_TARGET_OPT]);
@@ -101,9 +97,9 @@ const MODE_CHOICES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
const MODE_VALUE: Node = Node::Choice(MODE_CHOICES);
@@ -119,9 +115,9 @@ const MESSAGES_CHOICES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
const MESSAGES_VALUE: Node = Node::Choice(MESSAGES_CHOICES);
@@ -174,6 +170,10 @@ const fn build_rebuild(_path: &MatchedPath, _source: &str) -> Result<Command, Va
Ok(Command::App(AppCommand::Rebuild))
}
const fn build_version(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Version))
}
const fn build_undo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Undo))
}
@@ -267,7 +267,8 @@ pub static QUIT: CommandNode = CommandNode {
ast_builder: build_quit,
help_id: Some("app.quit"),
hint_ids: &["quit"],
usage_ids: &["parse.usage.quit"],};
usage_ids: &["parse.usage.quit"],
};
pub static HELP: CommandNode = CommandNode {
entry: Word::keyword("help"),
@@ -275,7 +276,8 @@ pub static HELP: CommandNode = CommandNode {
ast_builder: build_help,
help_id: Some("app.help"),
hint_ids: &["help"],
usage_ids: &["parse.usage.help"],};
usage_ids: &["parse.usage.help"],
};
pub static HINT: CommandNode = CommandNode {
entry: Word::keyword("hint"),
@@ -284,7 +286,8 @@ pub static HINT: CommandNode = CommandNode {
help_id: Some("app.hint"),
// hint_id assigned in Phase C with the tier-3 corpus (ADR-0053).
hint_ids: &["hint"],
usage_ids: &["parse.usage.hint"],};
usage_ids: &["parse.usage.hint"],
};
pub static REBUILD: CommandNode = CommandNode {
entry: Word::keyword("rebuild"),
@@ -292,7 +295,17 @@ pub static REBUILD: CommandNode = CommandNode {
ast_builder: build_rebuild,
help_id: Some("app.rebuild"),
hint_ids: &["rebuild"],
usage_ids: &["parse.usage.rebuild"],};
usage_ids: &["parse.usage.rebuild"],
};
pub static VERSION: CommandNode = CommandNode {
entry: Word::keyword("version"),
shape: EMPTY_SEQ,
ast_builder: build_version,
help_id: Some("app.version"),
hint_ids: &["version"],
usage_ids: &["parse.usage.version"],
};
pub static SAVE: CommandNode = CommandNode {
entry: Word::keyword("save"),
@@ -300,7 +313,8 @@ pub static SAVE: CommandNode = CommandNode {
ast_builder: build_save,
help_id: Some("app.save"),
hint_ids: &["save"],
usage_ids: &["parse.usage.save"],};
usage_ids: &["parse.usage.save"],
};
pub static NEW: CommandNode = CommandNode {
entry: Word::keyword("new"),
@@ -308,7 +322,8 @@ pub static NEW: CommandNode = CommandNode {
ast_builder: build_new,
help_id: Some("app.new"),
hint_ids: &["new"],
usage_ids: &["parse.usage.new"],};
usage_ids: &["parse.usage.new"],
};
pub static LOAD: CommandNode = CommandNode {
entry: Word::keyword("load"),
@@ -316,7 +331,8 @@ pub static LOAD: CommandNode = CommandNode {
ast_builder: build_load,
help_id: Some("app.load"),
hint_ids: &["load"],
usage_ids: &["parse.usage.load"],};
usage_ids: &["parse.usage.load"],
};
pub static EXPORT: CommandNode = CommandNode {
entry: Word::keyword("export"),
@@ -324,7 +340,8 @@ pub static EXPORT: CommandNode = CommandNode {
ast_builder: build_export,
help_id: Some("app.export"),
hint_ids: &["export"],
usage_ids: &["parse.usage.export"],};
usage_ids: &["parse.usage.export"],
};
pub static IMPORT: CommandNode = CommandNode {
entry: Word::keyword("import"),
@@ -332,7 +349,8 @@ pub static IMPORT: CommandNode = CommandNode {
ast_builder: build_import,
help_id: Some("app.import"),
hint_ids: &["import"],
usage_ids: &["parse.usage.import"],};
usage_ids: &["parse.usage.import"],
};
pub static MODE: CommandNode = CommandNode {
entry: Word::keyword("mode"),
@@ -340,7 +358,8 @@ pub static MODE: CommandNode = CommandNode {
ast_builder: build_mode,
help_id: Some("app.mode"),
hint_ids: &["mode"],
usage_ids: &["parse.usage.mode"],};
usage_ids: &["parse.usage.mode"],
};
pub static MESSAGES: CommandNode = CommandNode {
entry: Word::keyword("messages"),
@@ -348,7 +367,8 @@ pub static MESSAGES: CommandNode = CommandNode {
ast_builder: build_messages,
help_id: Some("app.messages"),
hint_ids: &["messages"],
usage_ids: &["parse.usage.messages"],};
usage_ids: &["parse.usage.messages"],
};
pub static UNDO: CommandNode = CommandNode {
entry: Word::keyword("undo"),
@@ -356,7 +376,8 @@ pub static UNDO: CommandNode = CommandNode {
ast_builder: build_undo,
help_id: Some("app.undo"),
hint_ids: &["undo"],
usage_ids: &["parse.usage.undo"],};
usage_ids: &["parse.usage.undo"],
};
pub static REDO: CommandNode = CommandNode {
entry: Word::keyword("redo"),
@@ -364,7 +385,8 @@ pub static REDO: CommandNode = CommandNode {
ast_builder: build_redo,
help_id: Some("app.redo"),
hint_ids: &["redo"],
usage_ids: &["parse.usage.redo"],};
usage_ids: &["parse.usage.redo"],
};
pub static COPY: CommandNode = CommandNode {
entry: Word::keyword("copy"),
@@ -372,4 +394,5 @@ pub static COPY: CommandNode = CommandNode {
ast_builder: build_copy,
help_id: Some("app.copy"),
hint_ids: &["copy"],
usage_ids: &["parse.usage.copy"],};
usage_ids: &["parse.usage.copy"],
};
+101 -78
View File
@@ -24,19 +24,17 @@
//! later swap that capture for the same typed slots used here, adding
//! live hints/highlighting.
use crate::dsl::command::{
Command, Expr, RowFilter, SeedOverride, SeedOverrideKind, ShowListKind,
};
use crate::dsl::command::{Command, Expr, RowFilter, SeedOverride, SeedOverrideKind, ShowListKind};
use crate::dsl::grammar::{
CommandNode, IdentSource, Node, NumberValidator, ValidationError, Word, expr,
shared::{
FALLBACK_VALUE_LIST, column_value_list, count_tuple_values,
current_column_value, insert_target_columns,
FALLBACK_VALUE_LIST, column_value_list, count_tuple_values, current_column_value,
insert_target_columns,
},
sql_delete, sql_insert, sql_select, sql_update,
};
use crate::dsl::walker::context::WalkContext;
use crate::dsl::value::Value;
use crate::dsl::walker::context::WalkContext;
use crate::dsl::walker::outcome::{MatchedItem, MatchedKind, MatchedPath};
// =================================================================
@@ -56,10 +54,10 @@ const TABLE_NAME_EXISTING: Node = Node::Ident {
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
/// Table-name slot variant that populates
@@ -75,10 +73,10 @@ const TABLE_NAME_INSERT: Node = Node::Ident {
highlight_override: None,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
// =================================================================
@@ -95,10 +93,7 @@ const SHOW_DATA_NODES: &[Node] = &[
];
const SHOW_DATA: Node = Node::Seq(SHOW_DATA_NODES);
const SHOW_TABLE_NODES: &[Node] = &[
Node::Word(Word::keyword("table")),
TABLE_NAME_EXISTING,
];
const SHOW_TABLE_NODES: &[Node] = &[Node::Word(Word::keyword("table")), TABLE_NAME_EXISTING];
const SHOW_TABLE: Node = Node::Seq(SHOW_TABLE_NODES);
// `show tables` / `show relationships` / `show indexes` — the
@@ -144,8 +139,7 @@ const SHOW_INDEX_NAME: Node = Node::Ident {
writes_cte_name: false,
writes_projection_alias: false,
};
const SHOW_INDEX_NODES: &[Node] =
&[Node::Word(Word::keyword("index")), SHOW_INDEX_NAME];
const SHOW_INDEX_NODES: &[Node] = &[Node::Word(Word::keyword("index")), SHOW_INDEX_NAME];
const SHOW_INDEX: Node = Node::Seq(SHOW_INDEX_NODES);
const SHOW_CHOICES: &[Node] = &[
@@ -192,9 +186,9 @@ static FORM_A_COLUMN: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: true,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static INSERT_COMMA: Node = Node::Punct(',');
@@ -224,8 +218,7 @@ fn insert_first_paren(ctx: &WalkContext, source: &str, pos: usize) -> Node {
/// or an identifier-shaped token (a column name) returns false.
fn first_paren_item_is_value_literal(source: &str, pos: usize) -> bool {
use crate::dsl::walker::lex_helpers::{
consume_ident, consume_number_literal, consume_string_literal,
skip_whitespace,
consume_ident, consume_number_literal, consume_string_literal, skip_whitespace,
};
let p = skip_whitespace(source, pos);
if p >= source.len() {
@@ -281,7 +274,11 @@ fn dsl_insert_value_list(ctx: &WalkContext, source: &str, pos: usize) -> Node {
return FALLBACK_VALUE_LIST;
};
let (count, closed) = count_tuple_values(source, pos);
let arity_ok = if closed { count == cols.len() } else { count <= cols.len() };
let arity_ok = if closed {
count == cols.len()
} else {
count <= cols.len()
};
if arity_ok {
Node::DynamicSubgrammar(column_value_list)
} else {
@@ -320,8 +317,7 @@ const INSERT_VALUES_KEYWORD_FIRST_NODES: &[Node] = &[
];
const INSERT_VALUES_KEYWORD_FIRST: Node = Node::Seq(INSERT_VALUES_KEYWORD_FIRST_NODES);
const INSERT_AFTER_TABLE_CHOICES: &[Node] =
&[INSERT_VALUES_KEYWORD_FIRST, INSERT_PAREN_FIRST];
const INSERT_AFTER_TABLE_CHOICES: &[Node] = &[INSERT_VALUES_KEYWORD_FIRST, INSERT_PAREN_FIRST];
const INSERT_AFTER_TABLE: Node = Node::Choice(INSERT_AFTER_TABLE_CHOICES);
const INSERT_NODES: &[Node] = &[
@@ -349,10 +345,10 @@ const TABLE_NAME_WRITES: Node = Node::Ident {
highlight_override: None,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
/// Column-name slot in `set col = …` — resolves the column's
@@ -366,9 +362,9 @@ const SET_COLUMN: Node = Node::Ident {
writes_table: false,
writes_column: true,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
/// Value slot resolved at walk time from
@@ -376,11 +372,7 @@ writes_projection_alias: false,
/// value-literal choice when no current_column is bound.
const PER_COLUMN_VALUE: Node = Node::DynamicSubgrammar(current_column_value);
const UPDATE_ASSIGNMENT_NODES: &[Node] = &[
SET_COLUMN,
Node::Punct('='),
PER_COLUMN_VALUE,
];
const UPDATE_ASSIGNMENT_NODES: &[Node] = &[SET_COLUMN, Node::Punct('='), PER_COLUMN_VALUE];
const UPDATE_ASSIGNMENT: Node = Node::Seq(UPDATE_ASSIGNMENT_NODES);
const UPDATE_ASSIGNMENTS: Node = Node::Repeated {
inner: &UPDATE_ASSIGNMENT,
@@ -568,8 +560,7 @@ const SEED_OVERRIDES: Node = Node::Repeated {
separator: Some(&Node::Punct(',')),
min: 1,
};
const SEED_SET_CLAUSE_NODES: &[Node] =
&[Node::Word(Word::keyword("set")), SEED_OVERRIDES];
const SEED_SET_CLAUSE_NODES: &[Node] = &[Node::Word(Word::keyword("set")), SEED_OVERRIDES];
const SEED_SET_CLAUSE: Node = Node::Seq(SEED_SET_CLAUSE_NODES);
const SEED_NODES: &[Node] = &[
@@ -980,7 +971,10 @@ fn parse_seed_override_tail(
MatchedKind::Word("in") => {
*i += 1; // `in`
// `(`
if matches!(region.get(*i).map(|t| &t.kind), Some(MatchedKind::Punct('('))) {
if matches!(
region.get(*i).map(|t| &t.kind),
Some(MatchedKind::Punct('('))
) {
*i += 1;
}
let mut values = Vec::new();
@@ -1001,7 +995,10 @@ fn parse_seed_override_tail(
MatchedKind::Word("between") => {
*i += 1; // `between`
let low = seed_take_value(region, i, column)?;
if matches!(region.get(*i).map(|t| &t.kind), Some(MatchedKind::Word("and"))) {
if matches!(
region.get(*i).map(|t| &t.kind),
Some(MatchedKind::Word("and"))
) {
*i += 1;
}
let high = seed_take_value(region, i, column)?;
@@ -1011,7 +1008,15 @@ fn parse_seed_override_tail(
*i += 1; // `as`
let gen_item = region
.get(*i)
.filter(|t| matches!(t.kind, MatchedKind::Ident { role: "seed_generator", .. }))
.filter(|t| {
matches!(
t.kind,
MatchedKind::Ident {
role: "seed_generator",
..
}
)
})
.ok_or_else(|| seed_set_error(column))?;
*i += 1;
Ok(SeedOverrideKind::Generator(gen_item.text.clone()))
@@ -1085,7 +1090,15 @@ fn build_insert(path: &MatchedPath, _source: &str) -> Result<Command, Validation
let table_idx = path
.items
.iter()
.position(|i| matches!(&i.kind, MatchedKind::Ident { role: "table_name", .. }))
.position(|i| {
matches!(
&i.kind,
MatchedKind::Ident {
role: "table_name",
..
}
)
})
.ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "missing table".to_string())],
@@ -1141,7 +1154,10 @@ fn build_insert(path: &MatchedPath, _source: &str) -> Result<Command, Validation
if columns.is_empty() {
return Err(ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "expected column names in `insert into T (…)`".to_string())],
args: vec![(
"detail",
"expected column names in `insert into T (…)`".to_string(),
)],
});
}
// Find the `values` keyword and the next `(` — the values
@@ -1247,9 +1263,7 @@ fn build_update(path: &MatchedPath, _source: &str) -> Result<Command, Validation
})
}
fn collect_assignments(
path: &MatchedPath,
) -> Result<Vec<(String, Value)>, ValidationError> {
fn collect_assignments(path: &MatchedPath) -> Result<Vec<(String, Value)>, ValidationError> {
let mut out = Vec::new();
let mut iter = path.items.iter();
while let Some(item) = iter.next() {
@@ -1495,9 +1509,7 @@ fn build_sql_insert(path: &MatchedPath, source: &str) -> Result<Command, Validat
let row_source = path
.items
.iter()
.find(|item| {
matches!(item.kind, MatchedKind::Word("values" | "select" | "with"))
})
.find(|item| matches!(item.kind, MatchedKind::Word("values" | "select" | "with")))
.map(|item| {
let end = tail_start.unwrap_or(source.len());
source[item.span.0..end]
@@ -1805,7 +1817,8 @@ pub static SHOW: CommandNode = CommandNode {
"parse.usage.show_indexes",
"parse.usage.show_relationship",
"parse.usage.show_index",
],};
],
};
pub static SEED: CommandNode = CommandNode {
entry: Word::keyword("seed"),
@@ -1823,7 +1836,8 @@ pub static INSERT: CommandNode = CommandNode {
help_id: Some("data.insert"),
// ADR-0053 Phase-B exemplar.
hint_ids: &["insert"],
usage_ids: &["parse.usage.insert"],};
usage_ids: &["parse.usage.insert"],
};
pub static UPDATE: CommandNode = CommandNode {
entry: Word::keyword("update"),
@@ -1831,7 +1845,8 @@ pub static UPDATE: CommandNode = CommandNode {
ast_builder: build_update,
help_id: Some("data.update"),
hint_ids: &["update"],
usage_ids: &["parse.usage.update"],};
usage_ids: &["parse.usage.update"],
};
pub static DELETE: CommandNode = CommandNode {
entry: Word::keyword("delete"),
@@ -1839,7 +1854,8 @@ pub static DELETE: CommandNode = CommandNode {
ast_builder: build_delete,
help_id: Some("data.delete"),
hint_ids: &["delete"],
usage_ids: &["parse.usage.delete"],};
usage_ids: &["parse.usage.delete"],
};
pub static REPLAY: CommandNode = CommandNode {
entry: Word::keyword("replay"),
@@ -1847,7 +1863,8 @@ pub static REPLAY: CommandNode = CommandNode {
ast_builder: build_replay,
help_id: Some("data.replay"),
hint_ids: &["replay"],
usage_ids: &["parse.usage.replay"],};
usage_ids: &["parse.usage.replay"],
};
pub static EXPLAIN: CommandNode = CommandNode {
entry: Word::keyword("explain"),
@@ -1855,7 +1872,8 @@ pub static EXPLAIN: CommandNode = CommandNode {
ast_builder: build_explain,
help_id: Some("data.explain"),
hint_ids: &["explain"],
usage_ids: &["parse.usage.explain"],};
usage_ids: &["parse.usage.explain"],
};
/// `explain` over advanced-mode SQL (ADR-0039).
///
@@ -1875,7 +1893,8 @@ pub static EXPLAIN_SQL: CommandNode = CommandNode {
// precedent; otherwise `note_help` would print `explain` twice.
help_id: None,
hint_ids: &["explain_sql"],
usage_ids: &[],};
usage_ids: &[],
};
/// SQL `SELECT` (ADR-0030 §6, ADR-0031, ADR-0032).
///
@@ -1891,7 +1910,8 @@ pub static SELECT: CommandNode = CommandNode {
ast_builder: build_select,
help_id: None,
hint_ids: &["select"],
usage_ids: &["parse.usage.select"],};
usage_ids: &["parse.usage.select"],
};
/// `WITH …` top-level statement (ADR-0032 §4 / sub-phase 2c).
///
@@ -1906,7 +1926,8 @@ pub static WITH: CommandNode = CommandNode {
ast_builder: build_select,
help_id: None,
hint_ids: &["with"],
usage_ids: &["parse.usage.with"],};
usage_ids: &["parse.usage.with"],
};
/// SQL `INSERT` — the `Advanced`-category node of the shared
/// `insert` entry word (ADR-0033 §2, Amendment 1, sub-phase 3j).
@@ -1993,7 +2014,11 @@ mod explain_tests {
#[test]
fn explain_show_data_carries_where_and_limit_through() {
match explain_inner("explain show data Customers where id = 1 limit 5") {
Command::ShowData { name, filter, limit } => {
Command::ShowData {
name,
filter,
limit,
} => {
assert_eq!(name, "Customers");
assert!(filter.is_some(), "where clause should survive");
assert_eq!(limit, Some(5));
@@ -2052,9 +2077,7 @@ mod explain_tests {
/// Advanced-mode counterpart of `explain_inner`.
fn explain_inner_adv(input: &str) -> Command {
match parse_command_in_mode(input, Mode::Advanced)
.expect("advanced explain should parse")
{
match parse_command_in_mode(input, Mode::Advanced).expect("advanced explain should parse") {
Command::Explain { query } => *query,
other => panic!("expected Command::Explain, got {other:?}"),
}
@@ -2085,7 +2108,9 @@ mod explain_tests {
#[test]
fn explain_sql_insert_wraps_a_sql_insert() {
match explain_inner_adv("explain insert into Customers values (1, 'Bo')") {
Command::SqlInsert { sql, target_table, .. } => {
Command::SqlInsert {
sql, target_table, ..
} => {
assert_eq!(target_table, "Customers");
assert_eq!(sql, "insert into Customers values (1, 'Bo')");
}
@@ -2096,7 +2121,9 @@ mod explain_tests {
#[test]
fn explain_sql_update_wraps_a_sql_update_with_clean_sql() {
match explain_inner_adv("explain update Customers set Name = 'Bo' where id = 1") {
Command::SqlUpdate { sql, target_table, .. } => {
Command::SqlUpdate {
sql, target_table, ..
} => {
assert_eq!(target_table, "Customers");
assert_eq!(sql, "update Customers set Name = 'Bo' where id = 1");
}
@@ -2107,7 +2134,9 @@ mod explain_tests {
#[test]
fn explain_sql_delete_wraps_a_sql_delete() {
match explain_inner_adv("explain delete from Customers where id = 1") {
Command::SqlDelete { sql, target_table, .. } => {
Command::SqlDelete {
sql, target_table, ..
} => {
assert_eq!(target_table, "Customers");
assert_eq!(sql, "delete from Customers where id = 1");
}
@@ -2148,11 +2177,7 @@ mod explain_tests {
fn explain_does_not_cover_ddl() {
// EXPLAIN QUERY PLAN applies to DML/queries only (ADR-0039
// out of scope); there is no SQL DDL branch under explain.
assert!(parse_command_in_mode(
"explain create table T (id int)",
Mode::Advanced,
)
.is_err());
assert!(parse_command_in_mode("explain create table T (id int)", Mode::Advanced,).is_err());
}
#[test]
@@ -2165,9 +2190,8 @@ mod explain_tests {
use crate::completion::candidates_at_cursor_in_mode;
let schema = crate::completion::SchemaCache::default();
let input = "explain ";
let completion =
candidates_at_cursor_in_mode(input, input.len(), &schema, Mode::Advanced)
.expect("explain offers candidates");
let completion = candidates_at_cursor_in_mode(input, input.len(), &schema, Mode::Advanced)
.expect("explain offers candidates");
let names: Vec<&str> = completion
.candidates
.iter()
@@ -2178,4 +2202,3 @@ mod explain_tests {
}
}
}
+246 -166
View File
@@ -16,11 +16,11 @@ use crate::dsl::command::{
AlterTableAction, ChangeColumnMode, ColumnSpec, Command, Constraint, ConstraintKind, Expr,
IndexSelector, RelationshipSelector, SqlForeignKey, TableConstraint,
};
use crate::dsl::value::Value;
use crate::dsl::grammar::{
CommandNode, HighlightClass, HintMode, IdentSource, Node, ValidationError, Word,
shared::{REFERENTIAL_CLAUSES, TYPE_SLOT, TYPE_VALIDATOR},
};
use crate::dsl::value::Value;
/// `HintMode` annotation shared by every `NewName` ident slot:
/// the user is inventing a name, so the hint panel forces the
@@ -39,12 +39,12 @@ const TABLE_NAME_NEW_IDENT: Node = Node::Ident {
role: "table_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const TABLE_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -63,12 +63,12 @@ const TABLE_NAME_EXISTING: Node = Node::Ident {
role: "table_name",
validator: None,
highlight_override: None,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const COLUMN_NAME: Node = Node::Ident {
@@ -76,12 +76,12 @@ const COLUMN_NAME: Node = Node::Ident {
role: "column_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const COLUMN_NAME_NEW_IDENT: Node = Node::Ident {
@@ -89,12 +89,12 @@ const COLUMN_NAME_NEW_IDENT: Node = Node::Ident {
role: "column_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const COLUMN_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -106,12 +106,12 @@ const RELATIONSHIP_NAME: Node = Node::Ident {
role: "relationship_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident {
@@ -119,12 +119,12 @@ const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident {
role: "relationship_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const RELATIONSHIP_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -139,9 +139,9 @@ const INDEX_NAME_EXISTING: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const INDEX_NAME_NEW_IDENT: Node = Node::Ident {
@@ -152,9 +152,9 @@ const INDEX_NAME_NEW_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const INDEX_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -181,10 +181,7 @@ const TABLE_OPT: Node = Node::Optional(&Node::Word(Word::keyword("table")));
// drop_table — `drop table <T>`
// =================================================================
const DROP_TABLE_NODES: &[Node] = &[
Node::Word(Word::keyword("table")),
TABLE_NAME_EXISTING,
];
const DROP_TABLE_NODES: &[Node] = &[Node::Word(Word::keyword("table")), TABLE_NAME_EXISTING];
const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES);
// Advanced-mode SQL `DROP TABLE [IF EXISTS] <name> [;]` (ADR-0035 §4,
@@ -192,8 +189,10 @@ const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES);
// plus the optional `IF EXISTS` no-op-with-note. The leading concrete
// `table` keyword (not the Optional) keeps the element/dispatch
// matching honest.
static SQL_DROP_IF_EXISTS_NODES: &[Node] =
&[Node::Word(Word::keyword("if")), Node::Word(Word::keyword("exists"))];
static SQL_DROP_IF_EXISTS_NODES: &[Node] = &[
Node::Word(Word::keyword("if")),
Node::Word(Word::keyword("exists")),
];
const SQL_DROP_IF_EXISTS_OPT: Node = Node::Optional(&Node::Seq(SQL_DROP_IF_EXISTS_NODES));
static SQL_DROP_TABLE_SHAPE_NODES: &[Node] = &[
Node::Word(Word::keyword("table")),
@@ -257,9 +256,9 @@ const DR_PARENT_NODES: &[Node] = &[
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
Node::Punct('.'),
Node::Ident {
@@ -270,9 +269,9 @@ const DR_PARENT_NODES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
const DR_PARENT: Node = Node::Seq(DR_PARENT_NODES);
@@ -286,9 +285,9 @@ const DR_CHILD_NODES: &[Node] = &[
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
Node::Punct('.'),
Node::Ident {
@@ -299,9 +298,9 @@ const DR_CHILD_NODES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
const DR_CHILD: Node = Node::Seq(DR_CHILD_NODES);
@@ -317,10 +316,7 @@ const DR_ENDPOINTS: Node = Node::Seq(DR_ENDPOINTS_NODES);
const DR_SELECTOR_CHOICES: &[Node] = &[DR_ENDPOINTS, RELATIONSHIP_NAME];
const DR_SELECTOR: Node = Node::Choice(DR_SELECTOR_CHOICES);
const DROP_RELATIONSHIP_NODES: &[Node] = &[
Node::Word(Word::keyword("relationship")),
DR_SELECTOR,
];
const DROP_RELATIONSHIP_NODES: &[Node] = &[Node::Word(Word::keyword("relationship")), DR_SELECTOR];
const DROP_RELATIONSHIP: Node = Node::Seq(DROP_RELATIONSHIP_NODES);
// =================================================================
@@ -341,18 +337,20 @@ const DI_POSITIONAL: Node = Node::Seq(DI_POSITIONAL_NODES);
const DI_SELECTOR_CHOICES: &[Node] = &[DI_POSITIONAL, INDEX_NAME_EXISTING];
const DI_SELECTOR: Node = Node::Choice(DI_SELECTOR_CHOICES);
const DROP_INDEX_NODES: &[Node] = &[
Node::Word(Word::keyword("index")),
DI_SELECTOR,
];
const DROP_INDEX_NODES: &[Node] = &[Node::Word(Word::keyword("index")), DI_SELECTOR];
const DROP_INDEX: Node = Node::Seq(DROP_INDEX_NODES);
// =================================================================
// drop entry — `drop (table|column|relationship|index) ...`
// =================================================================
const DROP_CHOICES: &[Node] =
&[DROP_COLUMN, DROP_RELATIONSHIP, DROP_TABLE, DROP_INDEX, DROP_CONSTRAINT];
const DROP_CHOICES: &[Node] = &[
DROP_COLUMN,
DROP_RELATIONSHIP,
DROP_TABLE,
DROP_INDEX,
DROP_CONSTRAINT,
];
const DROP_SHAPE: Node = Node::Choice(DROP_CHOICES);
// =================================================================
@@ -450,8 +448,7 @@ const AR_CHILD_COL_LIST: Node = Node::Repeated {
separator: Some(&Node::Punct(',')),
min: 1,
};
const AR_CHILD_COLS_PAREN_NODES: &[Node] =
&[Node::Punct('('), AR_CHILD_COL_LIST, Node::Punct(')')];
const AR_CHILD_COLS_PAREN_NODES: &[Node] = &[Node::Punct('('), AR_CHILD_COL_LIST, Node::Punct(')')];
const AR_CHILD_COLS_PAREN: Node = Node::Seq(AR_CHILD_COLS_PAREN_NODES);
const AR_CHILD_COLS_CHOICES: &[Node] = &[AR_CHILD_COLS_PAREN, AR_CHILD_COL];
const AR_CHILD_COLS: Node = Node::Choice(AR_CHILD_COLS_CHOICES);
@@ -474,10 +471,7 @@ const AR_CHILD_NODES: &[Node] = &[
];
const AR_CHILD: Node = Node::Seq(AR_CHILD_NODES);
const AR_AS_NAME_NODES: &[Node] = &[
Node::Word(Word::keyword("as")),
RELATIONSHIP_NAME_NEW,
];
const AR_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), RELATIONSHIP_NAME_NEW];
const AR_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AR_AS_NAME_NODES));
const AR_CREATE_FK_OPT: Node = Node::Optional(&Node::Flag("create-fk"));
@@ -501,10 +495,7 @@ const ADD_RELATIONSHIP: Node = Node::Seq(ADD_RELATIONSHIP_NODES);
// add_index — `add index [as <name>] on <T> (<col>, …)`
// =================================================================
const AI_AS_NAME_NODES: &[Node] = &[
Node::Word(Word::keyword("as")),
INDEX_NAME_NEW,
];
const AI_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), INDEX_NAME_NEW];
const AI_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AI_AS_NAME_NODES));
const ADD_INDEX_NODES: &[Node] = &[
@@ -537,9 +528,9 @@ const NEW_COLUMN_NAME_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const NEW_COLUMN_NAME: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -563,10 +554,7 @@ const RENAME_COLUMN: Node = Node::Seq(RENAME_COLUMN_NODES);
// ( <type> ) [--force-conversion | --dont-convert]`
// =================================================================
const CHANGE_FLAG_CHOICES: &[Node] = &[
Node::Flag("force-conversion"),
Node::Flag("dont-convert"),
];
const CHANGE_FLAG_CHOICES: &[Node] = &[Node::Flag("force-conversion"), Node::Flag("dont-convert")];
const CHANGE_FLAG_OPT: Node = Node::Repeated {
inner: &Node::Choice(CHANGE_FLAG_CHOICES),
separator: None,
@@ -732,8 +720,7 @@ fn build_add(path: &MatchedPath, _source: &str) -> Result<Command, ValidationErr
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
})?;
let (not_null, unique, default, check) =
collect_column_constraints(path)?;
let (not_null, unique, default, check) = collect_column_constraints(path)?;
Ok(Command::AddColumn {
table: require_ident(path, "table_name")?,
column: require_ident(path, "column_name")?,
@@ -949,7 +936,10 @@ fn build_drop_constraint(path: &MatchedPath, _source: &str) -> Result<Command, V
} else {
return Err(ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "drop constraint needs a constraint kind".to_string())],
args: vec![(
"detail",
"drop constraint needs a constraint kind".to_string(),
)],
});
};
Ok(Command::DropConstraint {
@@ -981,7 +971,8 @@ pub static DROP: CommandNode = CommandNode {
"parse.usage.drop_relationship",
"parse.usage.drop_index",
"parse.usage.drop_constraint",
],};
],
};
pub static ADD: CommandNode = CommandNode {
entry: Word::keyword("add"),
@@ -1003,7 +994,8 @@ pub static ADD: CommandNode = CommandNode {
"parse.usage.add_relationship",
"parse.usage.add_index",
"parse.usage.add_constraint",
],};
],
};
pub static RENAME: CommandNode = CommandNode {
entry: Word::keyword("rename"),
@@ -1011,7 +1003,8 @@ pub static RENAME: CommandNode = CommandNode {
ast_builder: build_rename_column,
help_id: Some("ddl.rename"),
hint_ids: &["rename_column"],
usage_ids: &["parse.usage.rename_column"],};
usage_ids: &["parse.usage.rename_column"],
};
pub static CHANGE: CommandNode = CommandNode {
entry: Word::keyword("change"),
@@ -1019,7 +1012,8 @@ pub static CHANGE: CommandNode = CommandNode {
ast_builder: build_change_column,
help_id: Some("ddl.change"),
hint_ids: &["change_column"],
usage_ids: &["parse.usage.change_column"],};
usage_ids: &["parse.usage.change_column"],
};
// =================================================================
// create_table — `create table <Name> [with pk [<col>(<type>)[, ...]]]`
@@ -1034,9 +1028,9 @@ const COL_NAME_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const COL_NAME: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -1074,8 +1068,12 @@ const CHECK_CONSTRAINT_NODES: &[Node] = &[
];
const CHECK_CONSTRAINT: Node = Node::Seq(CHECK_CONSTRAINT_NODES);
const COLUMN_CONSTRAINT_CHOICES: &[Node] =
&[NOT_NULL_CONSTRAINT, UNIQUE_CONSTRAINT, DEFAULT_CONSTRAINT, CHECK_CONSTRAINT];
const COLUMN_CONSTRAINT_CHOICES: &[Node] = &[
NOT_NULL_CONSTRAINT,
UNIQUE_CONSTRAINT,
DEFAULT_CONSTRAINT,
CHECK_CONSTRAINT,
];
const COLUMN_CONSTRAINT: Node = Node::Choice(COLUMN_CONSTRAINT_CHOICES);
/// Zero-or-more constraints — the suffix after a column's
@@ -1114,8 +1112,7 @@ const DROP_CONSTRAINT_KIND: Node = Node::Choice(DROP_CONSTRAINT_KIND_CHOICES);
// `writes_table: true` on the table ident (via `TABLE_NAME_
// EXISTING`) narrows the `.<column>` slot's completion
// candidates to that table's columns.
const CONSTRAINT_TARGET_NODES: &[Node] =
&[TABLE_NAME_EXISTING, Node::Punct('.'), COLUMN_NAME];
const CONSTRAINT_TARGET_NODES: &[Node] = &[TABLE_NAME_EXISTING, Node::Punct('.'), COLUMN_NAME];
const CONSTRAINT_TARGET: Node = Node::Seq(CONSTRAINT_TARGET_NODES);
const ADD_CONSTRAINT_NODES: &[Node] = &[
@@ -1145,9 +1142,9 @@ const COL_SPEC_NODES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
Node::Punct(')'),
COLUMN_CONSTRAINT_SUFFIX,
@@ -1275,10 +1272,14 @@ fn build_create_table(path: &MatchedPath, _source: &str) -> Result<Command, Vali
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
match &item.kind {
MatchedKind::Ident { role: "col_name", .. } => {
MatchedKind::Ident {
role: "col_name", ..
} => {
pending_name = Some(item.text.clone());
}
MatchedKind::Ident { role: "col_type", .. } => {
MatchedKind::Ident {
role: "col_type", ..
} => {
let ty = item.text.parse::<Type>().map_err(|_| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
@@ -1380,7 +1381,8 @@ pub static CREATE: CommandNode = CommandNode {
ast_builder: build_create_table,
help_id: Some("ddl.create"),
hint_ids: &["create_table"],
usage_ids: &["parse.usage.create_table"],};
usage_ids: &["parse.usage.create_table"],
};
// =================================================================
// create_m2n — `create m:n relationship from <T1> to <T2> [as <name>]`
@@ -1506,11 +1508,15 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
while let Some(item) = items.next() {
match &item.kind {
// A column name stashes until its type finalises the spec.
MatchedKind::Ident { role: "col_name", .. } => {
MatchedKind::Ident {
role: "col_name", ..
} => {
pending_name = Some(item.text.clone());
}
// Single-word type — resolve through the SQL alias map.
MatchedKind::Ident { role: "col_type", .. } => {
MatchedKind::Ident {
role: "col_type", ..
} => {
let ty = Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
@@ -1533,7 +1539,9 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
column_open = true;
}
// A table-level `PRIMARY KEY (col, …)` column reference.
MatchedKind::Ident { role: "pk_column", .. } => {
MatchedKind::Ident {
role: "pk_column", ..
} => {
primary_key.push(item.text.clone());
}
// `not null` column constraint (only once a column exists;
@@ -1557,7 +1565,10 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
let mut cols: Vec<String> = Vec::new();
while let Some(it) = items.peek() {
match &it.kind {
MatchedKind::Ident { role: "unique_column", .. } => {
MatchedKind::Ident {
role: "unique_column",
..
} => {
cols.push(it.text.clone());
items.next();
}
@@ -1575,7 +1586,10 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
// column's flag (round-trips via the single-column
// path); composite (or a name not among the
// columns) becomes a constraint.
match columns.iter_mut().find(|c| cols.len() == 1 && c.name == cols[0]) {
match columns
.iter_mut()
.find(|c| cols.len() == 1 && c.name == cols[0])
{
Some(c) => c.unique = true,
None if !cols.is_empty() => unique_constraints.push(cols),
None => {}
@@ -1588,16 +1602,17 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
// the most recent column) or the table-level clause (whose
// `pk_column` idents follow and are collected above).
MatchedKind::Word("primary") => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("key"))
) {
items.next();
// Table-level `PRIMARY KEY (…)` is followed by `(`
// (then `pk_column` idents, collected above);
// column-level `PRIMARY KEY` is not, and marks the
// most-recent column.
let table_level = matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Punct('('))
);
let table_level =
matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('(')));
if !table_level && let Some(last) = columns.last() {
primary_key.push(last.name.clone());
}
@@ -1647,12 +1662,20 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
// Inline FK is single-column (the column it sits on);
// a compound FK uses the table-level form (ADR-0043 D4).
let child_column = columns.last().map_or_else(String::new, |c| c.name.clone());
foreign_keys.push(consume_fk_reference(&mut items, None, vec![child_column], true));
foreign_keys.push(consume_fk_reference(
&mut items,
None,
vec![child_column],
true,
));
}
// Table-level `[constraint <name>] foreign key (<col>)
// references <parent> [(<col>)] [on …]` (ADR-0035 §5, 4b).
MatchedKind::Word("foreign") => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("key"))
) {
items.next(); // `key`
}
// `( <child column> [, <child column>]* )` — a compound
@@ -1674,7 +1697,10 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
items.next();
}
// `references <parent> …`
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("references"))
) {
items.next();
}
let fk =
@@ -1859,13 +1885,19 @@ where
Some(MatchedKind::Word("cascade")) => ReferentialAction::Cascade,
Some(MatchedKind::Word("restrict")) => ReferentialAction::Restrict,
Some(MatchedKind::Word("set")) => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("null"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("null"))
) {
items.next();
}
ReferentialAction::SetNull
}
Some(MatchedKind::Word("no")) => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("action"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("action"))
) {
items.next();
}
ReferentialAction::NoAction
@@ -1933,11 +1965,12 @@ pub static SQL_DROP_INDEX: CommandNode = CommandNode {
// concrete keyword (`unique index` | `index`) — the trap-safe form (the
// §3 rule forbids a leading *Optional*, not a leading `Choice`). The
// builder reads `unique` presence via `contains_word("unique")`.
static SQL_CI_UNIQUE_INDEX_NODES: &[Node] =
&[Node::Word(Word::keyword("unique")), Node::Word(Word::keyword("index"))];
static SQL_CI_UNIQUE_INDEX_NODES: &[Node] = &[
Node::Word(Word::keyword("unique")),
Node::Word(Word::keyword("index")),
];
const SQL_CI_UNIQUE_INDEX: Node = Node::Seq(SQL_CI_UNIQUE_INDEX_NODES);
static SQL_CI_LEAD_CHOICES: &[Node] =
&[SQL_CI_UNIQUE_INDEX, Node::Word(Word::keyword("index"))];
static SQL_CI_LEAD_CHOICES: &[Node] = &[SQL_CI_UNIQUE_INDEX, Node::Word(Word::keyword("index"))];
const SQL_CI_LEAD: Node = Node::Choice(SQL_CI_LEAD_CHOICES);
static SQL_CI_IF_NOT_EXISTS_NODES: &[Node] = &[
@@ -2104,8 +2137,7 @@ static AT_RENAME_COLUMN_TAIL_NODES: &[Node] = &[
NEW_COLUMN_NAME,
];
const AT_RENAME_COLUMN_TAIL: Node = Node::Seq(AT_RENAME_COLUMN_TAIL_NODES);
static AT_RENAME_TABLE_TAIL_NODES: &[Node] =
&[Node::Word(Word::keyword("to")), NEW_TABLE_NAME];
static AT_RENAME_TABLE_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("to")), NEW_TABLE_NAME];
const AT_RENAME_TABLE_TAIL: Node = Node::Seq(AT_RENAME_TABLE_TAIL_NODES);
static AT_RENAME_TAIL_CHOICES: &[Node] = &[AT_RENAME_COLUMN_TAIL, AT_RENAME_TABLE_TAIL];
const AT_RENAME_TAIL: Node = Node::Choice(AT_RENAME_TAIL_CHOICES);
@@ -2132,8 +2164,10 @@ static AT_AC_TYPE_NODES: &[Node] = &[
super::sql_create_table::SQL_TYPE,
];
const AT_AC_TYPE: Node = Node::Seq(AT_AC_TYPE_NODES);
static AT_AC_NOT_NULL_NODES: &[Node] =
&[Node::Word(Word::keyword("not")), Node::Word(Word::keyword("null"))];
static AT_AC_NOT_NULL_NODES: &[Node] = &[
Node::Word(Word::keyword("not")),
Node::Word(Word::keyword("null")),
];
const AT_AC_NOT_NULL: Node = Node::Seq(AT_AC_NOT_NULL_NODES);
static AT_AC_SET_DATA_TYPE_NODES: &[Node] = &[
Node::Word(Word::keyword("data")),
@@ -2149,8 +2183,7 @@ static AT_AC_SET_TAIL_CHOICES: &[Node] = &[
const AT_AC_SET_TAIL: Node = Node::Choice(AT_AC_SET_TAIL_CHOICES);
static AT_AC_SET_NODES: &[Node] = &[Node::Word(Word::keyword("set")), AT_AC_SET_TAIL];
const AT_AC_SET: Node = Node::Seq(AT_AC_SET_NODES);
static AT_AC_DROP_TAIL_CHOICES: &[Node] =
&[AT_AC_NOT_NULL, Node::Word(Word::keyword("default"))];
static AT_AC_DROP_TAIL_CHOICES: &[Node] = &[AT_AC_NOT_NULL, Node::Word(Word::keyword("default"))];
const AT_AC_DROP_TAIL: Node = Node::Choice(AT_AC_DROP_TAIL_CHOICES);
static AT_AC_DROP_NODES: &[Node] = &[Node::Word(Word::keyword("drop")), AT_AC_DROP_TAIL];
const AT_AC_DROP: Node = Node::Seq(AT_AC_DROP_NODES);
@@ -2258,10 +2291,14 @@ fn build_alter_add_column_spec(
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
match &item.kind {
MatchedKind::Ident { role: "col_name", .. } => {
MatchedKind::Ident {
role: "col_name", ..
} => {
pending_name = Some(item.text.clone());
}
MatchedKind::Ident { role: "col_type", .. } => {
MatchedKind::Ident {
role: "col_type", ..
} => {
let ty = Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
@@ -2280,7 +2317,10 @@ fn build_alter_add_column_spec(
spec = Some(ColumnSpec::new(name, Type::Real));
}
MatchedKind::Word("not") => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("null"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("null"))
) {
items.next();
if let Some(s) = spec.as_mut() {
s.not_null = true;
@@ -2326,11 +2366,15 @@ fn build_alter_column_type(path: &MatchedPath) -> Result<AlterTableAction, Valid
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
match &item.kind {
MatchedKind::Ident { role: "col_type", .. } => {
ty = Some(Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
})?);
MatchedKind::Ident {
role: "col_type", ..
} => {
ty = Some(
Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
})?,
);
}
MatchedKind::Word("double") => {
if matches!(
@@ -2379,7 +2423,10 @@ fn build_alter_column_attr(
message_key: "parse.error_wrapper",
args: vec![("detail", "set default needs a value".to_string())],
})?;
AlterTableAction::SetColumnDefault { column, default_sql }
AlterTableAction::SetColumnDefault {
column,
default_sql,
}
}
(false, true) => AlterTableAction::DropColumnDefault { column },
(true, false) => AlterTableAction::SetColumnNotNull { column },
@@ -2495,10 +2542,7 @@ fn build_alter_add_table_constraint(
/// Capture the raw SQL text of an `ADD … CHECK (<expr>)` (ADR-0035 §4g).
/// `sql_expr` is validate-only, so the expression is captured by byte
/// span — the 4a.2 / 4e mechanism.
fn capture_table_check_sql(
path: &MatchedPath,
source: &str,
) -> Result<String, ValidationError> {
fn capture_table_check_sql(path: &MatchedPath, source: &str) -> Result<String, ValidationError> {
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
if matches!(item.kind, MatchedKind::Word("check"))
@@ -2528,7 +2572,10 @@ fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey {
items.next();
}
items.next(); // `foreign`
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("key"))
) {
items.next();
}
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
@@ -2548,7 +2595,10 @@ fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
items.next();
}
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("references"))
) {
items.next();
}
// `ALTER TABLE … ADD FOREIGN KEY (…)` is the table-level form.
@@ -2626,7 +2676,10 @@ mod constraint_tests {
fn an_unconstrained_create_table_still_parses() {
let cols = create_columns("create table T with pk id(serial), name(text)");
assert_eq!(cols.len(), 2);
assert!(cols.iter().all(|c| !c.not_null && !c.unique && c.default.is_none()));
assert!(
cols.iter()
.all(|c| !c.not_null && !c.unique && c.default.is_none())
);
}
#[test]
@@ -2651,7 +2704,9 @@ mod constraint_tests {
#[test]
fn add_column_parses_a_unique_constraint() {
match parse_command("add column to T: email (text) unique").expect("parse") {
Command::AddColumn { unique, not_null, .. } => {
Command::AddColumn {
unique, not_null, ..
} => {
assert!(unique);
assert!(!not_null);
}
@@ -2682,9 +2737,7 @@ mod constraint_tests {
fn check_with_a_parenthesised_sub_expression_parses() {
// The check's own parens plus a nested group — the
// builder's paren-depth scan must pair them correctly.
let cols = create_columns(
"create table T with pk n(int) check ((n > 0) or (n < -10))",
);
let cols = create_columns("create table T with pk n(int) check ((n > 0) or (n < -10))");
assert!(cols[0].check.is_some());
}
@@ -2731,8 +2784,7 @@ mod constraint_tests {
#[test]
fn add_constraint_check_parses() {
match parse_command("add constraint check (age >= 0) to Users.age").expect("parse")
{
match parse_command("add constraint check (age >= 0) to Users.age").expect("parse") {
Command::AddConstraint {
column, constraint, ..
} => {
@@ -2826,8 +2878,11 @@ mod sql_drop_table_tests {
Command::DropColumn { .. }
));
assert!(matches!(
parse_command_in_mode("drop relationship Customers_id_to_Orders_CustId", Mode::Advanced)
.expect("parses"),
parse_command_in_mode(
"drop relationship Customers_id_to_Orders_CustId",
Mode::Advanced
)
.expect("parses"),
Command::DropRelationship { .. }
));
}
@@ -2932,7 +2987,13 @@ mod sql_create_index_tests {
columns,
unique,
if_not_exists,
} => Ci { name, table, columns, unique, if_not_exists },
} => Ci {
name,
table,
columns,
unique,
if_not_exists,
},
other => panic!("expected SqlCreateIndex, got {other:?}"),
}
}
@@ -3134,7 +3195,9 @@ mod sql_alter_table_tests {
// The target slot carries the `reject_internal_table` validator
// (mirroring CREATE TABLE), so an `__rdbms_*` target is refused
// before submit — engine-neutral, not a raw engine error.
assert!(parse_command_in_mode("alter table T rename to __rdbms_evil", Mode::Advanced).is_err());
assert!(
parse_command_in_mode("alter table T rename to __rdbms_evil", Mode::Advanced).is_err()
);
}
#[test]
@@ -3213,7 +3276,10 @@ mod sql_alter_table_tests {
// alias map still applies through the synonym
assert!(matches!(
alter("alter table T alter column n set data type double precision").1,
AlterTableAction::AlterColumnType { ty: crate::dsl::types::Type::Real, .. }
AlterTableAction::AlterColumnType {
ty: crate::dsl::types::Type::Real,
..
}
));
}
@@ -3238,7 +3304,10 @@ mod sql_alter_table_tests {
#[test]
fn alter_column_set_default_captures_raw_expr() {
match alter("alter table T alter column qty set default 0").1 {
AlterTableAction::SetColumnDefault { column, default_sql } => {
AlterTableAction::SetColumnDefault {
column,
default_sql,
} => {
assert_eq!(column, "qty");
assert_eq!(default_sql, "0");
}
@@ -3317,7 +3386,9 @@ mod sql_alter_table_tests {
match alter("alter table T add check (a < b)").1 {
AlterTableAction::AddTableConstraint { name, constraint } => {
assert_eq!(name, None);
assert!(matches!(*constraint, TableConstraint::Check { ref expr_sql } if expr_sql == "a < b"));
assert!(
matches!(*constraint, TableConstraint::Check { ref expr_sql } if expr_sql == "a < b")
);
}
other => panic!("expected AddTableConstraint/Check, got {other:?}"),
}
@@ -3335,7 +3406,9 @@ mod sql_alter_table_tests {
match alter("alter table T add unique (a, b)").1 {
AlterTableAction::AddTableConstraint { name, constraint } => {
assert_eq!(name, None);
assert!(matches!(*constraint, TableConstraint::Unique { ref columns } if columns == &["a".to_string(), "b".to_string()]));
assert!(
matches!(*constraint, TableConstraint::Unique { ref columns } if columns == &["a".to_string(), "b".to_string()])
);
}
other => panic!("expected AddTableConstraint/Unique, got {other:?}"),
}
@@ -3352,7 +3425,9 @@ mod sql_alter_table_tests {
)
.expect_err("a named UNIQUE constraint is refused");
assert!(
err.to_string().to_lowercase().contains("unique constraint cannot be named"),
err.to_string()
.to_lowercase()
.contains("unique constraint cannot be named"),
"expected the builder's named-UNIQUE refusal, got: {err}"
);
}
@@ -3364,7 +3439,9 @@ mod sql_alter_table_tests {
let err = parse_command_in_mode("alter table T add primary key (id)", Mode::Advanced)
.expect_err("ADD PRIMARY KEY is refused");
assert!(
err.to_string().to_lowercase().contains("primary key is fixed at creation"),
err.to_string()
.to_lowercase()
.contains("primary key is fixed at creation"),
"expected the builder's ADD-PRIMARY-KEY refusal, got: {err}"
);
}
@@ -3392,7 +3469,10 @@ mod sql_alter_table_tests {
assert_eq!(name.as_deref(), Some("fk_p"));
match *constraint {
TableConstraint::ForeignKey(fk) => {
assert_eq!(fk.parent_columns, None, "bare reference resolves at execution");
assert_eq!(
fk.parent_columns, None,
"bare reference resolves at execution"
);
}
other => panic!("expected ForeignKey, got {other:?}"),
}
+24 -35
View File
@@ -79,9 +79,9 @@ const EXPR_COLUMN: Node = Node::Ident {
writes_table: false,
writes_column: true,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
/// Operand alternatives. The literal keywords (`null` / `true`
@@ -126,8 +126,7 @@ fn where_rhs_operand(ctx: &WalkContext) -> Node {
// the leak is per distinct column (the walker
// memoizes `DynamicSubgrammar` resolution on
// `current_column`), not per keystroke.
let leaked: &'static str =
Box::leak(col.name.clone().into_boxed_str());
let leaked: &'static str = Box::leak(col.name.clone().into_boxed_str());
Node::TypedValueSlot {
ty: col.user_type,
column_name: Some(leaked),
@@ -260,10 +259,8 @@ static PAREN_GROUP_NODES: &[Node] = &[
Node::Subgrammar(&OR_EXPR),
Node::Punct(')'),
];
static BOOL_PRIMARY_CHOICES: &[Node] = &[
Node::Seq(PAREN_GROUP_NODES),
Node::Subgrammar(&PREDICATE),
];
static BOOL_PRIMARY_CHOICES: &[Node] =
&[Node::Seq(PAREN_GROUP_NODES), Node::Subgrammar(&PREDICATE)];
static BOOL_PRIMARY: Node = Node::Choice(BOOL_PRIMARY_CHOICES);
/// `not_expr := NOT not_expr | bool_primary`.
@@ -271,10 +268,7 @@ static NOT_FORM_NODES: &[Node] = &[
Node::Word(Word::keyword("not")),
Node::Subgrammar(&NOT_EXPR),
];
static NOT_EXPR_CHOICES: &[Node] = &[
Node::Seq(NOT_FORM_NODES),
Node::Subgrammar(&BOOL_PRIMARY),
];
static NOT_EXPR_CHOICES: &[Node] = &[Node::Seq(NOT_FORM_NODES), Node::Subgrammar(&BOOL_PRIMARY)];
static NOT_EXPR: Node = Node::Choice(NOT_EXPR_CHOICES);
/// `and_expr := not_expr ( AND not_expr )*`.
@@ -296,10 +290,7 @@ static AND_EXPR: Node = Node::Seq(AND_EXPR_NODES);
/// `or_expr := and_expr ( OR and_expr )*` — the fragment entry
/// point. `update` / `delete` / `show data` reference this
/// through `Node::Subgrammar(&OR_EXPR)`.
static OR_TAIL_NODES: &[Node] = &[
Node::Word(Word::keyword("or")),
Node::Subgrammar(&AND_EXPR),
];
static OR_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("or")), Node::Subgrammar(&AND_EXPR)];
static OR_TAIL: Node = Node::Seq(OR_TAIL_NODES);
static OR_EXPR_NODES: &[Node] = &[
Node::Subgrammar(&AND_EXPR),
@@ -534,18 +525,18 @@ impl<'a> ExprParser<'a> {
let span = item.span;
let literal = |value: Value| Operand::Literal { value, span };
match &item.kind {
MatchedKind::Ident { role: "expr_column", .. } => {
Ok(Operand::Column { name: item.text.clone(), span })
}
MatchedKind::Ident {
role: "expr_column",
..
} => Ok(Operand::Column {
name: item.text.clone(),
span,
}),
MatchedKind::Word("null") => Ok(literal(Value::Null)),
MatchedKind::Word("true") => Ok(literal(Value::Bool(true))),
MatchedKind::Word("false") => Ok(literal(Value::Bool(false))),
MatchedKind::NumberLit => {
Ok(literal(Value::Number(item.text.clone())))
}
MatchedKind::StringLit => {
Ok(literal(Value::Text(item.text.clone())))
}
MatchedKind::NumberLit => Ok(literal(Value::Number(item.text.clone()))),
MatchedKind::StringLit => Ok(literal(Value::Text(item.text.clone()))),
_ => Err(drift_error("expected a column or literal operand")),
}
}
@@ -591,8 +582,7 @@ mod tests {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
let result =
walk_node(input, 0, &OR_EXPR, &mut ctx, &mut path, &mut per_byte);
let result = walk_node(input, 0, &OR_EXPR, &mut ctx, &mut path, &mut per_byte);
match result {
NodeWalkResult::Matched { end, .. } => {
assert!(
@@ -730,8 +720,7 @@ mod tests {
negated: false,
}),
);
let Expr::Predicate(Predicate::Like { negated, .. }) =
parse_expr("Name not like 'A%'")
let Expr::Predicate(Predicate::Like { negated, .. }) = parse_expr("Name not like 'A%'")
else {
panic!("expected a negated Like");
};
@@ -794,16 +783,16 @@ mod tests {
fn nested_parentheses_round_trip() {
// Exercises the Subgrammar recursion a few levels deep.
let expr = parse_expr("((a = 1 and b = 2) or (c = 3))");
assert!(matches!(expr, Expr::Or(_) | Expr::And(_) | Expr::Predicate(_)));
assert!(matches!(
expr,
Expr::Or(_) | Expr::And(_) | Expr::Predicate(_)
));
}
#[test]
fn case_insensitive_keywords() {
// Keywords fold case; the built tree is identical.
assert_eq!(
parse_expr("a = 1 AND b = 2"),
parse_expr("a = 1 and b = 2"),
);
assert_eq!(parse_expr("a = 1 AND b = 2"), parse_expr("a = 1 and b = 2"),);
assert_eq!(
parse_expr("Email IS NOT NULL"),
parse_expr("Email is not null"),
+21 -24
View File
@@ -27,9 +27,9 @@ pub mod data;
pub mod ddl;
pub mod expr;
pub mod shared;
pub mod sql_expr;
pub mod sql_create_table;
pub mod sql_delete;
pub mod sql_expr;
pub mod sql_insert;
pub mod sql_select;
pub mod sql_update;
@@ -328,9 +328,7 @@ pub enum Node {
/// A number literal. The optional `validator` runs against
/// the matched text (used by Phase D value slots to enforce
/// per-type integer/decimal rules).
NumberLit {
validator: Option<NumberValidator>,
},
NumberLit { validator: Option<NumberValidator> },
/// A literal byte sequence at this position — matches
/// bytes verbatim (whitespace-skipped) with a lookahead so
/// `1` doesn't half-match `12` and `n` doesn't half-match
@@ -701,7 +699,11 @@ fn selected_nodes_for_input_in_mode(
.filter(|(_, _, c)| *c == CommandCategory::Simple)
.collect()
};
if selected.is_empty() { candidates } else { selected }
if selected.is_empty() {
candidates
} else {
selected
}
}
/// The single usage template most relevant to `source`, when
@@ -724,10 +726,7 @@ pub fn usage_key_for_input(source: &str) -> Option<&'static str> {
/// disambiguates the single most-relevant usage key from the
/// mode-selected key set.
#[must_use]
pub fn usage_key_for_input_in_mode(
source: &str,
mode: crate::mode::Mode,
) -> Option<&'static str> {
pub fn usage_key_for_input_in_mode(source: &str, mode: crate::mode::Mode) -> Option<&'static str> {
let (_entry, keys) = usage_keys_for_input_in_mode(source, mode)?;
pick_form_key(source, &keys)
}
@@ -755,7 +754,10 @@ fn pick_form_key<'a>(source: &str, keys: &[&'a str]) -> Option<&'a str> {
}
// The `create m:n relationship` form (ADR-0045) opens with `m:n`
// — a letter, so the digit branch misses it; its key ends `…m2n`.
if source[after..].get(..3).is_some_and(|s| s.eq_ignore_ascii_case("m:n")) {
if source[after..]
.get(..3)
.is_some_and(|s| s.eq_ignore_ascii_case("m:n"))
{
return keys.iter().copied().find(|k| k.ends_with("m2n"));
}
// Otherwise the form word is an identifier — `column`, `index`,
@@ -770,8 +772,7 @@ fn pick_form_key<'a>(source: &str, keys: &[&'a str]) -> Option<&'a str> {
/// which read the same data through the legacy `usage::REGISTRY`.
#[must_use]
pub fn entry_words_alphabetised() -> Vec<&'static str> {
let mut words: Vec<&'static str> =
REGISTRY.iter().map(|(c, _)| c.entry.primary).collect();
let mut words: Vec<&'static str> = REGISTRY.iter().map(|(c, _)| c.entry.primary).collect();
words.sort_unstable();
words.dedup();
words
@@ -791,6 +792,7 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
(&app::QUIT, CommandCategory::Simple),
(&app::HELP, CommandCategory::Simple),
(&app::HINT, CommandCategory::Simple),
(&app::VERSION, CommandCategory::Simple),
(&app::REBUILD, CommandCategory::Simple),
(&app::SAVE, CommandCategory::Simple),
(&app::NEW, CommandCategory::Simple),
@@ -904,9 +906,7 @@ pub fn command_for_entry_word(word: &str) -> Option<(usize, &'static CommandNode
/// returns its `Simple` DSL node and `Advanced` SQL node. The
/// dispatcher picks among them by the active input mode.
#[must_use]
pub fn commands_for_entry_word(
word: &str,
) -> Vec<(usize, &'static CommandNode, CommandCategory)> {
pub fn commands_for_entry_word(word: &str) -> Vec<(usize, &'static CommandNode, CommandCategory)> {
REGISTRY
.iter()
.enumerate()
@@ -1009,7 +1009,10 @@ mod hint_key_tests {
];
for c in classes {
let key = format!("hint.err.{c}.what");
assert!(cat.get(&key).is_some(), "missing tier-3 error block `{key}`");
assert!(
cat.get(&key).is_some(),
"missing tier-3 error block `{key}`"
);
}
}
@@ -1097,10 +1100,7 @@ mod usage_key_tests {
let cases = [
("add column to T: c (int)", "parse.usage.add_column"),
("add index on T (c)", "parse.usage.add_index"),
(
"add constraint unique to T.c",
"parse.usage.add_constraint",
),
("add constraint unique to T.c", "parse.usage.add_constraint"),
(
"drop constraint check from T.c",
"parse.usage.drop_constraint",
@@ -1117,10 +1117,7 @@ mod usage_key_tests {
("drop table T", "parse.usage.drop_table"),
("drop column from table T: c", "parse.usage.drop_column"),
("drop index i", "parse.usage.drop_index"),
(
"drop relationship r",
"parse.usage.drop_relationship",
),
("drop relationship r", "parse.usage.drop_relationship"),
("show data T", "parse.usage.show_data"),
("show table T", "parse.usage.show_table"),
// `create` is multi-form (table vs m:n, ADR-0045): each typed
+17 -24
View File
@@ -7,8 +7,8 @@
use crate::completion::TableColumn;
use crate::dsl::grammar::{
HighlightClass, HintMode, IdentSource, IdentValidator, Node,
NumberValidator, ValidationError, Word,
HighlightClass, HintMode, IdentSource, IdentValidator, Node, NumberValidator, ValidationError,
Word,
};
use crate::dsl::types::Type;
use crate::dsl::walker::context::WalkContext;
@@ -32,10 +32,7 @@ pub fn validate_type_name(value: &str) -> Result<(), ValidationError> {
.join(", ");
Err(ValidationError {
message_key: "parse.custom.unknown_type",
args: vec![
("found", value.to_string()),
("expected", expected),
],
args: vec![("found", value.to_string()), ("expected", expected)],
})
}
}
@@ -51,12 +48,12 @@ pub const TYPE_SLOT: Node = Node::Ident {
role: "type",
validator: Some(TYPE_VALIDATOR),
highlight_override: Some(HighlightClass::Type),
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
// --- Qualified column reference (`<Table>.<Column>`) --------------
@@ -70,9 +67,9 @@ const QUALIFIED_COLUMN_NODES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
Node::Punct('.'),
Node::Ident {
@@ -83,9 +80,9 @@ const QUALIFIED_COLUMN_NODES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
pub const QUALIFIED_COLUMN: Node = Node::Seq(QUALIFIED_COLUMN_NODES);
@@ -313,9 +310,7 @@ const fn slot_inner_for_type(ty: Type) -> &'static Node {
Type::Real => &REAL_SLOT_INNER,
Type::Decimal => &DECIMAL_SLOT_INNER,
Type::Bool => &BOOL_SLOT_INNER,
Type::Text | Type::Date | Type::DateTime | Type::Blob | Type::ShortId => {
&TEXT_SLOT_INNER
}
Type::Text | Type::Date | Type::DateTime | Type::Blob | Type::ShortId => &TEXT_SLOT_INNER,
}
}
@@ -397,9 +392,7 @@ pub(crate) const FALLBACK_VALUE_LIST: Node = Node::Repeated {
/// This is the single source of truth shared by [`column_value_list`]
/// (which builds the typed slots) and the `data.rs` arity gate (which
/// counts them) so the two never disagree (issue #17).
pub fn insert_target_columns<'c>(
ctx: &'c WalkContext<'_>,
) -> Option<Vec<&'c TableColumn>> {
pub fn insert_target_columns<'c>(ctx: &'c WalkContext<'_>) -> Option<Vec<&'c TableColumn>> {
let table_cols = ctx.current_table_columns.as_ref()?;
if table_cols.is_empty() {
return None;
+72 -22
View File
@@ -405,8 +405,14 @@ const TABLE_FK_NAMED: Node = Node::Seq(TABLE_FK_NAMED_NODES);
// / `foreign`) that disambiguates it from a column name. (A column
// literally named with one of those keywords is therefore unavailable,
// the same trade real SQL makes with its reserved words.)
static ELEMENT_CHOICES: &[Node] =
&[TABLE_PK, TABLE_UNIQUE, TABLE_CHECK, TABLE_FK_NAMED, TABLE_FK, COLUMN_DEF];
static ELEMENT_CHOICES: &[Node] = &[
TABLE_PK,
TABLE_UNIQUE,
TABLE_CHECK,
TABLE_FK_NAMED,
TABLE_FK,
COLUMN_DEF,
];
const ELEMENT_INNER: Node = Node::Choice(ELEMENT_CHOICES);
// Issue #4: wrap the element slot in `IntroProse` so a fresh element
// position (`create table T (` and after every `,`) surfaces a prose
@@ -495,18 +501,31 @@ mod tests {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
match walk_node(input, 0, &SQL_CREATE_TABLE_SHAPE, &mut ctx, &mut path, &mut per_byte) {
match walk_node(
input,
0,
&SQL_CREATE_TABLE_SHAPE,
&mut ctx,
&mut path,
&mut per_byte,
) {
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false,
}
}
fn good(input: &str) {
assert!(walks(input), "{input:?} should be a valid CREATE TABLE tail");
assert!(
walks(input),
"{input:?} should be a valid CREATE TABLE tail"
);
}
fn bad(input: &str) {
assert!(!walks(input), "{input:?} should NOT walk as a complete CREATE TABLE tail");
assert!(
!walks(input),
"{input:?} should NOT walk as a complete CREATE TABLE tail"
);
}
#[test]
@@ -638,7 +657,9 @@ mod tests {
good("table t (id int, ref int references other(id))");
good("table t (id int, ref int references other)"); // bare ref
good("table t (id int, ref int references other(id) on delete cascade)");
good("table t (id int, ref int references other(id) on update set null on delete restrict)");
good(
"table t (id int, ref int references other(id) on update set null on delete restrict)",
);
good("table t (id int, ref int, foreign key (ref) references other(id))");
good("table t (id int, ref int, constraint fk_x foreign key (ref) references other(id))");
good(
@@ -691,7 +712,10 @@ mod builder_tests {
assert_eq!(name, "t");
assert_eq!(
cols,
vec![("id".to_string(), Type::Int), ("name".to_string(), Type::Text)]
vec![
("id".to_string(), Type::Int),
("name".to_string(), Type::Text)
]
);
assert!(pk.is_empty(), "no PK declared");
assert!(!ine);
@@ -740,7 +764,10 @@ mod builder_tests {
let (_, cols, _, _) = sct("create table t (a varchar(255), b numeric(10, 2))");
assert_eq!(
cols,
vec![("a".to_string(), Type::Text), ("b".to_string(), Type::Decimal)]
vec![
("a".to_string(), Type::Text),
("b".to_string(), Type::Decimal)
]
);
}
@@ -780,8 +807,7 @@ mod builder_tests {
fn redundant_constraints_deduped_off_sole_pk_column() {
// ADR-0035 §6.5: advanced mode accepts the redundant spelling
// and silently drops the flags off the sole PK column.
match parse_command("create table t (id int primary key not null unique)")
.expect("parses")
match parse_command("create table t (id int primary key not null unique)").expect("parses")
{
Command::SqlCreateTable {
columns,
@@ -944,8 +970,7 @@ mod builder_tests {
// depth 2, not an element boundary, so the following `check`
// is still column-level. A naive "reset on any comma" would
// misclassify it as table-level (the §4.2 probe).
let (cols, checks) =
parse_sct_checks("create table t (n numeric(10, 2) check (n > 0))");
let (cols, checks) = parse_sct_checks("create table t (n numeric(10, 2) check (n > 0))");
assert_eq!(col(&cols, "n").check_sql.as_deref(), Some("n > 0"));
assert!(checks.is_empty(), "no table-level CHECK was produced");
}
@@ -977,8 +1002,7 @@ mod builder_tests {
fn table_check_before_a_later_column_is_table_level() {
// A CHECK element that appears between columns (not after a
// column's type) is table-level even though more columns follow.
let (cols, checks) =
parse_sct_checks("create table t (a int, check (a > 0), b int)");
let (cols, checks) = parse_sct_checks("create table t (a int, check (a > 0), b int)");
assert_eq!(checks, vec!["a > 0".to_string()]);
assert!(col(&cols, "a").check_sql.is_none() && col(&cols, "b").check_sql.is_none());
}
@@ -1004,7 +1028,10 @@ mod builder_tests {
assert_eq!(fk.parent_columns, Some(vec!["id".to_string()]));
assert_eq!(fk.on_delete, ReferentialAction::NoAction);
assert_eq!(fk.on_update, ReferentialAction::NoAction);
assert!(fk.inline, "a column-level `references` is an inline FK (ADR-0043 D4)");
assert!(
fk.inline,
"a column-level `references` is an inline FK (ADR-0043 D4)"
);
}
#[test]
@@ -1012,14 +1039,19 @@ mod builder_tests {
// The table-level `FOREIGN KEY (...)` form is not inline, so it can
// carry a multi-column reference and never triggers the inline
// "use the table-level form" hint (ADR-0043 D4).
let fks = parse_sct_fks("create table t (id int, pid int, foreign key (pid) references parent(id))");
let fks = parse_sct_fks(
"create table t (id int, pid int, foreign key (pid) references parent(id))",
);
assert!(!fks[0].inline, "table-level FOREIGN KEY is not inline");
}
#[test]
fn bare_inline_reference_has_no_parent_column() {
let fks = parse_sct_fks("create table t (id int, pid int references parent)");
assert_eq!(fks[0].parent_columns, None, "bare REFERENCES — resolved at execution");
assert_eq!(
fks[0].parent_columns, None,
"bare REFERENCES — resolved at execution"
);
assert_eq!(fks[0].parent_table, "parent");
assert_eq!(fks[0].child_columns, vec!["pid".to_string()]);
}
@@ -1047,8 +1079,9 @@ mod builder_tests {
#[test]
fn table_level_foreign_key_captured() {
let fks =
parse_sct_fks("create table t (id int, pid int, foreign key (pid) references parent(id))");
let fks = parse_sct_fks(
"create table t (id int, pid int, foreign key (pid) references parent(id))",
);
assert_eq!(fks.len(), 1);
assert_eq!(fks[0].name, None);
assert_eq!(fks[0].child_columns, vec!["pid".to_string()]);
@@ -1073,8 +1106,20 @@ mod builder_tests {
foreign key (a) references p(id), foreign key (b) references q(id))",
);
assert_eq!(fks.len(), 2);
assert_eq!((fks[0].child_columns[0].as_str(), fks[0].parent_table.as_str()), ("a", "p"));
assert_eq!((fks[1].child_columns[0].as_str(), fks[1].parent_table.as_str()), ("b", "q"));
assert_eq!(
(
fks[0].child_columns[0].as_str(),
fks[0].parent_table.as_str()
),
("a", "p")
);
assert_eq!(
(
fks[1].child_columns[0].as_str(),
fks[1].parent_table.as_str()
),
("b", "q")
);
}
#[test]
@@ -1108,7 +1153,12 @@ mod builder_tests {
assert_eq!(foreign_keys[0].child_columns, vec!["pid".to_string()]);
// the column-level CHECK still attaches to `pid`
assert_eq!(
columns.iter().find(|c| c.name == "pid").unwrap().check_sql.as_deref(),
columns
.iter()
.find(|c| c.name == "pid")
.unwrap()
.check_sql
.as_deref(),
Some("pid > 0")
);
// the table-level CHECK is captured separately
+12 -2
View File
@@ -82,7 +82,14 @@ mod tests {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
match walk_node(input, 0, &SQL_DELETE_SHAPE, &mut ctx, &mut path, &mut per_byte) {
match walk_node(
input,
0,
&SQL_DELETE_SHAPE,
&mut ctx,
&mut path,
&mut per_byte,
) {
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false,
}
@@ -93,7 +100,10 @@ mod tests {
}
fn bad(input: &str) {
assert!(!walks(input), "{input:?} should NOT walk as a complete DELETE tail");
assert!(
!walks(input),
"{input:?} should NOT walk as a complete DELETE tail"
);
}
#[test]
+31 -63
View File
@@ -82,19 +82,16 @@ const EXPR_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
// =================================================================
// or_expr := and_expr ( OR and_expr )* — the fragment entry point
// =================================================================
static OR_TAIL_NODES: &[Node] = &[
Node::Word(Word::keyword("or")),
Node::Subgrammar(&AND_EXPR),
];
static OR_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("or")), Node::Subgrammar(&AND_EXPR)];
static OR_TAIL: Node = Node::Seq(OR_TAIL_NODES);
static SQL_OR_EXPR_NODES: &[Node] = &[
Node::Subgrammar(&AND_EXPR),
@@ -140,10 +137,7 @@ static NOT_FORM_NODES: &[Node] = &[
Node::Word(Word::keyword("not")),
Node::Subgrammar(&NOT_EXPR),
];
static NOT_EXPR_CHOICES: &[Node] = &[
Node::Seq(NOT_FORM_NODES),
Node::Subgrammar(&PREDICATE),
];
static NOT_EXPR_CHOICES: &[Node] = &[Node::Seq(NOT_FORM_NODES), Node::Subgrammar(&PREDICATE)];
static NOT_EXPR: Node = Node::Choice(NOT_EXPR_CHOICES);
// =================================================================
@@ -156,10 +150,7 @@ static NOT_EXPR: Node = Node::Choice(NOT_EXPR_CHOICES);
// needs. ADR-0026's DSL grammar made the tail mandatory because it
// forbade a bare column as a boolean; SQL does not.
static PREDICATE_NODES: &[Node] = &[
Node::Subgrammar(&ADDITIVE),
Node::Optional(&PREDICATE_TAIL),
];
static PREDICATE_NODES: &[Node] = &[Node::Subgrammar(&ADDITIVE), Node::Optional(&PREDICATE_TAIL)];
static PREDICATE: Node = Node::Seq(PREDICATE_NODES);
// ---- cmp_op := <= | <> | >= | != | < | > | = --------------------
@@ -181,10 +172,7 @@ static CMP_OP_CHOICES: &[Node] = &[
// ---- predicate_tail branches ------------------------------------
/// `cmp_op additive`.
static COMPARE_FORM_NODES: &[Node] = &[
Node::Choice(CMP_OP_CHOICES),
Node::Subgrammar(&ADDITIVE),
];
static COMPARE_FORM_NODES: &[Node] = &[Node::Choice(CMP_OP_CHOICES), Node::Subgrammar(&ADDITIVE)];
/// `IS [NOT] NULL`.
static IS_NULL_NODES: &[Node] = &[
@@ -265,11 +253,7 @@ static PREDICATE_TAIL: Node = Node::Choice(PREDICATE_TAIL_CHOICES);
// additive := multiplicative ( ( + | - | || ) multiplicative )*
// =================================================================
static ADD_OP_CHOICES: &[Node] = &[
Node::Punct('+'),
Node::Punct('-'),
Node::Literal("||"),
];
static ADD_OP_CHOICES: &[Node] = &[Node::Punct('+'), Node::Punct('-'), Node::Literal("||")];
static ADD_TAIL_NODES: &[Node] = &[
Node::Choice(ADD_OP_CHOICES),
Node::Subgrammar(&MULTIPLICATIVE),
@@ -289,15 +273,8 @@ static ADDITIVE: Node = Node::Seq(ADDITIVE_NODES);
// multiplicative := unary ( ( * | / | % ) unary )*
// =================================================================
static MUL_OP_CHOICES: &[Node] = &[
Node::Punct('*'),
Node::Punct('/'),
Node::Punct('%'),
];
static MUL_TAIL_NODES: &[Node] = &[
Node::Choice(MUL_OP_CHOICES),
Node::Subgrammar(&UNARY),
];
static MUL_OP_CHOICES: &[Node] = &[Node::Punct('*'), Node::Punct('/'), Node::Punct('%')];
static MUL_TAIL_NODES: &[Node] = &[Node::Choice(MUL_OP_CHOICES), Node::Subgrammar(&UNARY)];
static MUL_TAIL: Node = Node::Seq(MUL_TAIL_NODES);
static MULTIPLICATIVE_NODES: &[Node] = &[
Node::Subgrammar(&UNARY),
@@ -314,14 +291,8 @@ static MULTIPLICATIVE: Node = Node::Seq(MULTIPLICATIVE_NODES);
// =================================================================
static SIGN_CHOICES: &[Node] = &[Node::Punct('-'), Node::Punct('+')];
static UNARY_SIGN_NODES: &[Node] = &[
Node::Choice(SIGN_CHOICES),
Node::Subgrammar(&UNARY),
];
static UNARY_CHOICES: &[Node] = &[
Node::Seq(UNARY_SIGN_NODES),
Node::Subgrammar(&PRIMARY),
];
static UNARY_SIGN_NODES: &[Node] = &[Node::Choice(SIGN_CHOICES), Node::Subgrammar(&UNARY)];
static UNARY_CHOICES: &[Node] = &[Node::Seq(UNARY_SIGN_NODES), Node::Subgrammar(&PRIMARY)];
static UNARY: Node = Node::Choice(UNARY_CHOICES);
// =================================================================
@@ -402,10 +373,7 @@ static SIMPLE_CASE_NODES: &[Node] = &[
Node::Optional(&ELSE_CLAUSE),
Node::Word(Word::keyword("end")),
];
static CASE_BODY_CHOICES: &[Node] = &[
Node::Seq(SEARCHED_CASE_NODES),
Node::Seq(SIMPLE_CASE_NODES),
];
static CASE_BODY_CHOICES: &[Node] = &[Node::Seq(SEARCHED_CASE_NODES), Node::Seq(SIMPLE_CASE_NODES)];
static CASE_NODES: &[Node] = &[
Node::Word(Word::keyword("case")),
Node::Choice(CASE_BODY_CHOICES),
@@ -467,14 +435,11 @@ const QUALIFIED_REF_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static QUALIFIED_REF_TAIL_NODES: &[Node] = &[
Node::Punct('.'),
QUALIFIED_REF_IDENT,
];
static QUALIFIED_REF_TAIL_NODES: &[Node] = &[Node::Punct('.'), QUALIFIED_REF_IDENT];
static NAME_OR_CALL_TAIL_CHOICES: &[Node] = &[
Node::Seq(QUALIFIED_REF_TAIL_NODES),
@@ -531,7 +496,10 @@ mod tests {
/// Assert `input` is *not* a complete SQL expression.
fn bad(input: &str) {
assert!(!walks(input), "{input:?} should NOT walk as a complete expression");
assert!(
!walks(input),
"{input:?} should NOT walk as a complete expression"
);
}
#[test]
@@ -643,13 +611,13 @@ mod tests {
#[test]
fn malformed_expressions_do_not_walk() {
bad("a +"); // dangling operator
bad("a in b"); // IN requires a parenthesised list
bad("= 1"); // no left operand
bad("a = "); // no right operand
bad("case a end"); // CASE with no WHEN clause
bad("and b"); // leading connective
bad("upper("); // unclosed call
bad("a +"); // dangling operator
bad("a in b"); // IN requires a parenthesised list
bad("= 1"); // no left operand
bad("a = "); // no right operand
bad("case a end"); // CASE with no WHEN clause
bad("and b"); // leading connective
bad("upper("); // unclosed call
}
#[test]
@@ -680,9 +648,9 @@ mod tests {
// The optional tail dispatches `.identifier` (qualified
// ref) vs `(args)` (function call) by first token — a
// bare ident remains a column ref.
good("foo(x)"); // function call
good("foo.bar"); // qualified ref
good("foo"); // bare ref
good("foo(x)"); // function call
good("foo.bar"); // qualified ref
good("foo"); // bare ref
}
#[test]
+31 -8
View File
@@ -120,7 +120,10 @@ fn target_value_columns(ctx: &WalkContext) -> Vec<TableColumn> {
listed
.iter()
.filter_map(|name| {
table_cols.iter().find(|c| c.name.eq_ignore_ascii_case(name)).cloned()
table_cols
.iter()
.find(|c| c.name.eq_ignore_ascii_case(name))
.cloned()
})
.collect()
},
@@ -148,7 +151,11 @@ fn target_value_columns(ctx: &WalkContext) -> Vec<TableColumn> {
fn tuple_value_list(ctx: &WalkContext, source: &str, pos: usize) -> Node {
let cols = target_value_columns(ctx);
let (count, closed) = count_tuple_values(source, pos);
let arity_ok = if closed { count == cols.len() } else { count <= cols.len() };
let arity_ok = if closed {
count == cols.len()
} else {
count <= cols.len()
};
if !cols.is_empty() && arity_ok {
Node::DynamicSubgrammar(sql_value_list)
} else {
@@ -304,8 +311,10 @@ static DO_UPDATE_NODES: &[Node] = &[
/// the enclosing Seq, each branch's FIRST token (`nothing` vs
/// `update`) disambiguates, so a non-match of branch 0 is a clean
/// `NoMatch` that falls through to branch 1.
static DO_ACTION_CHOICES: &[Node] =
&[Node::Word(Word::keyword("nothing")), Node::Seq(DO_UPDATE_NODES)];
static DO_ACTION_CHOICES: &[Node] = &[
Node::Word(Word::keyword("nothing")),
Node::Seq(DO_UPDATE_NODES),
];
// `const` — used by value in `ON_CONFLICT_CLAUSE_NODES`.
const DO_ACTION: Node = Node::Choice(DO_ACTION_CHOICES);
@@ -361,7 +370,14 @@ mod tests {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
match walk_node(input, 0, &SQL_INSERT_SHAPE, &mut ctx, &mut path, &mut per_byte) {
match walk_node(
input,
0,
&SQL_INSERT_SHAPE,
&mut ctx,
&mut path,
&mut per_byte,
) {
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false,
}
@@ -372,7 +388,10 @@ mod tests {
}
fn bad(input: &str) {
assert!(!walks(input), "{input:?} should NOT walk as a complete INSERT tail");
assert!(
!walks(input),
"{input:?} should NOT walk as a complete INSERT tail"
);
}
#[test]
@@ -418,8 +437,12 @@ mod tests {
// 3h: ON CONFLICT … DO NOTHING / DO UPDATE (ADR-0033 §9).
good("into t (id, name) values (1, 'x') on conflict (id) do nothing");
good("into t (id, name) values (1, 'x') on conflict do nothing");
good("into t (id, name) values (1, 'x') on conflict (id) do update set name = excluded.name");
good("into t (id, name) values (1, 'x') on conflict (id) do update set name = 'y' where id > 0");
good(
"into t (id, name) values (1, 'x') on conflict (id) do update set name = excluded.name",
);
good(
"into t (id, name) values (1, 'x') on conflict (id) do update set name = 'y' where id > 0",
);
// Multi-column conflict target + multi-assignment DO UPDATE.
good("into t (a, b) values (1, 2) on conflict (a, b) do update set b = excluded.b, a = 9");
// ON CONFLICT composes with RETURNING (order: row source,
+62 -97
View File
@@ -141,8 +141,15 @@ static EMPTY_NOMATCH: Node = Node::Choice(&[]);
/// suffix keywords. `as` is not listed — the AS-form alias is a
/// separate `Choice` branch that fires before the lookahead.
const PROJECTION_FOLLOW_SET: &[&str] = &[
"from", "where", "group", "order", "having", "limit",
"union", "intersect", "except",
"from",
"where",
"group",
"order",
"having",
"limit",
"union",
"intersect",
"except",
// `returning` belongs to an enclosing DML statement
// (`INSERT … SELECT … RETURNING …`, ADR-0033 §5), never to a
// projection item's bare alias — so a no-FROM SELECT row source
@@ -158,9 +165,21 @@ const PROJECTION_FOLLOW_SET: &[&str] = &[
/// only when `b` has no alias — `on` is not a base-table name a
/// learner would type as an alias.
const TABLE_SOURCE_FOLLOW_SET: &[&str] = &[
"where", "group", "order", "having", "limit",
"union", "intersect", "except",
"inner", "left", "right", "full", "cross", "join", "on",
"where",
"group",
"order",
"having",
"limit",
"union",
"intersect",
"except",
"inner",
"left",
"right",
"full",
"cross",
"join",
"on",
// `returning` belongs to an enclosing DML statement
// (`INSERT … SELECT … FROM t RETURNING …`, ADR-0033 §5), so the
// SELECT row source must not read it as table `t`'s bare alias.
@@ -172,15 +191,9 @@ fn peek_next_ident_lower(source: &str, pos: usize) -> Option<String> {
consume_ident(source, p).map(|(s, e)| source[s..e].to_ascii_lowercase())
}
fn projection_bare_alias_factory(
_: &WalkContext,
source: &str,
pos: usize,
) -> Node {
fn projection_bare_alias_factory(_: &WalkContext, source: &str, pos: usize) -> Node {
match peek_next_ident_lower(source, pos) {
Some(word)
if PROJECTION_FOLLOW_SET.iter().any(|k| *k == word) =>
{
Some(word) if PROJECTION_FOLLOW_SET.iter().any(|k| *k == word) => {
Node::Subgrammar(&EMPTY_NOMATCH)
}
Some(_) => PROJECTION_BARE_ALIAS_IDENT,
@@ -188,15 +201,9 @@ fn projection_bare_alias_factory(
}
}
fn table_source_bare_alias_factory(
_: &WalkContext,
source: &str,
pos: usize,
) -> Node {
fn table_source_bare_alias_factory(_: &WalkContext, source: &str, pos: usize) -> Node {
match peek_next_ident_lower(source, pos) {
Some(word)
if TABLE_SOURCE_FOLLOW_SET.iter().any(|k| *k == word) =>
{
Some(word) if TABLE_SOURCE_FOLLOW_SET.iter().any(|k| *k == word) => {
Node::Subgrammar(&EMPTY_NOMATCH)
}
Some(_) => TABLE_SOURCE_BARE_ALIAS_IDENT,
@@ -237,14 +244,12 @@ const TABLE_SOURCE_BARE_ALIAS_IDENT: Node = Node::Ident {
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: true,
writes_cte_name: false,
writes_projection_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static PROJECTION_AS_ALIAS_NODES: &[Node] = &[
Node::Word(Word::keyword("as")),
PROJECTION_BARE_ALIAS_IDENT,
];
static PROJECTION_AS_ALIAS_NODES: &[Node] =
&[Node::Word(Word::keyword("as")), PROJECTION_BARE_ALIAS_IDENT];
static PROJECTION_AS_ALIAS: Node = Node::Seq(PROJECTION_AS_ALIAS_NODES);
static TABLE_SOURCE_AS_ALIAS_NODES: &[Node] = &[
@@ -258,17 +263,14 @@ static PROJECTION_ALIAS_CHOICES: &[Node] = &[
Node::Lookahead(projection_bare_alias_factory),
];
static PROJECTION_ALIAS_CHOICE: Node = Node::Choice(PROJECTION_ALIAS_CHOICES);
static PROJECTION_ALIAS_OPTIONAL: Node =
Node::Optional(&PROJECTION_ALIAS_CHOICE);
static PROJECTION_ALIAS_OPTIONAL: Node = Node::Optional(&PROJECTION_ALIAS_CHOICE);
static TABLE_SOURCE_ALIAS_CHOICES: &[Node] = &[
Node::Subgrammar(&TABLE_SOURCE_AS_ALIAS),
Node::Lookahead(table_source_bare_alias_factory),
];
static TABLE_SOURCE_ALIAS_CHOICE: Node =
Node::Choice(TABLE_SOURCE_ALIAS_CHOICES);
static TABLE_SOURCE_ALIAS_OPTIONAL: Node =
Node::Optional(&TABLE_SOURCE_ALIAS_CHOICE);
static TABLE_SOURCE_ALIAS_CHOICE: Node = Node::Choice(TABLE_SOURCE_ALIAS_CHOICES);
static TABLE_SOURCE_ALIAS_OPTIONAL: Node = Node::Optional(&TABLE_SOURCE_ALIAS_CHOICE);
// =================================================================
// Projection item
@@ -282,16 +284,13 @@ const QUALIFIED_STAR_QUALIFIER: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static QUALIFIED_STAR_NODES: &[Node] = &[
QUALIFIED_STAR_QUALIFIER,
Node::Punct('.'),
Node::Punct('*'),
];
static QUALIFIED_STAR_NODES: &[Node] =
&[QUALIFIED_STAR_QUALIFIER, Node::Punct('.'), Node::Punct('*')];
static QUALIFIED_STAR: Node = Node::Seq(QUALIFIED_STAR_NODES);
static PROJECTION_EXPR_ITEM_NODES: &[Node] = &[
@@ -310,11 +309,7 @@ static PROJECTION_EXPR_ITEM: Node = Node::Seq(PROJECTION_EXPR_ITEM_NODES);
/// ambiguity between `t.*` and `sql_expr` (which can match a
/// bare `t`), since the walker's `Choice` doesn't backtrack on
/// a committed match.
fn projection_item_factory(
_: &WalkContext,
source: &str,
pos: usize,
) -> Node {
fn projection_item_factory(_: &WalkContext, source: &str, pos: usize) -> Node {
let p = skip_whitespace(source, pos);
let bytes = source.as_bytes();
if bytes.get(p) == Some(&b'*') {
@@ -363,8 +358,7 @@ static DISTINCT_OR_ALL_CHOICES: &[Node] = &[
Node::Word(Word::keyword("all")),
];
static DISTINCT_OR_ALL_CHOICE: Node = Node::Choice(DISTINCT_OR_ALL_CHOICES);
static DISTINCT_OR_ALL_OPTIONAL: Node =
Node::Optional(&DISTINCT_OR_ALL_CHOICE);
static DISTINCT_OR_ALL_OPTIONAL: Node = Node::Optional(&DISTINCT_OR_ALL_CHOICE);
// =================================================================
// Table source (FROM / JOIN target)
@@ -379,8 +373,8 @@ const TABLE_NAME_IDENT: Node = Node::Ident {
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static TABLE_SOURCE_NODES: &[Node] = &[
@@ -395,8 +389,7 @@ static TABLE_SOURCE: Node = Node::Seq(TABLE_SOURCE_NODES);
const JOIN_WORD: Node = Node::Word(Word::keyword("join"));
const ON_WORD: Node = Node::Word(Word::keyword("on"));
static OUTER_OPTIONAL: Node =
Node::Optional(&Node::Word(Word::keyword("outer")));
static OUTER_OPTIONAL: Node = Node::Optional(&Node::Word(Word::keyword("outer")));
// `INNER JOIN` and bare `JOIN` are split into two Choice
// branches so each branch has a distinct leading keyword
@@ -585,8 +578,7 @@ static SET_OP_CHOICES: &[Node] = &[
];
static SET_OP: Node = Node::Choice(SET_OP_CHOICES);
static SET_OP_TAIL_NODES: &[Node] =
&[Node::Subgrammar(&SET_OP), Node::Subgrammar(&SELECT_CORE)];
static SET_OP_TAIL_NODES: &[Node] = &[Node::Subgrammar(&SET_OP), Node::Subgrammar(&SELECT_CORE)];
static SET_OP_TAIL: Node = Node::Seq(SET_OP_TAIL_NODES);
static PLAIN_COMPOUND_NODES: &[Node] = &[
@@ -619,8 +611,7 @@ static WITH_PREFIXED_COMPOUND_NODES: &[Node] = &[
Node::Subgrammar(&WITH_CLAUSE),
Node::Subgrammar(&PLAIN_COMPOUND),
];
static WITH_PREFIXED_COMPOUND: Node =
Node::Seq(WITH_PREFIXED_COMPOUND_NODES);
static WITH_PREFIXED_COMPOUND: Node = Node::Seq(WITH_PREFIXED_COMPOUND_NODES);
static COMPOUND_CHOICES: &[Node] = &[
Node::Subgrammar(&WITH_PREFIXED_COMPOUND),
@@ -659,9 +650,9 @@ const CTE_COLUMN_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static CTE_COLUMN_LIST_NODES: &[Node] = &[
@@ -674,18 +665,13 @@ static CTE_COLUMN_LIST_NODES: &[Node] = &[
RPAREN,
];
static CTE_COLUMN_LIST_SEQ: Node = Node::Seq(CTE_COLUMN_LIST_NODES);
static CTE_COLUMN_LIST_OPTIONAL: Node =
Node::Optional(&CTE_COLUMN_LIST_SEQ);
static CTE_COLUMN_LIST_OPTIONAL: Node = Node::Optional(&CTE_COLUMN_LIST_SEQ);
// CTE body recursion pushes a fresh lexical scope frame (ADR-
// 0032 §4 / §10.2). Subqueries in `sql_expr.rs` do the same;
// the top-level statement's own COMPOUND embedding does not
// (it shares the implicit bottom frame).
static CTE_BODY_NODES: &[Node] = &[
LPAREN,
Node::ScopedSubgrammar(&SQL_SELECT_COMPOUND),
RPAREN,
];
static CTE_BODY_NODES: &[Node] = &[LPAREN, Node::ScopedSubgrammar(&SQL_SELECT_COMPOUND), RPAREN];
static CTE_BODY: Node = Node::Seq(CTE_BODY_NODES);
static CTE_DEF_NODES: &[Node] = &[
@@ -807,9 +793,7 @@ mod tests {
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
match walk_node(input, 0, fragment, &mut ctx, &mut path, &mut per_byte) {
NodeWalkResult::Matched { end, .. } => {
input[end..].trim().is_empty()
}
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false,
}
}
@@ -819,10 +803,7 @@ mod tests {
}
fn good(input: &str) {
assert!(
walks(input),
"{input:?} should be a valid SELECT statement"
);
assert!(walks(input), "{input:?} should be a valid SELECT statement");
}
fn bad(input: &str) {
@@ -1051,16 +1032,12 @@ mod tests {
#[test]
fn set_op_chain() {
good(
"select a from t union select b from u intersect select c from v",
);
good("select a from t union select b from u intersect select c from v");
}
#[test]
fn set_op_with_outer_order_by_and_limit() {
good(
"select a from t union select b from u order by a limit 10",
);
good("select a from t union select b from u order by a limit 10");
}
// ----- ORDER BY / LIMIT / OFFSET -----
@@ -1126,16 +1103,12 @@ mod tests {
#[test]
fn recursive_cte() {
good(
"with recursive r as (select 1 union all select 2) select * from r",
);
good("with recursive r as (select 1 union all select 2) select * from r");
}
#[test]
fn multiple_ctes() {
good(
"with a as (select 1), b as (select 2) select * from a union select * from b",
);
good("with a as (select 1), b as (select 2) select * from a union select * from b");
}
// ----- subquery shapes (recursion through SQL_SELECT_COMPOUND) -----
@@ -1147,9 +1120,7 @@ mod tests {
#[test]
fn nested_cte_body_with_union() {
good(
"with x as (select 1 union select 2) select * from x",
);
good("with x as (select 1 union select 2) select * from x");
}
// ----- case insensitivity / spacing -----
@@ -1363,9 +1334,7 @@ mod tests {
#[test]
fn in_subquery_in_where_clause() {
good("select * from t where id in (select user_id from orders)");
good(
"select * from customers where id not in (select customer_id from blocklist)",
);
good("select * from customers where id not in (select customer_id from blocklist)");
}
#[test]
@@ -1378,9 +1347,7 @@ mod tests {
#[test]
fn nested_subqueries() {
good(
"select * from t where x in (select y from u where y in (select z from v))",
);
good("select * from t where x in (select y from u where y in (select z from v))");
}
#[test]
@@ -1393,8 +1360,6 @@ mod tests {
#[test]
fn cte_body_references_qualified_columns() {
good(
"with x as (select t.name, t.age from t) select x.name from x",
);
good("with x as (select t.name, t.age from t) select x.name from x");
}
}
+12 -2
View File
@@ -119,7 +119,14 @@ mod tests {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
match walk_node(input, 0, &SQL_UPDATE_SHAPE, &mut ctx, &mut path, &mut per_byte) {
match walk_node(
input,
0,
&SQL_UPDATE_SHAPE,
&mut ctx,
&mut path,
&mut per_byte,
) {
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false,
}
@@ -130,7 +137,10 @@ mod tests {
}
fn bad(input: &str) {
assert!(!walks(input), "{input:?} should NOT walk as a complete UPDATE tail");
assert!(
!walks(input),
"{input:?} should NOT walk as a complete UPDATE tail"
);
}
#[test]
+3 -3
View File
@@ -21,9 +21,9 @@ pub mod walker;
pub use action::ReferentialAction;
pub use command::{
AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, CopyScope, Expr,
IndexSelector, MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector, RowFilter,
ShowListKind, SqlForeignKey,
AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, CopyScope,
Expr, IndexSelector, MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector,
RowFilter, ShowListKind, SqlForeignKey,
};
pub use parser::{ParseError, parse_command};
pub use types::Type;
+18 -25
View File
@@ -55,10 +55,9 @@ pub enum ParseError {
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Invalid { message, .. } => f.write_str(&crate::t!(
"parse.error_wrapper",
detail = message,
)),
Self::Invalid { message, .. } => {
f.write_str(&crate::t!("parse.error_wrapper", detail = message,))
}
Self::Empty => f.write_str(&crate::t!("parse.empty")),
}
}
@@ -125,10 +124,7 @@ pub fn parse_command_with_schema(
/// Schemaless, mode-aware parse (ADR-0030 §2). In `Mode::Simple`
/// the walker gates SQL-only commands and produces the
/// "this is SQL" hint instead of executing them.
pub fn parse_command_in_mode(
input: &str,
mode: Mode,
) -> Result<Command, ParseError> {
pub fn parse_command_in_mode(input: &str, mode: Mode) -> Result<Command, ParseError> {
parse_command_inner(input, None, mode)
}
@@ -185,10 +181,8 @@ fn unknown_command_error(source: &str) -> ParseError {
.collect();
let joined = oxford_join(&entries);
let start = skip_whitespace(source, 0);
let (position, found_word) = consume_ident(source, start).map_or_else(
|| (start, None),
|(s, e)| (s, Some(&source[s..e])),
);
let (position, found_word) = consume_ident(source, start)
.map_or_else(|| (start, None), |(s, e)| (s, Some(&source[s..e])));
let message = found_word.map_or_else(
|| format!("expected one of {joined}"),
|w| format!("expected one of {joined}, found `{w}`"),
@@ -1034,19 +1028,22 @@ mod tests {
false,
);
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId on delete cascade on update set null"),
ok(
"add 1:n relationship from Customers.Id to Orders.CustId on delete cascade on update set null"
),
expected
);
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId on update set null on delete cascade"),
ok(
"add 1:n relationship from Customers.Id to Orders.CustId on update set null on delete cascade"
),
expected
);
}
#[test]
fn add_relationship_repeated_clause_errors() {
let e =
err("add 1:n relationship from C.id to O.cid on delete cascade on delete restrict");
let e = err("add 1:n relationship from C.id to O.cid on delete cascade on delete restrict");
match e {
ParseError::Invalid { message, .. } => {
assert!(message.contains("specified twice"), "{message}");
@@ -1073,7 +1070,9 @@ mod tests {
#[test]
fn add_relationship_with_name_actions_and_flag() {
assert_eq!(
ok("add 1:n relationship as cust_orders from Customers.Id to Orders.CustId on delete cascade on update no action --create-fk"),
ok(
"add 1:n relationship as cust_orders from Customers.Id to Orders.CustId on delete cascade on update no action --create-fk"
),
rel(
Some("cust_orders"),
("Customers", "Id"),
@@ -1300,10 +1299,7 @@ mod tests {
#[test]
fn advanced_ambiguous_update_routes_to_sql() {
assert!(matches!(
parse_command_in_mode(
"update Orders set total = 0 where id = 1",
Mode::Advanced,
),
parse_command_in_mode("update Orders set total = 0 where id = 1", Mode::Advanced,),
Ok(Command::SqlUpdate { .. })
));
}
@@ -1399,10 +1395,7 @@ mod tests {
// in advanced mode)" pointer is added at the hint layer
// (input_render), not in the parsed command/error here.
assert!(matches!(
parse_command_in_mode(
"delete from Orders where id = 1 returning *",
Mode::Simple,
),
parse_command_in_mode("delete from Orders where id = 1 returning *", Mode::Simple,),
Err(ParseError::Invalid { .. })
));
}
+1 -2
View File
@@ -9,8 +9,7 @@ use rand::RngExt;
/// Base58 alphabet — Bitcoin-style. 0 / O / I / l are excluded
/// because they are easily confused in print.
const ALPHABET: &[u8; 58] =
b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
const ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
const DEFAULT_LEN: usize = 10;
+4 -26
View File
@@ -43,29 +43,9 @@
/// - **Broader scalars:** `date`, `datetime`, `hex`, `ifnull`,
/// `instr`, `nullif`, `random`, `replace`, `strftime`, `typeof`.
pub const KNOWN_SQL_FUNCTIONS: &[&str] = &[
"abs",
"avg",
"coalesce",
"count",
"date",
"datetime",
"hex",
"ifnull",
"instr",
"length",
"lower",
"max",
"min",
"nullif",
"random",
"replace",
"round",
"strftime",
"substr",
"sum",
"trim",
"typeof",
"upper",
"abs", "avg", "coalesce", "count", "date", "datetime", "hex", "ifnull", "instr", "length",
"lower", "max", "min", "nullif", "random", "replace", "round", "strftime", "substr", "sum",
"trim", "typeof", "upper",
];
/// Whether `partial` is a case-insensitive prefix of at least one
@@ -80,9 +60,7 @@ pub const KNOWN_SQL_FUNCTIONS: &[&str] = &[
#[must_use]
pub fn is_known_function_prefix(partial: &str) -> bool {
let lowered = partial.to_lowercase();
KNOWN_SQL_FUNCTIONS
.iter()
.any(|f| f.starts_with(&lowered))
KNOWN_SQL_FUNCTIONS.iter().any(|f| f.starts_with(&lowered))
}
#[cfg(test)]
+2 -9
View File
@@ -59,11 +59,7 @@ impl Type {
#[must_use]
pub const fn sqlite_strict_type(self) -> &'static str {
match self {
Self::Text
| Self::ShortId
| Self::Decimal
| Self::Date
| Self::DateTime => "TEXT",
Self::Text | Self::ShortId | Self::Decimal | Self::Date | Self::DateTime => "TEXT",
Self::Int | Self::Serial | Self::Bool => "INTEGER",
Self::Real => "REAL",
Self::Blob => "BLOB",
@@ -107,10 +103,7 @@ impl Type {
/// match against a numeric column (ADR-0027, Amendment 1).
#[must_use]
pub const fn is_numeric(self) -> bool {
matches!(
self,
Self::Int | Self::Real | Self::Decimal | Self::Serial
)
matches!(self, Self::Int | Self::Real | Self::Decimal | Self::Serial)
}
/// The user-facing type that an FK column should use to
+37 -18
View File
@@ -129,13 +129,14 @@ impl Value {
fn bind_int(&self, column: &str, ty: Type) -> Result<Bound, ValueError> {
match self {
Self::Number(n) => n
.parse::<i64>()
.map(Bound::Integer)
.map_err(|_| ValueError::Format {
column: column.to_string(),
message: format!("`{n}` is not a valid {ty} (whole number expected)"),
}),
Self::Number(n) => {
n.parse::<i64>()
.map(Bound::Integer)
.map_err(|_| ValueError::Format {
column: column.to_string(),
message: format!("`{n}` is not a valid {ty} (whole number expected)"),
})
}
other => Err(ValueError::TypeMismatch {
column: column.to_string(),
expected_human: format!("a whole number for `{ty}`"),
@@ -241,9 +242,7 @@ pub(crate) fn validate_date(s: &str) -> Result<(), String> {
// Expect YYYY-MM-DD: 10 chars, two dashes at fixed positions.
let bytes = s.as_bytes();
if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
return Err(format!(
"`{s}` is not a date in `YYYY-MM-DD` form"
));
return Err(format!("`{s}` is not a date in `YYYY-MM-DD` form"));
}
let year = parse_digits(&s[0..4]).ok_or_else(|| format!("`{s}`: invalid year"))?;
let month = parse_digits(&s[5..7]).ok_or_else(|| format!("`{s}`: invalid month"))?;
@@ -272,7 +271,9 @@ pub(crate) fn validate_datetime(s: &str) -> Result<(), String> {
validate_date(date_part)?;
let bytes = s.as_bytes();
if bytes[10] != b'T' {
return Err(format!("`{s}`: missing `T` separator between date and time"));
return Err(format!(
"`{s}`: missing `T` separator between date and time"
));
}
if bytes[13] != b':' || bytes[16] != b':' {
return Err(format!("`{s}`: time portion must be `HH:MM:SS`"));
@@ -326,8 +327,14 @@ mod tests {
#[test]
fn integer_for_int_column() {
assert_eq!(n("42").bind_for_column("c", Type::Int).unwrap(), Bound::Integer(42));
assert_eq!(n("-7").bind_for_column("c", Type::Int).unwrap(), Bound::Integer(-7));
assert_eq!(
n("42").bind_for_column("c", Type::Int).unwrap(),
Bound::Integer(42)
);
assert_eq!(
n("-7").bind_for_column("c", Type::Int).unwrap(),
Bound::Integer(-7)
);
}
#[test]
@@ -355,7 +362,9 @@ mod tests {
#[test]
fn shortid_validation_runs_on_text_for_shortid_column() {
let err = t("toolong_xyz_more").bind_for_column("c", Type::ShortId).unwrap_err();
let err = t("toolong_xyz_more")
.bind_for_column("c", Type::ShortId)
.unwrap_err();
assert!(matches!(err, ValueError::Format { .. }));
// Well-formed shortid binds fine.
@@ -367,8 +376,14 @@ mod tests {
#[test]
fn bool_for_bool_column_maps_to_zero_or_one() {
assert_eq!(Value::Bool(true).bind_for_column("c", Type::Bool).unwrap(), Bound::Integer(1));
assert_eq!(Value::Bool(false).bind_for_column("c", Type::Bool).unwrap(), Bound::Integer(0));
assert_eq!(
Value::Bool(true).bind_for_column("c", Type::Bool).unwrap(),
Bound::Integer(1)
);
assert_eq!(
Value::Bool(false).bind_for_column("c", Type::Bool).unwrap(),
Bound::Integer(0)
);
}
#[test]
@@ -377,13 +392,17 @@ mod tests {
t("2025-01-15").bind_for_column("c", Type::Date).unwrap(),
Bound::Text("2025-01-15".to_string())
);
let err = t("2025/01/15").bind_for_column("c", Type::Date).unwrap_err();
let err = t("2025/01/15")
.bind_for_column("c", Type::Date)
.unwrap_err();
assert!(matches!(err, ValueError::Format { .. }));
}
#[test]
fn date_range_check() {
let err = t("2025-13-01").bind_for_column("c", Type::Date).unwrap_err();
let err = t("2025-13-01")
.bind_for_column("c", Type::Date)
.unwrap_err();
assert!(matches!(err, ValueError::Format { message, .. } if message.contains("month")));
}
+119 -179
View File
@@ -28,12 +28,10 @@ use crate::completion::TableColumn;
use crate::dsl::grammar::{HighlightClass, Node, ValidationError};
use crate::dsl::walker::context::WalkContext;
use crate::dsl::walker::lex_helpers::{
consume_bare_path, consume_flag, consume_ident, consume_number_literal,
consume_string_literal, skip_whitespace,
};
use crate::dsl::walker::outcome::{
ByteClass, Expectation, MatchedItem, MatchedKind, MatchedPath,
consume_bare_path, consume_flag, consume_ident, consume_number_literal, consume_string_literal,
skip_whitespace,
};
use crate::dsl::walker::outcome::{ByteClass, Expectation, MatchedItem, MatchedKind, MatchedPath};
/// Maximum nesting of `Node::Subgrammar` frames (ADR-0026 §1).
///
@@ -77,10 +75,7 @@ static DYNAMIC_CACHE: LazyLock<Mutex<HashMap<DynamicKey, &'static Node>>> =
/// Resolve a `DynamicSubgrammar` factory to a `&'static Node`,
/// reusing a previously-leaked Node when the factory's inputs
/// match a cached entry.
fn resolve_dynamic(
factory: fn(&WalkContext) -> Node,
ctx: &WalkContext,
) -> &'static Node {
fn resolve_dynamic(factory: fn(&WalkContext) -> Node, ctx: &WalkContext) -> &'static Node {
let key = DynamicKey {
factory: factory as usize,
current_table_columns: ctx.current_table_columns.clone(),
@@ -123,10 +118,7 @@ pub enum NodeWalkResult {
expected: Vec<Expectation>,
},
/// Committed and hit a hard mismatch or validator failure.
Failed {
position: usize,
kind: FailureKind,
},
Failed { position: usize, kind: FailureKind },
}
const fn matched(end: usize) -> NodeWalkResult {
@@ -218,9 +210,7 @@ fn walk_node_inner(
kind: FailureKind::Mismatch { expected: vec![] },
}
}
Node::Subgrammar(inner) => {
walk_subgrammar(source, pos, inner, ctx, path, per_byte)
}
Node::Subgrammar(inner) => walk_subgrammar(source, pos, inner, ctx, path, per_byte),
Node::ScopedSubgrammar(inner) => {
walk_scoped_subgrammar(source, pos, inner, ctx, path, per_byte)
}
@@ -247,8 +237,7 @@ fn walk_node_inner(
// DynamicSubgrammar wrapper that delegates to the
// memoized `column_value_list`), so the per-walk
// leak is a few bytes, not a whole typed tree.
let resolved: &'static Node =
Box::leak(Box::new(factory(ctx, source, pos)));
let resolved: &'static Node = Box::leak(Box::new(factory(ctx, source, pos)));
walk_node(source, pos, resolved, ctx, path, per_byte)
}
Node::SetColumn(col) => {
@@ -262,7 +251,10 @@ fn walk_node_inner(
let col: &crate::completion::TableColumn = col;
ctx.current_column = Some(col.clone());
ctx.pending_value_column = Some(col.name.clone());
NodeWalkResult::Matched { end: pos, skipped: Vec::new() }
NodeWalkResult::Matched {
end: pos,
skipped: Vec::new(),
}
}
Node::TypedValueSlot {
ty,
@@ -342,7 +334,10 @@ fn walk_word(
// Amendment 4). Plain keywords leave it `None`.
class: word.highlight_override.unwrap_or(HighlightClass::Keyword),
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
} else {
NodeWalkResult::NoMatch {
position,
@@ -477,9 +472,7 @@ fn walk_ident(
// ScopedSubgrammar (which is structurally guaranteed to be
// the CTE body — no intervening scoped subgrammar in CTE
// syntax) runs the harvest at body-frame exit.
if writes_cte_name
&& let Some(frame) = ctx.from_scope_stack.last_mut()
{
if writes_cte_name && let Some(frame) = ctx.from_scope_stack.last_mut() {
frame
.cte_bindings
.push(crate::dsl::walker::context::CteBinding {
@@ -487,13 +480,12 @@ fn walk_ident(
columns: Vec::new(),
});
let placeholder_index = frame.cte_bindings.len() - 1;
ctx.pending_cte_harvest =
Some(crate::dsl::walker::context::PendingCteHarvest {
placeholder_index,
col_list: Vec::new(),
cte_name: text.clone(),
cte_name_span: (start, end),
});
ctx.pending_cte_harvest = Some(crate::dsl::walker::context::PendingCteHarvest {
placeholder_index,
col_list: Vec::new(),
cte_name: text.clone(),
cte_name_span: (start, end),
});
}
// ADR-0032 §10.3: the optional `(c1, c2, …)` rename list
// between the cte name and `AS`. Each `cte_column` ident
@@ -507,9 +499,7 @@ fn walk_ident(
}
// ADR-0032 §10.4: projection-list alias accumulator for
// ORDER BY completion candidates.
if writes_projection_alias
&& let Some(frame) = ctx.from_scope_stack.last_mut()
{
if writes_projection_alias && let Some(frame) = ctx.from_scope_stack.last_mut() {
frame.projection_aliases.push(text.clone());
}
if writes_column && matches!(src, crate::dsl::grammar::IdentSource::Columns) {
@@ -529,9 +519,7 @@ fn walk_ident(
.map(|c| c.name.clone())
.or_else(|| Some(text.clone()));
}
if writes_user_listed_column
&& matches!(src, crate::dsl::grammar::IdentSource::Columns)
{
if writes_user_listed_column && matches!(src, crate::dsl::grammar::IdentSource::Columns) {
// Form A: `insert into <T> (col1, col2, …)`. Append the
// matched column name to user_listed_columns so the
// inner `values (…)` slot list mirrors the user's
@@ -564,7 +552,10 @@ fn walk_ident(
// (issue #8 / ADR-0022 Amendment 4).
class: highlight_override.unwrap_or(HighlightClass::Identifier),
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
fn walk_string_lit(
@@ -648,7 +639,10 @@ fn walk_literal(
end,
class,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
fn walk_number_lit(
@@ -683,7 +677,10 @@ fn walk_number_lit(
end,
class: HighlightClass::Number,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
fn walk_flag(
@@ -717,7 +714,10 @@ fn walk_flag(
end,
class: HighlightClass::Flag,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
#[allow(clippy::too_many_arguments)]
@@ -784,7 +784,10 @@ fn walk_repeated(
count += 1;
last_item_skipped = skipped;
}
NodeWalkResult::NoMatch { expected, position: inner_pos } => {
NodeWalkResult::NoMatch {
expected,
position: inner_pos,
} => {
// Mid-typing-the-next-item recovery: if the
// separator just consumed and the inner failed
// at EOF, the user is partway through typing the
@@ -860,7 +863,10 @@ fn walk_bare_path(
end,
class: HighlightClass::String,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
fn walk_choice(
@@ -1031,7 +1037,10 @@ fn walk_optional(
skipped: expected,
}
}
NodeWalkResult::Incomplete { position: p, expected } if !inner_committed => {
NodeWalkResult::Incomplete {
position: p,
expected,
} if !inner_committed => {
// Inner reported Incomplete without consuming
// anything — same as NoMatch from the user's
// perspective. Roll back and skip.
@@ -1156,9 +1165,7 @@ fn walk_scoped_subgrammar(
// walks that NoMatch / Incomplete / Fail leave the placeholder
// empty (the outer-frame state is also discarded in the
// speculative path, so this is correct).
if let (Some(req), NodeWalkResult::Matched { end, .. }) =
(pending_cte, &result)
{
if let (Some(req), NodeWalkResult::Matched { end, .. }) = (pending_cte, &result) {
run_cte_harvest(ctx, path, source, pos, *end, &req);
}
@@ -1240,9 +1247,8 @@ fn run_cte_harvest(
select_idx = Some(i + 1); // start of projection list
}
MatchedKind::Word(
"from" | "where" | "group" | "having" | "order"
| "limit" | "offset" | "union" | "intersect"
| "except",
"from" | "where" | "group" | "having" | "order" | "limit" | "offset" | "union"
| "intersect" | "except",
) if select_idx.is_some() => {
end_idx = i;
break;
@@ -1281,12 +1287,7 @@ fn run_cte_harvest(
// Classify each projection item per ADR-0032 §10.3.
let mut derived: Vec<CteColumn> = Vec::new();
for slice in item_slices {
classify_projection_item(
slice,
body_frame,
&ctx.from_scope_stack,
&mut derived,
);
classify_projection_item(slice, body_frame, &ctx.from_scope_stack, &mut derived);
}
// Apply (c1, c2, …) positional rename if provided. Types
@@ -1339,8 +1340,7 @@ fn run_cte_harvest(
let stack_len = ctx.from_scope_stack.len();
if stack_len >= 2
&& let Some(outer) = ctx.from_scope_stack.get_mut(stack_len - 2)
&& let Some(placeholder) =
outer.cte_bindings.get_mut(req.placeholder_index)
&& let Some(placeholder) = outer.cte_bindings.get_mut(req.placeholder_index)
{
placeholder.columns = derived;
}
@@ -1368,9 +1368,7 @@ fn classify_projection_item(
// empty because it wasn't a base-table lookup), resolve
// through to the in-scope CteBinding so nested CTEs project
// correctly.
if expr_slice.len() == 1
&& matches!(expr_slice[0].kind, MatchedKind::Punct('*'))
{
if expr_slice.len() == 1 && matches!(expr_slice[0].kind, MatchedKind::Punct('*')) {
for binding in &body_frame.from_scope {
for col in expand_binding(binding, scope_stack) {
out.push(col);
@@ -1383,7 +1381,10 @@ fn classify_projection_item(
if expr_slice.len() == 3
&& matches!(
expr_slice[0].kind,
MatchedKind::Ident { role: "qualified_star_qualifier", .. }
MatchedKind::Ident {
role: "qualified_star_qualifier",
..
}
)
&& matches!(expr_slice[1].kind, MatchedKind::Punct('.'))
&& matches!(expr_slice[2].kind, MatchedKind::Punct('*'))
@@ -1413,11 +1414,7 @@ fn classify_projection_item(
)
{
let col_text = &expr_slice[0].text;
let resolved_type = resolve_bare_column_type_in_frame(
body_frame,
scope_stack,
col_text,
);
let resolved_type = resolve_bare_column_type_in_frame(body_frame, scope_stack, col_text);
let name = alias.unwrap_or_else(|| col_text.clone());
out.push(CteColumn {
name: Some(name),
@@ -1447,12 +1444,7 @@ fn classify_projection_item(
{
let qual = &expr_slice[0].text;
let col_text = &expr_slice[2].text;
let resolved_type = resolve_qualified_column_type(
body_frame,
scope_stack,
qual,
col_text,
);
let resolved_type = resolve_qualified_column_type(body_frame, scope_stack, qual, col_text);
let name = alias.unwrap_or_else(|| col_text.clone());
out.push(CteColumn {
name: Some(name),
@@ -1493,16 +1485,8 @@ fn strip_trailing_alias<'a>(
}
) {
// Optional preceding `AS` keyword.
if slice.len() >= 2
&& matches!(
slice[slice.len() - 2].kind,
MatchedKind::Word("as")
)
{
return (
&slice[..slice.len() - 2],
Some(last.text.clone()),
);
if slice.len() >= 2 && matches!(slice[slice.len() - 2].kind, MatchedKind::Word("as")) {
return (&slice[..slice.len() - 2], Some(last.text.clone()));
}
return (&slice[..slice.len() - 1], Some(last.text.clone()));
}
@@ -1613,8 +1597,8 @@ fn merge_expected(dst: &mut Vec<Expectation>, src: Vec<Expectation>) {
#[cfg(test)]
mod tests {
use super::{
DYNAMIC_CACHE, FailureKind, MAX_SUBGRAMMAR_DEPTH, NodeWalkResult,
resolve_dynamic, walk_node,
DYNAMIC_CACHE, FailureKind, MAX_SUBGRAMMAR_DEPTH, NodeWalkResult, resolve_dynamic,
walk_node,
};
use crate::dsl::grammar::{Node, Word};
use crate::dsl::walker::context::WalkContext;
@@ -1629,18 +1613,14 @@ mod tests {
Node::Subgrammar(&NESTED),
Node::Punct(')'),
];
static NESTED_CHOICES: &[Node] = &[
Node::Seq(NESTED_GROUP),
Node::Word(Word::keyword("x")),
];
static NESTED_CHOICES: &[Node] = &[Node::Seq(NESTED_GROUP), Node::Word(Word::keyword("x"))];
static NESTED: Node = Node::Choice(NESTED_CHOICES);
fn walk_nested(input: &str) -> NodeWalkResult {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
let result =
walk_node(input, 0, &NESTED, &mut ctx, &mut path, &mut per_byte);
let result = walk_node(input, 0, &NESTED, &mut ctx, &mut path, &mut per_byte);
assert_eq!(
ctx.subgrammar_depth, 0,
"subgrammar_depth must be restored to 0 after the walk",
@@ -1726,14 +1706,8 @@ mod tests {
fn resolve_dynamic_cache_is_populated() {
let ctx = WalkContext::new();
let _ = resolve_dynamic(const_factory, &ctx);
let populated = !DYNAMIC_CACHE
.lock()
.expect("cache lock")
.is_empty();
assert!(
populated,
"resolve_dynamic should populate the memo cache",
);
let populated = !DYNAMIC_CACHE.lock().expect("cache lock").is_empty();
assert!(populated, "resolve_dynamic should populate the memo cache",);
}
// ---- ScopedSubgrammar (ADR-0032 §10.2) -----------------------
@@ -1758,14 +1732,7 @@ mod tests {
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
let baseline_frames = ctx.from_scope_stack.len();
let result = walk_node(
input,
0,
&SCOPED_NESTED,
&mut ctx,
&mut path,
&mut per_byte,
);
let result = walk_node(input, 0, &SCOPED_NESTED, &mut ctx, &mut path, &mut per_byte);
assert_eq!(
ctx.subgrammar_depth, 0,
"subgrammar_depth must be restored to 0 after the walk",
@@ -1801,9 +1768,9 @@ mod tests {
kind: FailureKind::Validation(err),
..
} => assert_eq!(err.message_key, "parse.custom.expression_too_deep"),
other => panic!(
"expected expression_too_deep on pathological scoped nesting, got {other:?}",
),
other => {
panic!("expected expression_too_deep on pathological scoped nesting, got {other:?}",)
}
}
}
@@ -1822,9 +1789,7 @@ mod tests {
/// Walk a top-level SQL SELECT and return the bottom frame's
/// `from_scope` after the walk completes. Used to verify that
/// `writes_table` / `writes_table_alias` populate bindings.
fn from_scope_after_walk(
input: &str,
) -> Vec<crate::dsl::walker::context::TableBinding> {
fn from_scope_after_walk(input: &str) -> Vec<crate::dsl::walker::context::TableBinding> {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
@@ -1871,9 +1836,7 @@ mod tests {
#[test]
fn join_pushes_a_second_binding() {
let bindings = from_scope_after_walk(
"select * from a join b on x = y",
);
let bindings = from_scope_after_walk("select * from a join b on x = y");
assert_eq!(bindings.len(), 2);
assert_eq!(bindings[0].table, "a");
assert_eq!(bindings[1].table, "b");
@@ -1881,9 +1844,7 @@ mod tests {
#[test]
fn join_with_aliases() {
let bindings = from_scope_after_walk(
"select * from a as x join b as y on x.id = y.id",
);
let bindings = from_scope_after_walk("select * from a as x join b as y on x.id = y.id");
assert_eq!(bindings.len(), 2);
assert_eq!(bindings[0].table, "a");
assert_eq!(bindings[0].alias, Some("x".to_string()));
@@ -1893,9 +1854,8 @@ mod tests {
#[test]
fn three_way_join_pushes_three_bindings() {
let bindings = from_scope_after_walk(
"select * from a join b on x = y left join c on y = z",
);
let bindings =
from_scope_after_walk("select * from a join b on x = y left join c on y = z");
assert_eq!(bindings.len(), 3);
assert_eq!(bindings[0].table, "a");
assert_eq!(bindings[1].table, "b");
@@ -1908,9 +1868,8 @@ mod tests {
// binding into the inner scope frame; on exit, the frame
// pops and the inner binding is gone. The outer scope's
// from_scope still contains only `outer_t`.
let bindings = from_scope_after_walk(
"select * from outer_t where id in (select id from inner_t)",
);
let bindings =
from_scope_after_walk("select * from outer_t where id in (select id from inner_t)");
assert_eq!(bindings.len(), 1);
assert_eq!(bindings[0].table, "outer_t");
}
@@ -1921,9 +1880,8 @@ mod tests {
// body's scope frame; on body-frame exit, the inner
// binding goes away. The outer scope contains only
// the CTE-name reference `cte_x`.
let bindings = from_scope_after_walk(
"with cte_x as (select * from base_table) select * from cte_x",
);
let bindings =
from_scope_after_walk("with cte_x as (select * from base_table) select * from cte_x");
assert_eq!(bindings.len(), 1);
assert_eq!(bindings[0].table, "cte_x");
}
@@ -1940,10 +1898,7 @@ mod tests {
/// `cte_bindings` and `projection_aliases` after the walk.
fn frame_state_after_walk(
input: &str,
) -> (
Vec<crate::dsl::walker::context::CteBinding>,
Vec<String>,
) {
) -> (Vec<crate::dsl::walker::context::CteBinding>, Vec<String>) {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
@@ -1968,9 +1923,7 @@ mod tests {
#[test]
fn cte_name_pushes_placeholder_binding() {
let (ctes, _) = frame_state_after_walk(
"with cte_x as (select 1) select * from cte_x",
);
let (ctes, _) = frame_state_after_walk("with cte_x as (select 1) select * from cte_x");
assert_eq!(ctes.len(), 1);
assert_eq!(ctes[0].name, "cte_x");
// §10.3 stage-2 harvest produces one CteColumn per
@@ -1984,9 +1937,8 @@ mod tests {
#[test]
fn multiple_ctes_push_in_order() {
let (ctes, _) = frame_state_after_walk(
"with a as (select 1), b as (select 2) select * from b",
);
let (ctes, _) =
frame_state_after_walk("with a as (select 1), b as (select 2) select * from b");
assert_eq!(ctes.len(), 2);
assert_eq!(ctes[0].name, "a");
assert_eq!(ctes[1].name, "b");
@@ -2006,25 +1958,20 @@ mod tests {
#[test]
fn projection_aliases_captured_via_as_form() {
let (_, aliases) = frame_state_after_walk(
"select a as alpha, b as beta from t",
);
let (_, aliases) = frame_state_after_walk("select a as alpha, b as beta from t");
assert_eq!(aliases, vec!["alpha".to_string(), "beta".to_string()]);
}
#[test]
fn projection_aliases_captured_via_bare_form() {
let (_, aliases) = frame_state_after_walk(
"select a alpha, b beta from t",
);
let (_, aliases) = frame_state_after_walk("select a alpha, b beta from t");
assert_eq!(aliases, vec!["alpha".to_string(), "beta".to_string()]);
}
#[test]
fn projection_aliases_mixed_forms() {
let (_, aliases) = frame_state_after_walk(
"select a as alpha, b beta, c, d as delta from t",
);
let (_, aliases) =
frame_state_after_walk("select a as alpha, b beta, c, d as delta from t");
assert_eq!(
aliases,
vec!["alpha".to_string(), "beta".to_string(), "delta".to_string()]
@@ -2033,8 +1980,7 @@ mod tests {
#[test]
fn projection_aliases_empty_when_no_aliases() {
let (_, aliases) =
frame_state_after_walk("select a, b from t");
let (_, aliases) = frame_state_after_walk("select a, b from t");
assert!(aliases.is_empty());
}
@@ -2088,9 +2034,24 @@ mod tests {
s.table_columns.insert(
"users".to_string(),
vec![
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false },
TableColumn { name: "name".to_string(), user_type: Type::Text, not_null: false, has_default: false },
TableColumn { name: "age".to_string(), user_type: Type::Int, not_null: false, has_default: false },
TableColumn {
name: "id".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
},
TableColumn {
name: "name".to_string(),
user_type: Type::Text,
not_null: false,
has_default: false,
},
TableColumn {
name: "age".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
},
],
);
s
@@ -2108,10 +2069,7 @@ mod tests {
assert_eq!(ctes.len(), 1);
assert_eq!(ctes[0].columns.len(), 3);
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("id"));
assert_eq!(
ctes[0].columns[0].type_,
Some(crate::dsl::types::Type::Int),
);
assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),);
assert_eq!(ctes[0].columns[1].name.as_deref(), Some("name"));
assert_eq!(
ctes[0].columns[1].type_,
@@ -2161,10 +2119,7 @@ mod tests {
);
assert_eq!(ctes[0].columns.len(), 1);
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("age"));
assert_eq!(
ctes[0].columns[0].type_,
Some(crate::dsl::types::Type::Int),
);
assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),);
}
#[test]
@@ -2259,15 +2214,9 @@ mod tests {
.expect("outer_cte binding");
assert_eq!(outer.columns.len(), 2);
assert_eq!(outer.columns[0].name.as_deref(), Some("id"));
assert_eq!(
outer.columns[0].type_,
Some(crate::dsl::types::Type::Int),
);
assert_eq!(outer.columns[0].type_, Some(crate::dsl::types::Type::Int),);
assert_eq!(outer.columns[1].name.as_deref(), Some("name"));
assert_eq!(
outer.columns[1].type_,
Some(crate::dsl::types::Type::Text),
);
assert_eq!(outer.columns[1].type_, Some(crate::dsl::types::Type::Text),);
}
#[test]
@@ -2287,15 +2236,9 @@ mod tests {
let b = ctes.iter().find(|c| c.name == "b").expect("b binding");
assert_eq!(b.columns.len(), 2);
assert_eq!(b.columns[0].name.as_deref(), Some("id"));
assert_eq!(
b.columns[0].type_,
Some(crate::dsl::types::Type::Int),
);
assert_eq!(b.columns[0].type_, Some(crate::dsl::types::Type::Int),);
assert_eq!(b.columns[1].name.as_deref(), Some("name"));
assert_eq!(
b.columns[1].type_,
Some(crate::dsl::types::Type::Text),
);
assert_eq!(b.columns[1].type_, Some(crate::dsl::types::Type::Text),);
}
#[test]
@@ -2310,10 +2253,7 @@ mod tests {
);
assert_eq!(ctes[0].columns.len(), 3);
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("a"));
assert_eq!(
ctes[0].columns[0].type_,
Some(crate::dsl::types::Type::Int),
);
assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),);
assert_eq!(ctes[0].columns[1].name.as_deref(), Some("b"));
assert_eq!(
ctes[0].columns[1].type_,
+41 -28
View File
@@ -24,8 +24,8 @@
use crate::dsl::grammar::HighlightClass;
use crate::dsl::walker::context::WalkContext;
use crate::dsl::walker::lex_helpers::{
consume_bare_path, consume_flag, consume_ident, consume_number_literal,
consume_string_literal, skip_whitespace,
consume_bare_path, consume_flag, consume_ident, consume_number_literal, consume_string_literal,
skip_whitespace,
};
use crate::dsl::walker::outcome::{ByteClass, WalkBound};
@@ -47,16 +47,11 @@ pub fn highlight_runs(source: &str) -> Vec<ByteClass> {
/// token, producing the keyword classes the renderer needs to
/// colour `select` / `from` / `where` / `union` / `case` / etc.
#[must_use]
pub fn highlight_runs_in_mode(
source: &str,
mode: crate::mode::Mode,
) -> Vec<ByteClass> {
pub fn highlight_runs_in_mode(source: &str, mode: crate::mode::Mode) -> Vec<ByteClass> {
let mut ctx = WalkContext::new();
ctx.mode = mode;
let (result, _cmd) = super::walk(source, WalkBound::EndOfInput, &mut ctx);
let mut classes: Vec<ByteClass> = result
.map(|r| r.per_byte_class)
.unwrap_or_default();
let mut classes: Vec<ByteClass> = result.map(|r| r.per_byte_class).unwrap_or_default();
let scan_start = classes.last().map_or(0, |c| c.end);
scan_remainder(source, scan_start, &mut classes);
@@ -133,9 +128,7 @@ fn scan_remainder(source: &str, start: usize, classes: &mut Vec<ByteClass>) {
.get(pos + 1)
.copied()
.is_some_and(|c| c.is_ascii_digit()));
if looks_like_number
&& let Some((s, e)) = consume_number_literal(source, pos)
{
if looks_like_number && let Some((s, e)) = consume_number_literal(source, pos) {
classes.push(ByteClass {
start: s,
end: e,
@@ -222,8 +215,14 @@ mod tests {
"no Error highlight on a valid m:n line: {runs:?}"
);
let kinds: Vec<HighlightClass> = runs.iter().map(|(_, _, c)| *c).collect();
assert!(kinds.contains(&HighlightClass::Keyword), "keywords highlighted: {runs:?}");
assert!(kinds.contains(&HighlightClass::Identifier), "table names highlighted: {runs:?}");
assert!(
kinds.contains(&HighlightClass::Keyword),
"keywords highlighted: {runs:?}"
);
assert!(
kinds.contains(&HighlightClass::Identifier),
"table names highlighted: {runs:?}"
);
}
#[test]
@@ -276,10 +275,7 @@ mod tests {
#[test]
fn flag_classified_via_fallback() {
// Walker doesn't engage for a bare `--all-rows`.
assert_eq!(
run("--all-rows"),
vec![(0, 10, HighlightClass::Flag)],
);
assert_eq!(run("--all-rows"), vec![(0, 10, HighlightClass::Flag)],);
}
#[test]
@@ -445,15 +441,13 @@ mod tests {
// dispatcher, so only the entry word would highlight).
let runs = run_advanced("select * from t");
assert!(
runs.iter().any(|(s, e, c)| {
*c == HighlightClass::Keyword && (*s, *e) == (0, 6)
}),
runs.iter()
.any(|(s, e, c)| { *c == HighlightClass::Keyword && (*s, *e) == (0, 6) }),
"expected `select` keyword span 0..6; got {runs:?}",
);
assert!(
runs.iter().any(|(s, e, c)| {
*c == HighlightClass::Keyword && (*s, *e) == (9, 13)
}),
runs.iter()
.any(|(s, e, c)| { *c == HighlightClass::Keyword && (*s, *e) == (9, 13) }),
"expected `from` keyword span 9..13; got {runs:?}",
);
}
@@ -514,18 +508,37 @@ mod tests {
let insert = keywords_of(
"insert into t (a) values (1) on conflict (a) do update set a = excluded.a returning a",
);
for kw in ["insert", "into", "values", "on", "conflict", "do", "update", "set", "returning"] {
assert!(insert.contains(&kw), "INSERT/UPSERT: missing `{kw}`; got {insert:?}");
for kw in [
"insert",
"into",
"values",
"on",
"conflict",
"do",
"update",
"set",
"returning",
] {
assert!(
insert.contains(&kw),
"INSERT/UPSERT: missing `{kw}`; got {insert:?}"
);
}
let update = keywords_of("update t set a = 1 where id = 2 returning a");
for kw in ["update", "set", "where", "returning"] {
assert!(update.contains(&kw), "UPDATE: missing `{kw}`; got {update:?}");
assert!(
update.contains(&kw),
"UPDATE: missing `{kw}`; got {update:?}"
);
}
let delete = keywords_of("delete from t where id = 1 returning *");
for kw in ["delete", "from", "where", "returning"] {
assert!(delete.contains(&kw), "DELETE: missing `{kw}`; got {delete:?}");
assert!(
delete.contains(&kw),
"DELETE: missing `{kw}`; got {delete:?}"
);
}
}
}
+1 -3
View File
@@ -110,9 +110,7 @@ pub fn consume_number_literal(source: &str, start: usize) -> Option<(usize, usiz
return None;
}
let mut i = start;
let leading_minus = bytes[i] == b'-'
&& i + 1 < bytes.len()
&& bytes[i + 1].is_ascii_digit();
let leading_minus = bytes[i] == b'-' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit();
if leading_minus {
i += 1;
}
+430 -559
View File
File diff suppressed because it is too large Load Diff
+110 -38
View File
@@ -14,12 +14,12 @@
//! advanced effective mode (ADR-0037).
use crate::app::EffectiveMode;
use crate::dsl::ReferentialAction;
use crate::dsl::types::Type;
use crate::dsl::Command;
use crate::dsl::ReferentialAction;
use crate::dsl::command::{
ColumnSpec, CompareOp, Constraint, ConstraintKind, Expr, Operand, Predicate, RowFilter,
};
use crate::dsl::types::Type;
use crate::dsl::value::Value;
/// The dimmed `Executing SQL:` prefix on a teaching-echo line
@@ -79,7 +79,12 @@ pub fn echo_for_query(
name,
filter,
limit,
} => Some(vec![render_show_data(name, filter.as_ref(), *limit, primary_key)]),
} => Some(vec![render_show_data(
name,
filter.as_ref(),
*limit,
primary_key,
)]),
_ => None,
}
}
@@ -150,12 +155,12 @@ pub fn command_to_sql(command: &Command) -> Option<String> {
column,
kind,
} => match kind {
ConstraintKind::NotNull => {
Some(format!("ALTER TABLE {table} ALTER COLUMN {column} DROP NOT NULL"))
}
ConstraintKind::Default => {
Some(format!("ALTER TABLE {table} ALTER COLUMN {column} DROP DEFAULT"))
}
ConstraintKind::NotNull => Some(format!(
"ALTER TABLE {table} ALTER COLUMN {column} DROP NOT NULL"
)),
ConstraintKind::Default => Some(format!(
"ALTER TABLE {table} ALTER COLUMN {column} DROP DEFAULT"
)),
// A column-level UNIQUE / CHECK is anonymous in our model —
// no portable name to DROP CONSTRAINT by, so no echo (Bucket C,
// ADR-0035 Amendment 2 residual gap / ADR-0038 §7).
@@ -169,7 +174,10 @@ pub fn command_to_sql(command: &Command) -> Option<String> {
table,
assignments,
filter: RowFilter::AllRows,
} => Some(format!("UPDATE {table} SET {}", render_assignments(assignments))),
} => Some(format!(
"UPDATE {table} SET {}",
render_assignments(assignments)
)),
Command::Delete {
table,
filter: RowFilter::AllRows,
@@ -199,7 +207,13 @@ fn render_create_table(name: &str, columns: &[ColumnSpec], primary_key: &[String
// The same column-constraint suffix `add column` emits (ADR-0029):
// simple-mode `create table` can carry `default` / `check` too, so
// the echo must render them or it is not equivalent (§1 contract).
append_constraints(&mut s, c.not_null, c.unique, c.default.as_ref(), c.check.as_ref());
append_constraints(
&mut s,
c.not_null,
c.unique,
c.default.as_ref(),
c.check.as_ref(),
);
s
})
.collect();
@@ -299,8 +313,10 @@ pub(crate) fn render_create_m2n(
primary_key: &[String],
foreign_keys: &[(Vec<String>, String, Vec<String>)],
) -> String {
let mut parts: Vec<String> =
columns.iter().map(|(n, ty)| format!("{n} {}", ty.keyword())).collect();
let mut parts: Vec<String> = columns
.iter()
.map(|(n, ty)| format!("{n} {}", ty.keyword()))
.collect();
parts.push(format!("PRIMARY KEY ({})", primary_key.join(", ")));
for (child_columns, parent_table, parent_columns) in foreign_keys {
parts.push(format!(
@@ -368,7 +384,12 @@ pub(crate) fn render_add_relationship_create_fk(
) -> Vec<String> {
let mut lines: Vec<String> = new_columns
.iter()
.map(|(col, ty)| format!("ALTER TABLE {child_table} ADD COLUMN {col} {}", ty.keyword()))
.map(|(col, ty)| {
format!(
"ALTER TABLE {child_table} ADD COLUMN {col} {}",
ty.keyword()
)
})
.collect();
lines.push(render_add_relationship(
name,
@@ -461,7 +482,11 @@ fn predicate_to_sql(predicate: &Predicate) -> String {
negated,
} => {
let not = if *negated { "NOT " } else { "" };
format!("{} {not}LIKE {}", operand_to_sql(target), operand_to_sql(pattern))
format!(
"{} {not}LIKE {}",
operand_to_sql(target),
operand_to_sql(pattern)
)
}
Predicate::Between {
target,
@@ -484,7 +509,11 @@ fn predicate_to_sql(predicate: &Predicate) -> String {
} => {
let not = if *negated { "NOT " } else { "" };
let rendered: Vec<String> = items.iter().map(operand_to_sql).collect();
format!("{} {not}IN ({})", operand_to_sql(target), rendered.join(", "))
format!(
"{} {not}IN ({})",
operand_to_sql(target),
rendered.join(", ")
)
}
Predicate::IsNull { target, negated } => {
let not = if *negated { "NOT " } else { "" };
@@ -562,7 +591,10 @@ mod tests {
fn create_table_compound_pk_renders_table_level() {
let cmd = create_table(
"T",
vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)],
vec![
ColumnSpec::new("a", Type::Int),
ColumnSpec::new("b", Type::Int),
],
&["a", "b"],
);
assert_eq!(
@@ -594,7 +626,11 @@ mod tests {
default: Some(Value::Text("A".to_string())),
..ColumnSpec::new("grade", Type::Text)
};
let cmd = create_table("T", vec![ColumnSpec::new("id", Type::Serial), age, grade], &["id"]);
let cmd = create_table(
"T",
vec![ColumnSpec::new("id", Type::Serial), age, grade],
&["id"],
);
let sql = command_to_sql(&cmd).expect("echo");
assert_eq!(
sql,
@@ -625,11 +661,11 @@ mod tests {
check: None,
};
let sql = command_to_sql(&cmd).expect("echo");
assert_eq!(sql, "ALTER TABLE T ADD COLUMN note text NOT NULL DEFAULT 'n/a'");
assert!(matches!(
reparse(&sql),
Ok(Command::SqlAlterTable { .. })
));
assert_eq!(
sql,
"ALTER TABLE T ADD COLUMN note text NOT NULL DEFAULT 'n/a'"
);
assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
}
#[test]
@@ -657,7 +693,10 @@ mod tests {
})),
};
let sql = command_to_sql(&cmd).expect("echo");
assert_eq!(sql, "ALTER TABLE T ADD COLUMN score int UNIQUE CHECK (score >= 0)");
assert_eq!(
sql,
"ALTER TABLE T ADD COLUMN score int UNIQUE CHECK (score >= 0)"
);
assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
}
@@ -1031,7 +1070,10 @@ mod tests {
let lines = render_drop_column_cascade(
"Orders",
"CustId",
&["Orders_CustId_idx".to_string(), "Orders_CustId_Day_idx".to_string()],
&[
"Orders_CustId_idx".to_string(),
"Orders_CustId_Day_idx".to_string(),
],
);
assert_eq!(
lines.as_slice(),
@@ -1043,9 +1085,18 @@ mod tests {
);
// Each line is itself runnable advanced-mode SQL (the §1 contract
// holds per line for category 2).
assert!(matches!(reparse(&lines[0]), Ok(Command::SqlDropIndex { .. })));
assert!(matches!(reparse(&lines[1]), Ok(Command::SqlDropIndex { .. })));
assert!(matches!(reparse(&lines[2]), Ok(Command::SqlAlterTable { .. })));
assert!(matches!(
reparse(&lines[0]),
Ok(Command::SqlDropIndex { .. })
));
assert!(matches!(
reparse(&lines[1]),
Ok(Command::SqlDropIndex { .. })
));
assert!(matches!(
reparse(&lines[2]),
Ok(Command::SqlAlterTable { .. })
));
}
#[test]
@@ -1054,7 +1105,10 @@ mod tests {
// plain `DROP COLUMN` — still semantically equivalent.
let lines = render_drop_column_cascade("T", "c", &[]);
assert_eq!(lines.as_slice(), &["ALTER TABLE T DROP COLUMN c"]);
assert!(matches!(reparse(&lines[0]), Ok(Command::SqlAlterTable { .. })));
assert!(matches!(
reparse(&lines[0]),
Ok(Command::SqlAlterTable { .. })
));
}
#[test]
@@ -1078,8 +1132,14 @@ mod tests {
"ALTER TABLE Orders ADD CONSTRAINT Customers_id_to_Orders_CustId FOREIGN KEY (CustId) REFERENCES Customers (id) ON DELETE CASCADE",
]
);
assert!(matches!(reparse(&lines[0]), Ok(Command::SqlAlterTable { .. })));
assert!(matches!(reparse(&lines[1]), Ok(Command::SqlAlterTable { .. })));
assert!(matches!(
reparse(&lines[0]),
Ok(Command::SqlAlterTable { .. })
));
assert!(matches!(
reparse(&lines[1]),
Ok(Command::SqlAlterTable { .. })
));
}
#[test]
@@ -1116,8 +1176,16 @@ mod tests {
],
&["Students_id".to_string(), "Courses_id".to_string()],
&[
(vec!["Students_id".to_string()], "Students".to_string(), vec!["id".to_string()]),
(vec!["Courses_id".to_string()], "Courses".to_string(), vec!["id".to_string()]),
(
vec!["Students_id".to_string()],
"Students".to_string(),
vec!["id".to_string()],
),
(
vec!["Courses_id".to_string()],
"Courses".to_string(),
vec!["id".to_string()],
),
],
);
assert_eq!(
@@ -1172,8 +1240,14 @@ mod tests {
#[test]
fn value_literal_renders_null_uppercase_and_quotes_text() {
assert_eq!(value_to_sql_literal(&Value::Null), "NULL");
assert_eq!(value_to_sql_literal(&Value::Text("O'Hara".to_string())), "'O''Hara'");
assert_eq!(value_to_sql_literal(&Value::Number("3.14".to_string())), "3.14");
assert_eq!(
value_to_sql_literal(&Value::Text("O'Hara".to_string())),
"'O''Hara'"
);
assert_eq!(
value_to_sql_literal(&Value::Number("3.14".to_string())),
"3.14"
);
assert_eq!(value_to_sql_literal(&Value::Bool(false)), "false");
}
@@ -1258,9 +1332,7 @@ mod tests {
"Command::App({app:?}) is Bucket C — no echo"
);
// Also confirm echo_for gates the same in advanced mode.
assert!(
echo_for(&Command::App(app), EffectiveMode::AdvancedPersistent).is_none(),
);
assert!(echo_for(&Command::App(app), EffectiveMode::AdvancedPersistent).is_none(),);
}
}
+10 -5
View File
@@ -8,9 +8,8 @@
use crossterm::event::KeyEvent;
use crate::db::{
AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult,
DropColumnResult, InsertResult, QueryPlan, RelationshipDiagramData, TableDescription,
UpdateResult,
AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult, DropColumnResult,
InsertResult, QueryPlan, RelationshipDiagramData, TableDescription, UpdateResult,
};
use crate::dsl::Command;
@@ -73,10 +72,16 @@ pub enum AppEvent {
},
/// An `explain …` command succeeded (ADR-0028). `plan`
/// carries the captured query plan; nothing was executed.
DslExplainSucceeded { command: Command, plan: QueryPlan },
DslExplainSucceeded {
command: Command,
plan: QueryPlan,
},
/// A `show <kind>` list command (V5) — carries pre-formatted
/// display lines (tables / relationships / indexes).
DslShowListSucceeded { command: Command, lines: Vec<String> },
DslShowListSucceeded {
command: Command,
lines: Vec<String>,
},
/// `show relationship <name>` (ADR-0044) — structured data for the
/// diagram, rendered App-side; `None` when no such relationship.
DslShowRelationshipSucceeded {
+3 -11
View File
@@ -43,17 +43,11 @@ impl Catalog {
}
}
fn flatten(
value: &serde_norway::Value,
prefix: String,
out: &mut HashMap<String, String>,
) {
fn flatten(value: &serde_norway::Value, prefix: String, out: &mut HashMap<String, String>) {
match value {
serde_norway::Value::Mapping(map) => {
for (k, v) in map {
let k_str = k
.as_str()
.expect("catalog keys must be strings");
let k_str = k.as_str().expect("catalog keys must be strings");
let next = if prefix.is_empty() {
k_str.to_string()
} else {
@@ -85,9 +79,7 @@ pub fn catalog() -> &'static Catalog {
/// See module docs for failure modes.
pub fn translate(key: &str, args: &[(&str, &dyn Display)]) -> String {
let template = catalog().get(key).unwrap_or_else(|| {
panic!(
"missing catalog key: `{key}` (the validator should have caught this)"
);
panic!("missing catalog key: `{key}` (the validator should have caught this)");
});
substitute(template, args, key)
}
+29 -29
View File
@@ -41,8 +41,14 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("diagnostic.alias_used_as_column", &["name"]),
("diagnostic.ambiguous_column", &["column", "qualifiers"]),
("diagnostic.auto_column_overridden", &["column", "type"]),
("diagnostic.compound_arity_mismatch", &["op", "left_n", "right_n"]),
("diagnostic.cte_arity_mismatch", &["cte", "declared", "actual"]),
(
"diagnostic.compound_arity_mismatch",
&["op", "left_n", "right_n"],
),
(
"diagnostic.cte_arity_mismatch",
&["cte", "declared", "actual"],
),
("diagnostic.duplicate_cte", &["name"]),
("diagnostic.eq_null", &[]),
("diagnostic.insert_arity_mismatch", &["expected", "actual"]),
@@ -63,7 +69,10 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
),
("diagnostic.not_null_missing", &["column"]),
("diagnostic.like_numeric", &["column", "type"]),
("diagnostic.projection_alias_misplaced", &["alias", "clause"]),
(
"diagnostic.projection_alias_misplaced",
&["alias", "clause"],
),
("diagnostic.table_used_as_column", &["name"]),
("diagnostic.type_mismatch", &["column", "type"]),
("diagnostic.unknown_column", &["name", "table"]),
@@ -149,10 +158,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
"error.type_mismatch.change_column.headline",
&["table", "column", "src_type", "target_type"],
),
(
"error.type_mismatch.change_column.hint",
&["target_type"],
),
("error.type_mismatch.change_column.hint", &["target_type"]),
(
"error.type_mismatch.insert.headline",
&["value", "expected_type"],
@@ -181,6 +187,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("help.app.quit", &[]),
("help.app.help", &[]),
("help.app.hint", &[]),
("help.app.version", &[]),
("help.app.rebuild", &[]),
("help.app.save", &[]),
("help.app.new", &[]),
@@ -218,10 +225,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("help.data.explain", &[]),
// ---- Hint panel ambient typing assistance (ADR-0022 §6) ----
("hint.ambient_complete", &[]),
(
"hint.ambient_error_with_usage",
&["message", "usage"],
),
("hint.ambient_error_with_usage", &["message", "usage"]),
("hint.ambient_expected", &["expected"]),
("hint.getting_started", &[]),
("hint.block.heading", &[]),
@@ -272,6 +276,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("hint.cmd.help.concept", &[]),
("hint.cmd.hint.what", &[]),
("hint.cmd.hint.example", &[]),
("hint.cmd.version.what", &[]),
("hint.cmd.version.example", &[]),
("hint.cmd.rebuild.what", &[]),
("hint.cmd.rebuild.example", &[]),
("hint.cmd.rebuild.concept", &[]),
@@ -401,10 +407,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("hint.cmd.explain_sql.what", &[]),
("hint.cmd.explain_sql.example", &[]),
("hint.cmd.explain_sql.concept", &[]),
(
"hint.ambient_invalid_ident",
&["kind", "found"],
),
("hint.ambient_invalid_ident", &["kind", "found"]),
("hint.ambient_typing_name", &[]),
// Issue #4: introduce the advanced-mode CREATE TABLE element
// slot (`create table T (`) so the otherwise-invisible
@@ -412,10 +415,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("hint.create_table_element", &[]),
("hint.seed_count", &[]),
("hint.value_literal_slot", &[]),
(
"hint.ambient_typing_name_then",
&["next"],
),
("hint.ambient_typing_name_then", &["next"]),
// Per-column-type value-slot hints (ADR-0024 §Phase D).
("hint.value_slot_blob", &[]),
("hint.value_slot_bool", &[]),
@@ -438,7 +438,10 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("parse.custom.alter_named_unique", &[]),
("parse.custom.bind_type_mismatch", &["found", "expected"]),
("parse.custom.change_column_flags_exclusive", &[]),
("parse.custom.constraint_redundant_on_pk", &["column", "constraint"]),
(
"parse.custom.constraint_redundant_on_pk",
&["column", "constraint"],
),
("parse.custom.create_table_needs_pk", &[]),
("parse.custom.expression_too_deep", &[]),
("parse.custom.insert_form_a_missing_values", &["columns"]),
@@ -486,6 +489,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("parse.usage.mode", &[]),
("parse.usage.new", &[]),
("parse.usage.quit", &[]),
("parse.usage.version", &[]),
("parse.usage.rebuild", &[]),
("parse.usage.redo", &[]),
("parse.usage.replay", &[]),
@@ -572,14 +576,12 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
&["table", "col_count", "col_list", "supplied", "non_auto_csv"],
),
("select.internal_table", &["table"]),
(
"cli.invalid_value",
&["flag", "value", "expected"],
),
("cli.invalid_value", &["flag", "value", "expected"]),
("cli.missing_value", &["flag"]),
("cli.multiple_paths", &["first", "second"]),
("cli.resume_with_path", &[]),
("cli.unknown_argument", &["arg"]),
("cli.version_line", &["version"]),
(
"archive.export_sequence_exhausted",
&["project", "target_dir", "limit"],
@@ -862,8 +864,7 @@ mod tests {
}
}
let declared: HashSet<&str> =
KEYS_AND_PLACEHOLDERS.iter().map(|(k, _)| *k).collect();
let declared: HashSet<&str> = KEYS_AND_PLACEHOLDERS.iter().map(|(k, _)| *k).collect();
for key in cat.keys() {
if key.starts_with("_test.") {
continue;
@@ -885,9 +886,8 @@ mod tests {
/// Mirror of `tests/engine_vocabulary_audit.rs::FORBIDDEN`,
/// duplicated here so the catalog validator is self-contained
/// (no dependency on the integration-test binary).
const FORBIDDEN_ENGINE_VOCABULARY: &[&str] = &[
"SQLite", "sqlite", "rusqlite", "STRICT", "PRAGMA",
];
const FORBIDDEN_ENGINE_VOCABULARY: &[&str] =
&["SQLite", "sqlite", "rusqlite", "STRICT", "PRAGMA"];
/// Detect a `{name:...}` format-specifier placeholder.
/// Doubled braces `{{` / `}}` are escapes — must skip them.
+2 -2
View File
@@ -34,8 +34,8 @@ pub mod keys;
pub mod translate;
pub use error::{DiagnosticTable, FriendlyError};
pub use format::{catalog, Catalog};
pub use translate::{error_hint_class, FailureContext, Operation, TranslateContext, Verbosity};
pub use format::{Catalog, catalog};
pub use translate::{FailureContext, Operation, TranslateContext, Verbosity, error_hint_class};
// `translate::translate` and `format::translate` are different
// callables — the former is the structured DbError → FriendlyError
+12
View File
@@ -162,6 +162,10 @@ error:
# ---- Help text (CLI banner + in-app `help` command) ------------------
# ---- CLI argument-parsing errors (stderr before TUI starts) ---------
cli:
# Version line for `--version` / `-V` and the in-app `version` command
# (ADR-0054). `{version}` is `CARGO_PKG_VERSION` — the single source of
# truth, equal to the `v*` release tag (release CI guards the match).
version_line: "rdbms-playground {version}"
missing_value: "missing value for --{flag}"
invalid_value: "invalid value for --{flag}: {value} (expected one of: {expected})"
unknown_argument: "unknown argument: {arg}"
@@ -186,6 +190,7 @@ help:
Options:
-h, --help Print this help and exit.
-V, --version Print the version and exit.
--theme <light|dark> Override theme (default: auto-detect).
--data-dir <PATH> Use PATH as the data root instead of
the OS-standard location for this run.
@@ -210,6 +215,7 @@ help:
App-level commands (typed inside the app, available in both modes):
quit Exit cleanly.
version Print the application version.
mode simple|advanced Switch input mode.
help Show this list of commands in-app.
save Save the current temp project under a
@@ -258,6 +264,8 @@ help:
help <command> — detailed help for one command (e.g. `help insert`)
hint: |-
hint — explain the most recent error (press F1 for a hint on what you're typing)
version: |-
version — print the application version (same as the `--version` command-line flag)
rebuild: |-
rebuild — rebuild the project database from project.yaml + data/ (with confirmation)
save: |-
@@ -425,6 +433,9 @@ hint:
hint:
what: "Explain the most recent error — or, pressing F1 while typing, the command you're building."
example: "hint"
version:
what: "Print the application version."
example: "version"
rebuild:
what: "Rebuild the project database from its saved text files."
example: "rebuild"
@@ -874,6 +885,7 @@ parse:
quit: "quit"
help: "help [<command>]"
hint: "hint"
version: "version"
rebuild: "rebuild"
save: "save | save as"
new: "new"
+79 -76
View File
@@ -201,11 +201,7 @@ impl TranslateContext {
/// Combine schema-resolved facts with operation and
/// verbosity to build the full translator input.
#[must_use]
pub fn from_facts(
operation: Operation,
verbosity: Verbosity,
facts: FailureContext,
) -> Self {
pub fn from_facts(operation: Operation, verbosity: Verbosity, facts: FailureContext) -> Self {
Self {
operation: Some(operation),
table: facts.table,
@@ -234,15 +230,15 @@ pub fn translate(error: &DbError, ctx: &TranslateContext) -> FriendlyError {
// refusal sites). Catalog entries exist for the typed
// invalid-value cases but the migration sweep
// (ADR-0019 §9) is what wires them. For now, passthrough.
DbError::Unsupported(message) | DbError::InvalidValue(message) => {
passthrough(message)
}
DbError::Unsupported(message) | DbError::InvalidValue(message) => passthrough(message),
DbError::PersistenceFatal { message, .. }
| DbError::RebuildRowFailed { detail: message, .. }
| DbError::RebuildRowFailed {
detail: message, ..
}
| DbError::Io(message) => passthrough(message),
DbError::WorkerGone => passthrough(
"the database worker is no longer available — the application must restart",
),
DbError::WorkerGone => {
passthrough("the database worker is no longer available — the application must restart")
}
};
// Attach the row pinpoint when the runtime resolved one.
// The translator never builds the table itself — it only
@@ -320,11 +316,7 @@ const fn fk_hint_class(ctx: &TranslateContext) -> &'static str {
}
}
fn translate_sqlite(
message: &str,
kind: SqliteErrorKind,
ctx: &TranslateContext,
) -> FriendlyError {
fn translate_sqlite(message: &str, kind: SqliteErrorKind, ctx: &TranslateContext) -> FriendlyError {
// `change column ... --dont-convert` lets the engine
// accept or refuse each cell. Whatever the engine returns
// (constraint, datatype mismatch, …) means "the new type
@@ -392,8 +384,8 @@ fn translate_constraint(message: &str, ctx: &TranslateContext) -> FriendlyError
// ---- UNIQUE -----------------------------------------------------
fn translate_unique(message: &str, ctx: &TranslateContext) -> FriendlyError {
let (table, column) = parse_qualified_target(message)
.unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
let (table, column) =
parse_qualified_target(message).unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
let value = ctx_value(ctx);
match ctx.operation {
Some(Operation::Update) => fe(
@@ -405,11 +397,7 @@ fn translate_unique(message: &str, ctx: &TranslateContext) -> FriendlyError {
),
verbose_hint(
ctx,
t!(
"error.unique.update.hint",
table = table,
column = column
),
t!("error.unique.update.hint", table = table, column = column),
),
),
// Default to the INSERT variant — it's the most common
@@ -425,11 +413,7 @@ fn translate_unique(message: &str, ctx: &TranslateContext) -> FriendlyError {
),
verbose_hint(
ctx,
t!(
"error.unique.insert.hint",
table = table,
column = column
),
t!("error.unique.insert.hint", table = table, column = column),
),
),
}
@@ -542,8 +526,8 @@ fn fk_parent_side_update(ctx: &TranslateContext) -> FriendlyError {
// ---- NOT NULL --------------------------------------------------
fn translate_not_null(message: &str, ctx: &TranslateContext) -> FriendlyError {
let (table, column) = parse_qualified_target(message)
.unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
let (table, column) =
parse_qualified_target(message).unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
match ctx.operation {
Some(Operation::Update) => fe(
t!(
@@ -576,9 +560,17 @@ fn translate_check(_message: &str, ctx: &TranslateContext) -> FriendlyError {
let column = ctx_column(ctx);
let is_update = matches!(ctx.operation, Some(Operation::Update));
let headline = if is_update {
t!("error.check.update.headline", table = table, column = column)
t!(
"error.check.update.headline",
table = table,
column = column
)
} else {
t!("error.check.insert.headline", table = table, column = column)
t!(
"error.check.insert.headline",
table = table,
column = column
)
};
let hint = ctx.check_rule.as_ref().map_or_else(
|| {
@@ -613,8 +605,7 @@ fn translate_check(_message: &str, ctx: &TranslateContext) -> FriendlyError {
// ---- not_found / already_exists --------------------------------
fn translate_not_found_table(message: &str, ctx: &TranslateContext) -> FriendlyError {
let name = parse_after_colon(message)
.map_or_else(|| ctx_table(ctx), str::to_string);
let name = parse_after_colon(message).map_or_else(|| ctx_table(ctx), str::to_string);
headline_only(t!("error.not_found.table.headline", name = name))
}
@@ -656,17 +647,11 @@ fn translate_already_exists(message: &str, ctx: &TranslateContext) -> FriendlyEr
column = column
));
}
return headline_only(t!(
"error.already_exists.table.headline",
name = name
));
return headline_only(t!("error.already_exists.table.headline", name = name));
}
// No backticks — engine-style "table T already exists".
if let Some(name) = parse_after_word(message, "table") {
return headline_only(t!(
"error.already_exists.table.headline",
name = name
));
return headline_only(t!("error.already_exists.table.headline", name = name));
}
if let Some(name) = parse_after_word(message, "relationship") {
return headline_only(t!(
@@ -696,36 +681,25 @@ fn translate_generic(message: &str, ctx: &TranslateContext) -> FriendlyError {
if lower.contains("misuse of aggregate") {
return headline_only(t!("engine.aggregate_misuse", name = "?"));
}
if lower.contains("group by")
|| lower.contains("must appear in")
{
if lower.contains("group by") || lower.contains("must appear in") {
return headline_only(t!("engine.group_by_required"));
}
if (lower.contains("union")
|| lower.contains("intersect")
|| lower.contains("except"))
if (lower.contains("union") || lower.contains("intersect") || lower.contains("except"))
&& lower.contains("result columns")
{
// Last-resort safety net — the pre-flight pass in 2d.1
// catches this in most cases; if the engine surfaces it
// anyway, route it through the engine-neutral key.
return headline_only(t!(
"engine.compound_arity_mismatch",
op = "set operator"
));
return headline_only(t!("engine.compound_arity_mismatch", op = "set operator"));
}
if lower.contains("scalar subquery") || lower.contains("more than one row") {
return headline_only(t!("engine.scalar_subquery_too_many_rows"));
}
if lower.contains("recursive")
&& (lower.contains("cte") || lower.contains("union"))
{
if lower.contains("recursive") && (lower.contains("cte") || lower.contains("union")) {
return headline_only(t!("engine.recursive_cte_malformed"));
}
let operation = ctx
.operation
.map_or("operation", Operation::keyword);
let operation = ctx.operation.map_or("operation", Operation::keyword);
// F2 (ADR-0035 Amendment 1): when no table is in context, use the
// table-less hint so a contextless `friendly_message()` (replay, undo,
// rebuild, export) never renders a literal `{table}` placeholder.
@@ -789,23 +763,33 @@ fn ctx_table(ctx: &TranslateContext) -> String {
}
fn ctx_column(ctx: &TranslateContext) -> String {
ctx.column.clone().unwrap_or_else(|| "the column".to_string())
ctx.column
.clone()
.unwrap_or_else(|| "the column".to_string())
}
fn ctx_value(ctx: &TranslateContext) -> String {
ctx.value.clone().unwrap_or_else(|| "that value".to_string())
ctx.value
.clone()
.unwrap_or_else(|| "that value".to_string())
}
fn ctx_parent_table(ctx: &TranslateContext) -> String {
ctx.parent_table.clone().unwrap_or_else(|| "the referenced table".to_string())
ctx.parent_table
.clone()
.unwrap_or_else(|| "the referenced table".to_string())
}
fn ctx_parent_column(ctx: &TranslateContext) -> String {
ctx.parent_column.clone().unwrap_or_else(|| "the referenced column".to_string())
ctx.parent_column
.clone()
.unwrap_or_else(|| "the referenced column".to_string())
}
fn ctx_child_table(ctx: &TranslateContext) -> String {
ctx.child_table.clone().unwrap_or_else(|| "the referencing table".to_string())
ctx.child_table
.clone()
.unwrap_or_else(|| "the referencing table".to_string())
}
/// Extract `T.col` from a message like
@@ -847,11 +831,7 @@ fn parse_after_word<'a>(message: &'a str, keyword: &str) -> Option<&'a str> {
let rest = message[pos..].trim_start();
let token_end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
let token = rest[..token_end].trim_matches(|c: char| c == '`' || c == '\'');
if token.is_empty() {
None
} else {
Some(token)
}
if token.is_empty() { None } else { Some(token) }
}
#[cfg(test)]
@@ -876,15 +856,24 @@ mod tests {
};
let d = TranslateContext::default;
assert_eq!(
error_hint_class(&sqlite(SqliteErrorKind::NoSuchTable, "no such table: X"), &d()),
error_hint_class(
&sqlite(SqliteErrorKind::NoSuchTable, "no such table: X"),
&d()
),
Some("not_found")
);
assert_eq!(
error_hint_class(&sqlite(SqliteErrorKind::NoSuchColumn, "no such column: X"), &d()),
error_hint_class(
&sqlite(SqliteErrorKind::NoSuchColumn, "no such column: X"),
&d()
),
Some("not_found")
);
assert_eq!(
error_hint_class(&sqlite(SqliteErrorKind::AlreadyExists, "already exists"), &d()),
error_hint_class(
&sqlite(SqliteErrorKind::AlreadyExists, "already exists"),
&d()
),
Some("already_exists")
);
assert_eq!(
@@ -933,13 +922,19 @@ mod tests {
parent_table: Some("Parent".to_string()),
..TranslateContext::default()
};
assert_eq!(error_hint_class(&fk(), &ctx), Some("foreign_key.child_side"));
assert_eq!(
error_hint_class(&fk(), &ctx),
Some("foreign_key.child_side")
);
// child_table populated → parent-side.
let ctx = TranslateContext {
child_table: Some("Child".to_string()),
..TranslateContext::default()
};
assert_eq!(error_hint_class(&fk(), &ctx), Some("foreign_key.parent_side"));
assert_eq!(
error_hint_class(&fk(), &ctx),
Some("foreign_key.parent_side")
);
// No enrichment: operation is the tiebreaker.
assert_eq!(
error_hint_class(&fk(), &ctx_with(Operation::Delete)),
@@ -1049,14 +1044,22 @@ mod tests {
ctx.parent_column = Some("country, code".to_string());
ctx.value = Some("7, 8".to_string());
let f = translate(&err, &ctx);
assert!(f.headline.contains("no parent row"), "child-side: {}", f.headline);
assert!(
f.headline.contains("no parent row"),
"child-side: {}",
f.headline
);
assert!(f.headline.contains("Region"));
assert!(
f.headline.contains("country, code"),
"both parent columns must appear: {}",
f.headline
);
assert!(f.headline.contains("`7, 8`"), "joined value: {}", f.headline);
assert!(
f.headline.contains("`7, 8`"),
"joined value: {}",
f.headline
);
}
#[test]
+139 -143
View File
@@ -25,9 +25,9 @@
use ratatui::style::{Color, Modifier, Style};
use crate::dsl::parser::{parse_command_with_schema, parse_command_with_schema_in_mode};
use crate::mode::Mode;
use crate::dsl::walker;
use crate::dsl::{ParseError, parse_command};
use crate::mode::Mode;
use crate::theme::Theme;
/// A run of text with its byte range in the source and the
@@ -85,7 +85,16 @@ pub fn render_input_runs_in_mode(
mode: Mode,
) -> Vec<StyledRun> {
// Identity feedback view — highlight/overlay the whole input.
render_input_runs_feedback(input, cursor_byte, theme, cache, mode, input, cursor_byte, 0)
render_input_runs_feedback(
input,
cursor_byte,
theme,
cache,
mode,
input,
cursor_byte,
0,
)
}
/// [`render_input_runs_in_mode`] with a separate **feedback view** for
@@ -121,12 +130,14 @@ pub fn render_input_runs_feedback(
byte_range: (0, offset),
style: ratatui::style::Style::default().fg(theme.fg),
}];
r.extend(lex_to_runs_in_mode(view, theme, mode).into_iter().map(|run| {
StyledRun {
byte_range: (run.byte_range.0 + offset, run.byte_range.1 + offset),
..run
}
}));
r.extend(
lex_to_runs_in_mode(view, theme, mode)
.into_iter()
.map(|run| StyledRun {
byte_range: (run.byte_range.0 + offset, run.byte_range.1 + offset),
..run
}),
);
r
};
if let InputState::DefiniteErrorAt(pos) =
@@ -150,7 +161,11 @@ pub fn render_input_runs_feedback(
walker::Severity::Error => theme.tok_error,
walker::Severity::Warning => theme.warning,
};
overlay_span(&mut runs, (diag.span.0 + offset, diag.span.1 + offset), colour);
overlay_span(
&mut runs,
(diag.span.0 + offset, diag.span.1 + offset),
colour,
);
}
inject_cursor(&mut runs, input, cursor_byte, theme);
runs
@@ -234,9 +249,7 @@ pub fn classify_input_with_schema_in_mode(
))
}
fn classify_parse_result(
result: Result<crate::dsl::Command, ParseError>,
) -> InputState {
fn classify_parse_result(result: Result<crate::dsl::Command, ParseError>) -> InputState {
match result {
Ok(_) => InputState::Valid,
Err(ParseError::Empty) => InputState::Empty,
@@ -372,8 +385,7 @@ pub fn advanced_alternative_note(
// carries a blocking ERROR diagnostic such as a value-count
// mismatch. Incomplete input (still being typed) and empty input are
// excluded so the pointer doesn't flicker mid-keystroke.
let definite_dsl_error = match classify_input_with_schema_in_mode(input, cache, Mode::Simple)
{
let definite_dsl_error = match classify_input_with_schema_in_mode(input, cache, Mode::Simple) {
InputState::DefiniteErrorAt(_) => true,
InputState::Valid => {
crate::dsl::walker::input_verdict_in_mode(input, Some(cache), Mode::Simple)
@@ -386,8 +398,7 @@ pub fn advanced_alternative_note(
}
// The validity-verdict-driven gate (ADR-0033 Amendment 5): the
// line must be fully valid (verdict `None`) in advanced mode.
if crate::dsl::walker::input_verdict_in_mode(input, Some(cache), Mode::Advanced).is_some()
{
if crate::dsl::walker::input_verdict_in_mode(input, Some(cache), Mode::Advanced).is_some() {
return None;
}
Some(crate::t!("advanced_mode.also_valid_sql"))
@@ -714,8 +725,7 @@ fn ambient_hint_core_in_mode(
// narrows column candidates to the active table and runs the
// §10.6 look-ahead, so it is the authoritative "what can go
// here" set.
let completion =
crate::completion::candidates_at_cursor_in_mode(input, cursor, cache, mode);
let completion = crate::completion::candidates_at_cursor_in_mode(input, cursor, cache, mode);
// Schema-aware diagnostics (ADR-0027 §2). `input_diagnostics`
// is non-empty only for a command that *structurally parses*
@@ -834,7 +844,9 @@ fn ambient_hint_core_in_mode(
// keyword set.
return Some(AmbientHint::Prose(crate::friendly::translate(key, &[])));
}
Some(crate::dsl::grammar::HintMode::SuppressProse | crate::dsl::grammar::HintMode::Default)
Some(
crate::dsl::grammar::HintMode::SuppressProse | crate::dsl::grammar::HintMode::Default,
)
| None => {}
}
@@ -855,7 +867,8 @@ fn ambient_hint_core_in_mode(
// Invalid identifier: cursor sits in a known-set slot but
// the typed prefix matches nothing in the schema. (Stage
// 8e / the user's #5.)
if let Some(inv) = crate::completion::invalid_ident_at_cursor_in_mode(input, cursor, cache, mode)
if let Some(inv) =
crate::completion::invalid_ident_at_cursor_in_mode(input, cursor, cache, mode)
{
let kind = match inv.source {
crate::dsl::grammar::IdentSource::Tables => "table",
@@ -1036,11 +1049,7 @@ pub fn lex_to_runs(input: &str, theme: &Theme) -> Vec<StyledRun> {
/// with `Mode::Advanced` so SQL keywords past the entry word
/// match and get highlighted (ADR-0030 §8).
#[must_use]
pub fn lex_to_runs_in_mode(
input: &str,
theme: &Theme,
mode: Mode,
) -> Vec<StyledRun> {
pub fn lex_to_runs_in_mode(input: &str, theme: &Theme, mode: Mode) -> Vec<StyledRun> {
base_runs(input, theme, mode)
}
@@ -1076,12 +1085,7 @@ fn base_runs(input: &str, theme: &Theme, mode: Mode) -> Vec<StyledRun> {
runs
}
fn inject_cursor(
runs: &mut Vec<StyledRun>,
input: &str,
cursor_byte: usize,
theme: &Theme,
) {
fn inject_cursor(runs: &mut Vec<StyledRun>, input: &str, cursor_byte: usize, theme: &Theme) {
let cursor_byte = cursor_byte.min(input.len());
// End-of-input cursor: append the empty-range sentinel.
@@ -1164,9 +1168,10 @@ mod tests {
let mut cache = SchemaCache::default();
cache.tables.push("Customers".into());
cache.columns.push("name".into());
cache
.table_columns
.insert("Customers".into(), vec![TableColumn::new("name", Type::Text)]);
cache.table_columns.insert(
"Customers".into(),
vec![TableColumn::new("name", Type::Text)],
);
let input = ": select name from Customers";
let view = "select name from Customers";
let offset = 2; // ": "
@@ -1362,9 +1367,10 @@ mod tests {
let mut cache = crate::completion::SchemaCache::default();
cache.tables.push("users".to_string());
cache.columns.push("email".to_string());
cache
.table_columns
.insert("users".to_string(), vec![TableColumn::new("email", Type::Text)]);
cache.table_columns.insert(
"users".to_string(),
vec![TableColumn::new("email", Type::Text)],
);
cache
}
@@ -1392,7 +1398,10 @@ mod tests {
}
// Tab candidates remain available (completion is independent).
let comp = crate::completion::candidates_at_cursor_in_mode(
input, input.len(), &cache, Mode::Simple,
input,
input.len(),
&cache,
Mode::Simple,
)
.expect("completion remains available");
let texts: Vec<&str> = comp.candidates.iter().map(|c| c.text.as_str()).collect();
@@ -1424,7 +1433,10 @@ mod tests {
&hint,
Some(AmbientHint::Prose(p)) if p.contains("row count")
);
assert!(!is_count_prose, "count hint must not show for {input:?}; got {hint:?}");
assert!(
!is_count_prose,
"count hint must not show for {input:?}; got {hint:?}"
);
}
}
@@ -1502,14 +1514,12 @@ mod tests {
use crate::dsl::types::Type;
let mut cache = SchemaCache::default();
cache.tables.push("Customers".to_string());
let tc = vec![
TableColumn {
name: "Age".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
},
];
let tc = vec![TableColumn {
name: "Age".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
}];
for c in &tc {
cache.columns.push(c.name.clone());
}
@@ -1551,9 +1561,7 @@ mod tests {
p.contains("No such") && p.contains("Agx"),
"a genuine column typo before FROM must warn at typing time; got: {p:?}",
),
other => panic!(
"`select Agx` must surface a typing-time typo hint; got: {other:?}",
),
other => panic!("`select Agx` must surface a typing-time typo hint; got: {other:?}",),
}
}
@@ -1652,8 +1660,7 @@ mod tests {
// ADR-0022 Amendment 1: advanced-mode ambient assistance
// surfaces SQL completion candidates (here the FROM-slot
// table) instead of the simple-mode "this is SQL" gate.
let cache =
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let input = "select * from ";
match ambient_hint_in_mode(
input,
@@ -1678,10 +1685,7 @@ mod tests {
// `INSERT … (` column list. (The simple-mode DSL value-slot
// prose is a separate surface; this pins the §8 advanced claim.)
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
let set_slot = "update Customers set ";
match ambient_hint_in_mode(set_slot, set_slot.len(), None, &cache, Mode::Advanced) {
@@ -1706,16 +1710,10 @@ mod tests {
fn simple_mode_ambient_does_not_surface_sql_candidates() {
// The simple-mode entry point keeps gating SQL — advanced
// assistance is opt-in via mode, never leaked into simple.
let cache =
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let input = "select * from ";
let hint = ambient_hint_in_mode(
input,
input.len(),
None,
&cache,
crate::mode::Mode::Simple,
);
let hint =
ambient_hint_in_mode(input, input.len(), None, &cache, crate::mode::Mode::Simple);
let offers_table = matches!(
&hint,
Some(AmbientHint::Candidates { items, .. })
@@ -1733,8 +1731,7 @@ mod tests {
fn f1_mid_typed_table_prefix_shows_completion_not_error() {
// "select * from c" — `c` prefix-matches `Customers`. The
// hint must offer the completion, not "no such table c".
let cache =
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
match ambient_hint_in_mode(
"select * from c",
"select * from c".len(),
@@ -1753,8 +1750,7 @@ mod tests {
#[test]
fn f1_genuinely_unknown_table_still_shows_error() {
// "zzz" matches no table prefix — the error must still show.
let cache =
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
match ambient_hint_in_mode(
"select * from zzz",
"select * from zzz".len(),
@@ -1773,8 +1769,7 @@ mod tests {
fn f1_simple_mode_dsl_mid_typed_table_completes() {
// The same shadowing affects DSL commands in simple mode:
// "show data c" must offer Customers, not "no such table c".
let cache =
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
match ambient_hint_in_mode(
"show data c",
"show data c".len(),
@@ -1804,7 +1799,12 @@ mod tests {
cache.columns.push("order_col".to_string());
cache.table_columns.insert(
"Orders".to_string(),
vec![TableColumn { name: "order_col".to_string(), user_type: Type::Int, not_null: false, has_default: false }],
vec![TableColumn {
name: "order_col".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
}],
);
let comp = crate::completion::candidates_at_cursor_in_mode(
@@ -1846,9 +1846,7 @@ mod tests {
for c in &columns {
cache.columns.push(c.name.clone());
}
cache
.table_columns
.insert(table.to_string(), columns);
cache.table_columns.insert(table.to_string(), columns);
cache
}
@@ -1860,7 +1858,11 @@ mod tests {
("Customers", &[("id", Type::Serial), ("name", Type::Text)]),
(
"Products",
&[("id", Type::Serial), ("name", Type::Text), ("price", Type::Decimal)],
&[
("id", Type::Serial),
("name", Type::Text),
("price", Type::Decimal),
],
),
(
"OrderLines",
@@ -1873,13 +1875,19 @@ mod tests {
),
(
"Orders",
&[("id", Type::Serial), ("customer_id", Type::Int), ("date", Type::Date)],
&[
("id", Type::Serial),
("customer_id", Type::Int),
("date", Type::Date),
],
),
];
for (t, cols) in tables {
cache.tables.push((*t).to_string());
let tc: Vec<TableColumn> =
cols.iter().map(|(n, ty)| TableColumn::new(*n, *ty)).collect();
let tc: Vec<TableColumn> = cols
.iter()
.map(|(n, ty)| TableColumn::new(*n, *ty))
.collect();
for c in &tc {
cache.columns.push(c.name.clone());
}
@@ -1914,17 +1922,11 @@ mod tests {
#[test]
fn ambient_hint_at_insert_first_value_shows_int_prose() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
let input = "insert into Customers values (";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
assert!(
p.contains("integer"),
"expected int-slot prose, got: {p:?}",
);
assert!(p.contains("integer"), "expected int-slot prose, got: {p:?}",);
}
other => panic!("expected Prose, got {other:?}"),
}
@@ -1942,7 +1944,11 @@ mod tests {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Serial), ("Name", Type::Text), ("Email", Type::Text)],
&[
("id", Type::Serial),
("Name", Type::Text),
("Email", Type::Text),
],
);
let input = "insert into Customers values (1, 'Alice', 'a@b.c')";
match ambient_hint(input, input.len(), None, &cache) {
@@ -1966,10 +1972,7 @@ mod tests {
// A valid simple-mode DSL command gets no advanced pointer —
// it isn't an error, and there is nothing to switch modes for.
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Serial), ("Name", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Serial), ("Name", Type::Text)]);
let input = "insert into Customers values ('Alice')";
if let Some(AmbientHint::Prose(p)) = ambient_hint(input, input.len(), None, &cache) {
assert!(
@@ -2010,10 +2013,7 @@ mod tests {
#[test]
fn ambient_hint_at_insert_second_value_shows_text_prose() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
let input = "insert into Customers values (1, ";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
@@ -2029,10 +2029,8 @@ mod tests {
#[test]
fn ambient_hint_at_update_set_shows_per_column_prose() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Birthday", Type::Date)],
);
let cache =
schema_with_columns("Customers", &[("id", Type::Int), ("Birthday", Type::Date)]);
let input = "update Customers set Birthday=";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
@@ -2057,9 +2055,7 @@ mod tests {
// hasn't committed), the Optional propagates Incomplete
// and the user sees no error overlay until they submit.
assert_eq!(
classify_input(
"insert into Orders (id, CustId, Total) values (42, 89, 17.59"
),
classify_input("insert into Orders (id, CustId, Total) values (42, 89, 17.59"),
InputState::IncompleteAtEof,
);
assert_eq!(
@@ -2071,18 +2067,12 @@ mod tests {
#[test]
fn ambient_hint_at_insert_first_value_mentions_column_name() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
let input = "insert into Customers values (";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
assert!(p.contains("id"), "expected column name `id`, got {p:?}");
assert!(
p.contains("integer"),
"expected int prose, got {p:?}",
);
assert!(p.contains("integer"), "expected int prose, got {p:?}",);
}
other => panic!("expected Prose, got {other:?}"),
}
@@ -2091,10 +2081,7 @@ mod tests {
#[test]
fn ambient_hint_at_update_set_mentions_column_name() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
let input = "update Customers set Email=";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
@@ -2131,10 +2118,7 @@ mod tests {
#[test]
fn ambient_hint_at_second_insert_value_mentions_second_column() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
let input = "insert into Customers values (1, ";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
@@ -2165,14 +2149,20 @@ mod tests {
use crate::dsl::types::Type;
let cases: &[(&[(&str, Type)], &str)] = &[
// string first value (the report's case): first col text.
(&[("Name", Type::Text), ("Age", Type::Int)],
"insert into Customers values ('Oli'"),
(
&[("Name", Type::Text), ("Age", Type::Int)],
"insert into Customers values ('Oli'",
),
// integer first value: first col int.
(&[("Age", Type::Int), ("Name", Type::Text)],
"insert into Customers values (42"),
(
&[("Age", Type::Int), ("Name", Type::Text)],
"insert into Customers values (42",
),
// real first value: first col real.
(&[("Score", Type::Real), ("Name", Type::Text)],
"insert into Customers values (3.5"),
(
&[("Score", Type::Real), ("Name", Type::Text)],
"insert into Customers values (3.5",
),
];
for (cols, input) in cases {
let cache = schema_with_columns("Customers", cols);
@@ -2232,10 +2222,7 @@ mod tests {
// is nothing left to fill. Guards against over-correcting the
// fix into never suggesting the close paren.
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("Name", Type::Text), ("Age", Type::Int)],
);
let cache = schema_with_columns("Customers", &[("Name", Type::Text), ("Age", Type::Int)]);
let input = "insert into Customers values ('Oli', 52";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
@@ -2384,10 +2371,7 @@ mod tests {
match ambient_hint("show data Missing", 17, None, &cache) {
Some(AmbientHint::Prose(p)) => {
assert!(p.contains("Missing"), "got {p:?}");
assert!(
p.to_lowercase().contains("no such table"),
"got {p:?}",
);
assert!(p.to_lowercase().contains("no such table"), "got {p:?}",);
}
other => panic!("expected Prose, got {other:?}"),
}
@@ -2440,8 +2424,7 @@ mod tests {
use crate::dsl::types::Type;
// Two type-mismatch WARNINGs; the hint names the column
// whose offending literal the cursor sits in.
let cache =
schema_with_columns("Events", &[("a", Type::Int), ("b", Type::Int)]);
let cache = schema_with_columns("Events", &[("a", Type::Int), ("b", Type::Int)]);
let input = "delete from Events where a = 'x' or b = 'y'";
let on_x = input.find("'x'").expect("'x' literal") + 1;
let on_y = input.find("'y'").expect("'y' literal") + 1;
@@ -2460,8 +2443,16 @@ mod tests {
inserted_range: (5, 5),
original_text: String::new(),
candidates: vec![
Candidate { text: "data".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both },
Candidate { text: "table".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both },
Candidate {
text: "data".to_string(),
kind: CandidateKind::Keyword,
mode: crate::completion::ModeClass::Both,
},
Candidate {
text: "table".to_string(),
kind: CandidateKind::Keyword,
mode: crate::completion::ModeClass::Both,
},
],
selection_idx: 1,
};
@@ -2494,8 +2485,16 @@ mod tests {
// produce — proves the memo's list is being used,
// not a recomputed one.
candidates: vec![
Candidate { text: "data".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both },
Candidate { text: "table".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both },
Candidate {
text: "data".to_string(),
kind: CandidateKind::Keyword,
mode: crate::completion::ModeClass::Both,
},
Candidate {
text: "table".to_string(),
kind: CandidateKind::Keyword,
mode: crate::completion::ModeClass::Both,
},
],
selection_idx: 1,
};
@@ -2564,10 +2563,7 @@ mod tests {
fn classify_trailing_whitespace_does_not_create_definite_error() {
// Trailing whitespace alone shouldn't promote an
// incomplete-at-EOF state into a definite error.
assert_eq!(
classify_input("create "),
InputState::IncompleteAtEof,
);
assert_eq!(classify_input("create "), InputState::IncompleteAtEof,);
}
#[test]
+3 -4
View File
@@ -60,8 +60,8 @@ pub fn init(path: Option<&Path>) -> Result<PathBuf> {
.with_context(|| format!("create log directory {}", parent.display()))?;
}
let file = open_log_file(&chosen)?;
let filter = EnvFilter::try_from_env("RDBMS_PLAYGROUND_LOG")
.unwrap_or_else(|_| EnvFilter::new("info"));
let filter =
EnvFilter::try_from_env("RDBMS_PLAYGROUND_LOG").unwrap_or_else(|_| EnvFilter::new("info"));
let layer = fmt::layer()
.with_writer(file)
.with_ansi(false)
@@ -95,8 +95,7 @@ fn home_dir() -> Option<PathBuf> {
if let Some(p) = std::env::var_os("HOME") {
return Some(PathBuf::from(p));
}
if let (Some(drive), Some(path)) =
(std::env::var_os("HOMEDRIVE"), std::env::var_os("HOMEPATH"))
if let (Some(drive), Some(path)) = (std::env::var_os("HOMEDRIVE"), std::env::var_os("HOMEPATH"))
{
let mut combined = PathBuf::from(drive);
combined.push(path);
+6 -1
View File
@@ -1,6 +1,6 @@
use std::process::ExitCode;
use rdbms_playground::cli::{help_text, Args};
use rdbms_playground::cli::{Args, help_text, version_text};
use rdbms_playground::{logging, runtime};
fn main() -> ExitCode {
@@ -22,6 +22,11 @@ fn main() -> ExitCode {
}
};
if args.version {
println!("{}", version_text());
return ExitCode::SUCCESS;
}
if args.help {
print!("{}", help_text());
return ExitCode::SUCCESS;
+69 -42
View File
@@ -172,7 +172,10 @@ fn constraint_lines(desc: &TableDescription) -> Vec<String> {
/// A `detail` matching no marker renders neutral — the engine's
/// plan vocabulary may grow (ADR-0028 §4).
const PLAN_TAXONOMY: &[(&str, OutputStyleClass)] = &[
("USING AUTOMATIC COVERING INDEX", OutputStyleClass::AutomaticIndex),
(
"USING AUTOMATIC COVERING INDEX",
OutputStyleClass::AutomaticIndex,
),
("USING AUTOMATIC INDEX", OutputStyleClass::AutomaticIndex),
("USING COVERING INDEX", OutputStyleClass::Efficient),
("USING INTEGER PRIMARY KEY", OutputStyleClass::Efficient),
@@ -225,8 +228,7 @@ fn render_plan_subtree(
emitted: &mut HashSet<i64>,
mode: Mode,
) {
let children: Vec<&ExplainRow> =
rows.iter().filter(|r| r.parent == parent).collect();
let children: Vec<&ExplainRow> = rows.iter().filter(|r| r.parent == parent).collect();
let last_idx = children.len().saturating_sub(1);
for (idx, row) in children.iter().enumerate() {
if !emitted.insert(row.id) {
@@ -235,8 +237,7 @@ fn render_plan_subtree(
let is_last = idx == last_idx;
let connector = if is_last { "└─ " } else { "├─ " };
out.push(plan_node_line(prefix, connector, &row.detail, mode));
let child_prefix =
format!("{prefix}{}", if is_last { " " } else { "" });
let child_prefix = format!("{prefix}{}", if is_last { " " } else { "" });
render_plan_subtree(rows, row.id, &child_prefix, out, emitted, mode);
}
}
@@ -343,13 +344,8 @@ pub fn render_diagnostic_table(
const fn alignment_for(ty: Option<Type>) -> Alignment {
match ty {
Some(Type::Int | Type::Real | Type::Decimal | Type::Serial) => Alignment::Right,
Some(Type::Text)
| Some(Type::Bool)
| Some(Type::Date)
| Some(Type::DateTime)
| Some(Type::Blob)
| Some(Type::ShortId)
| None => Alignment::Left,
Some(Type::Text) | Some(Type::Bool) | Some(Type::Date) | Some(Type::DateTime)
| Some(Type::Blob) | Some(Type::ShortId) | None => Alignment::Left,
}
}
@@ -406,11 +402,7 @@ fn cell_width(s: &str) -> usize {
/// Render a single bordered table given header cells, body
/// rows, and per-column alignment. Outer frame +
/// header-underline only.
fn render_table(
headers: &[String],
body: &[Vec<String>],
alignments: &[Alignment],
) -> Vec<String> {
fn render_table(headers: &[String], body: &[Vec<String>], alignments: &[Alignment]) -> Vec<String> {
debug_assert_eq!(headers.len(), alignments.len());
// Compute column widths: max(header, all body cells).
@@ -792,13 +784,12 @@ fn gutter_seg(i: usize, child_rows: &[usize], parent_rows: &[usize], w: usize) -
}
// The vertical bus spans the full range of endpoint rows.
let bounds = child_rows
.iter()
.chain(parent_rows)
.copied()
.fold(None, |acc: Option<(usize, usize)>, r| {
let bounds = child_rows.iter().chain(parent_rows).copied().fold(
None,
|acc: Option<(usize, usize)>, r| {
Some(acc.map_or((r, r), |(lo, hi)| (lo.min(r), hi.max(r))))
});
},
);
if let Some((top, bot)) = bounds
&& i >= top
&& i <= bot
@@ -1138,7 +1129,10 @@ mod tests {
assert!(out.contains("customer_id ●"), "FK marker:\n{out}");
assert!(out.contains("id (PK) ●"), "parent endpoint marker:\n{out}");
assert!(out.contains('▶'), "arrowhead:\n{out}");
assert!(out.contains('n') && out.contains('1'), "cardinality:\n{out}");
assert!(
out.contains('n') && out.contains('1'),
"cardinality:\n{out}"
);
assert!(
out.contains("on delete cascade · on update no action"),
"actions:\n{out}"
@@ -1237,7 +1231,10 @@ mod tests {
let (r_out, r_in) = blank_rels();
let region = TableDescription {
name: "Region".to_string(),
columns: vec![col("country", Type::Int, true, false), col("code", Type::Int, true, false)],
columns: vec![
col("country", Type::Int, true, false),
col("code", Type::Int, true, false),
],
outbound_relationships: r_out,
inbound_relationships: r_in,
indexes: Vec::new(),
@@ -1277,7 +1274,10 @@ mod tests {
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("region_code ●"), "child endpoint 2:\n{text}");
assert!(text.contains("(PK) ●"), "parent endpoint is PK + marked:\n{text}");
assert!(
text.contains("(PK) ●"),
"parent endpoint is PK + marked:\n{text}"
);
assert!(
text.contains("(country, region_code) ▶ Region.(country, code)"),
"pairing line:\n{text}",
@@ -1412,11 +1412,7 @@ mod tests {
let data = DataResult {
table_name: "Customers".to_string(),
columns: vec!["id".to_string(), "Name".to_string(), "Email".to_string()],
column_types: vec![
Some(Type::Serial),
Some(Type::Text),
Some(Type::Text),
],
column_types: vec![Some(Type::Serial), Some(Type::Text), Some(Type::Text)],
rows: vec![
vec![
Some("1".to_string()),
@@ -1634,7 +1630,10 @@ mod tests {
assert!(out.contains("Indexes:"), "got:\n{out}");
assert!(out.contains("idx_email (Email)"), "got:\n{out}");
// A plain index carries no uniqueness marker.
assert!(!out.contains("[unique]"), "plain index unmarked; got:\n{out}");
assert!(
!out.contains("[unique]"),
"plain index unmarked; got:\n{out}"
);
}
#[test]
@@ -1677,7 +1676,10 @@ mod tests {
indexes: Vec::new(),
unique_constraints: vec![vec!["a".to_string(), "b".to_string()]],
check_constraints: vec![
crate::persistence::TableCheck { name: None, expr: "a < b".to_string() },
crate::persistence::TableCheck {
name: None,
expr: "a < b".to_string(),
},
crate::persistence::TableCheck {
name: Some("a_lt_b".to_string()),
expr: "a <> b".to_string(),
@@ -1691,7 +1693,10 @@ mod tests {
// (ADR-0035 Amendment 1) so the user can `drop constraint <name>`.
assert!(out.contains("unique_a_b: unique (a, b)"), "got:\n{out}");
assert!(out.contains("check (a < b)"), "unnamed check; got:\n{out}");
assert!(out.contains("check a_lt_b (a <> b)"), "named check shows its name; got:\n{out}");
assert!(
out.contains("check a_lt_b (a <> b)"),
"named check shows its name; got:\n{out}"
);
}
#[test]
@@ -1732,17 +1737,37 @@ mod tests {
let plan = QueryPlan {
display_sql: "SELECT 1".to_string(),
rows: vec![
ExplainRow { id: 1, parent: 0, detail: "root".to_string() },
ExplainRow { id: 2, parent: 1, detail: "child-a".to_string() },
ExplainRow { id: 3, parent: 1, detail: "child-b".to_string() },
ExplainRow {
id: 1,
parent: 0,
detail: "root".to_string(),
},
ExplainRow {
id: 2,
parent: 1,
detail: "child-a".to_string(),
},
ExplainRow {
id: 3,
parent: 1,
detail: "child-b".to_string(),
},
],
};
let lines = render_explain_plan(&plan, Mode::Simple);
// display SQL + 3 plan nodes.
assert_eq!(lines.len(), 4);
assert!(lines[1].text.contains("root"));
assert!(lines[2].text.contains("├─ child-a"), "got {:?}", lines[2].text);
assert!(lines[3].text.contains("─ child-b"), "got {:?}", lines[3].text);
assert!(
lines[2].text.contains("─ child-a"),
"got {:?}",
lines[2].text
);
assert!(
lines[3].text.contains("└─ child-b"),
"got {:?}",
lines[3].text
);
// The single root uses `└─`; its children are indented
// by three spaces (no `│` spine, the root being last).
assert!(lines[1].text.starts_with("└─ root"));
@@ -1775,7 +1800,10 @@ mod tests {
fn render_explain_plan_colours_a_full_scan_expensive() {
let plan = one_node_plan("SCAN Customers");
let lines = render_explain_plan(&plan, Mode::Simple);
assert_eq!(span_class_for(&lines[1], "SCAN"), OutputStyleClass::Expensive);
assert_eq!(
span_class_for(&lines[1], "SCAN"),
OutputStyleClass::Expensive
);
// The table name stays neutral (ADR-0028 §6).
assert_eq!(
span_class_for(&lines[1], "Customers"),
@@ -1801,8 +1829,7 @@ mod tests {
#[test]
fn render_explain_plan_flags_an_automatic_index() {
let plan =
one_node_plan("SEARCH Orders USING AUTOMATIC COVERING INDEX (CustId=?)");
let plan = one_node_plan("SEARCH Orders USING AUTOMATIC COVERING INDEX (CustId=?)");
let lines = render_explain_plan(&plan, Mode::Simple);
assert_eq!(
span_class_for(&lines[1], "USING AUTOMATIC COVERING INDEX"),
+29 -13
View File
@@ -150,7 +150,9 @@ fn encode_cell(ty: Type, value: &CellValue) -> Result<Cell, String> {
other => Err(format!("expected date/datetime (text), got {other:?}")),
},
Type::Blob => match value {
CellValue::Blob(bytes) => Ok(Cell::Plain(base64::engine::general_purpose::STANDARD.encode(bytes))),
CellValue::Blob(bytes) => Ok(Cell::Plain(
base64::engine::general_purpose::STANDARD.encode(bytes),
)),
other => Err(format!("expected blob, got {other:?}")),
},
Type::Serial => match value {
@@ -169,7 +171,11 @@ fn format_real(f: f64) -> String {
if f.is_nan() {
"nan".to_string()
} else if f.is_infinite() {
if f > 0.0 { "inf".to_string() } else { "-inf".to_string() }
if f > 0.0 {
"inf".to_string()
} else {
"-inf".to_string()
}
} else {
// Default `{}` formatting on f64 emits a shortest
// round-tripping decimal — exactly what the ADR asks
@@ -318,8 +324,7 @@ fn parse_field(bytes: &[u8]) -> Result<(RawCell, usize), CsvError> {
_ => i += 1,
}
}
let content =
String::from_utf8(bytes[..i].to_vec()).map_err(|_| CsvError::InvalidUtf8)?;
let content = String::from_utf8(bytes[..i].to_vec()).map_err(|_| CsvError::InvalidUtf8)?;
Ok((
RawCell {
content,
@@ -435,7 +440,10 @@ mod tests {
name: "T".to_string(),
columns: vec![col("n", Type::Int), col("r", Type::Real)],
rows: vec![
vec![CellValue::Integer(42), CellValue::Real(std::f64::consts::PI)],
vec![
CellValue::Integer(42),
CellValue::Real(std::f64::consts::PI),
],
vec![CellValue::Integer(-7), CellValue::Real(0.0)],
],
})
@@ -452,10 +460,7 @@ mod tests {
let body = serialize_table(&TableSnapshot {
name: "T".to_string(),
columns: vec![col("b", Type::Bool)],
rows: vec![
vec![CellValue::Integer(1)],
vec![CellValue::Integer(0)],
],
rows: vec![vec![CellValue::Integer(1)], vec![CellValue::Integer(0)]],
})
.unwrap();
let s = String::from_utf8(body).unwrap();
@@ -555,13 +560,21 @@ mod tests {
let body = serialize_table(&table).unwrap();
let parsed = parse_csv(std::str::from_utf8(&body).unwrap()).unwrap();
let row = &parsed.rows[0];
assert!(matches!(decode_cell(Type::Int, &row[0]).unwrap(), CellValue::Integer(42)));
assert!(matches!(
decode_cell(Type::Int, &row[0]).unwrap(),
CellValue::Integer(42)
));
match decode_cell(Type::Real, &row[1]).unwrap() {
CellValue::Real(f) => assert!((f - std::f64::consts::PI).abs() < 1e-12),
other => panic!("got {other:?}"),
}
assert!(matches!(decode_cell(Type::Bool, &row[2]).unwrap(), CellValue::Integer(1)));
assert!(matches!(decode_cell(Type::Blob, &row[3]).unwrap(), CellValue::Blob(b) if b == b"hi"));
assert!(matches!(
decode_cell(Type::Bool, &row[2]).unwrap(),
CellValue::Integer(1)
));
assert!(
matches!(decode_cell(Type::Blob, &row[3]).unwrap(), CellValue::Blob(b) if b == b"hi")
);
}
#[test]
@@ -572,7 +585,10 @@ mod tests {
#[test]
fn decode_cell_reports_friendly_error_for_bad_int() {
let cell = RawCell { content: "abc".to_string(), was_quoted: false };
let cell = RawCell {
content: "abc".to_string(),
was_quoted: false,
};
let err = decode_cell(Type::Int, &cell).expect_err("must error");
assert!(err.contains("integer"));
assert!(err.contains("abc"));
+33 -18
View File
@@ -108,10 +108,7 @@ pub(super) fn read_recent_sources(
});
}
};
let mut sources: Vec<String> = body
.lines()
.filter_map(parse_record_source)
.collect();
let mut sources: Vec<String> = body.lines().filter_map(parse_record_source).collect();
if sources.len() > max_n {
let skip = sources.len() - max_n;
sources.drain(0..skip);
@@ -187,12 +184,26 @@ fn looks_like_iso8601(s: &str) -> bool {
return false;
}
let digit = |i: usize| b[i].is_ascii_digit();
digit(0) && digit(1) && digit(2) && digit(3) && b[4] == b'-'
&& digit(5) && digit(6) && b[7] == b'-'
&& digit(8) && digit(9) && b[10] == b'T'
&& digit(11) && digit(12) && b[13] == b':'
&& digit(14) && digit(15) && b[16] == b':'
&& digit(17) && digit(18) && b[19] == b'Z'
digit(0)
&& digit(1)
&& digit(2)
&& digit(3)
&& b[4] == b'-'
&& digit(5)
&& digit(6)
&& b[7] == b'-'
&& digit(8)
&& digit(9)
&& b[10] == b'T'
&& digit(11)
&& digit(12)
&& b[13] == b':'
&& digit(14)
&& digit(15)
&& b[16] == b':'
&& digit(17)
&& digit(18)
&& b[19] == b'Z'
}
fn unescape_command(s: &str) -> String {
@@ -321,10 +332,8 @@ mod tests {
#[test]
fn parse_journal_record_ok_extracts_unescaped_source() {
let rec = parse_journal_record(
"2026-05-24T10:00:00Z|ok|create table T with pk id(int)",
)
.expect("valid ok journal record");
let rec = parse_journal_record("2026-05-24T10:00:00Z|ok|create table T with pk id(int)")
.expect("valid ok journal record");
assert!(rec.status_is_ok);
assert_eq!(rec.source, "create table T with pk id(int)");
}
@@ -370,8 +379,8 @@ mod tests {
fn parse_journal_record_preserves_pipe_in_source() {
// `|` is not escaped by the writer (it's a valid SQL char);
// `splitn(3, '|')` keeps everything after the second `|`.
let rec = parse_journal_record("2026-05-24T10:00:00Z|ok|select 'a|b' from t")
.expect("ok record");
let rec =
parse_journal_record("2026-05-24T10:00:00Z|ok|select 'a|b' from t").expect("ok record");
assert_eq!(rec.source, "select 'a|b' from t");
}
@@ -406,7 +415,10 @@ mod tests {
#[test]
fn iso8601_known_seconds() {
assert_eq!(iso8601_from_unix_secs(0), "1970-01-01T00:00:00Z");
assert_eq!(iso8601_from_unix_secs(1_778_112_000), "2026-05-07T00:00:00Z");
assert_eq!(
iso8601_from_unix_secs(1_778_112_000),
"2026-05-07T00:00:00Z"
);
}
#[test]
@@ -437,7 +449,10 @@ mod tests {
.collect();
std::fs::write(&path, body).unwrap();
let got = read_recent_sources(&path, 3).unwrap();
assert_eq!(got, vec!["cmd7".to_string(), "cmd8".to_string(), "cmd9".to_string()]);
assert_eq!(
got,
vec!["cmd7".to_string(), "cmd8".to_string(), "cmd9".to_string()]
);
}
#[test]
+37 -43
View File
@@ -82,7 +82,10 @@ impl Default for MigratorRegistry {
#[derive(Debug)]
pub enum MigrateError {
VersionParse(String),
NewerThanSupported { file: u32, latest: u32 },
NewerThanSupported {
file: u32,
latest: u32,
},
NoMigratorForVersion(u32),
StepFailed {
from: u32,
@@ -108,10 +111,9 @@ impl std::fmt::Display for MigrateError {
file = file,
latest = latest,
)),
Self::NoMigratorForVersion(v) => f.write_str(&crate::t!(
"persistence.migrate.no_migrator",
version = v,
)),
Self::NoMigratorForVersion(v) => {
f.write_str(&crate::t!("persistence.migrate.no_migrator", version = v,))
}
Self::StepFailed { from, to, source } => f.write_str(&crate::t!(
"persistence.migrate.step_failed",
from = from,
@@ -192,8 +194,11 @@ pub fn migrate_to_latest(
// Write the .bak before any transformation runs so a
// mid-migration crash leaves the original recoverable.
let bak_path =
project_path.join(format!("{}.v{}.bak", crate::project::PROJECT_YAML, file_version));
let bak_path = project_path.join(format!(
"{}.v{}.bak",
crate::project::PROJECT_YAML,
file_version
));
std::fs::write(&bak_path, body).map_err(|source| MigrateError::Io {
path: bak_path.clone(),
source,
@@ -214,8 +219,8 @@ pub fn migrate_to_latest(
// Sanity: the new body must declare the next version.
// If a migrator forgets to bump, we'd loop endlessly
// through the chain — catch it here.
let advertised = read_version(&next_body)
.map_err(|e| MigrateError::BadOutput(e.to_string()))?;
let advertised =
read_version(&next_body).map_err(|e| MigrateError::BadOutput(e.to_string()))?;
if advertised != v + 1 {
return Err(MigrateError::BadOutput(format!(
"v{v}→v{} migrator left version field at {advertised}",
@@ -281,9 +286,8 @@ fn read_version(body: &str) -> Result<u32, MigrateError> {
struct VersionOnly {
version: u32,
}
let v: VersionOnly = serde_norway::from_str(body).map_err(|e| {
MigrateError::VersionParse(e.to_string())
})?;
let v: VersionOnly =
serde_norway::from_str(body).map_err(|e| MigrateError::VersionParse(e.to_string()))?;
Ok(v.version)
}
@@ -309,12 +313,8 @@ mod tests {
#[test]
fn no_migration_runs_when_body_already_latest() {
let tmp = tempdir();
let outcome = migrate_to_latest(
&v1_body(),
&MigratorRegistry::production(),
tmp.path(),
)
.unwrap();
let outcome =
migrate_to_latest(&v1_body(), &MigratorRegistry::production(), tmp.path()).unwrap();
assert_eq!(outcome.body, v1_body());
assert_eq!(outcome.migrated_from, None);
// No .bak written when nothing migrated.
@@ -328,7 +328,13 @@ mod tests {
let err = migrate_to_latest(body, &MigratorRegistry::production(), Path::new("/tmp"))
.expect_err("must reject");
assert!(
matches!(err, MigrateError::NewerThanSupported { file: 99, latest: 1 }),
matches!(
err,
MigrateError::NewerThanSupported {
file: 99,
latest: 1
}
),
"got: {err:?}",
);
}
@@ -366,12 +372,7 @@ mod tests {
#[test]
fn migrate_runs_chain_and_writes_bak() {
let tmp = tempdir();
let outcome = migrate_to_latest(
&v1_body(),
&registry_with_v1_to_v2(),
tmp.path(),
)
.unwrap();
let outcome = migrate_to_latest(&v1_body(), &registry_with_v1_to_v2(), tmp.path()).unwrap();
assert_eq!(outcome.migrated_from, Some(1));
assert!(outcome.body.contains("version: 2"));
let bak = tmp.path().join("project.yaml.v1.bak");
@@ -396,11 +397,8 @@ mod tests {
let tmp = tempdir();
let yaml_path = tmp.path().join("project.yaml");
std::fs::write(&yaml_path, v1_body()).unwrap();
let outcome = ensure_project_yaml_migrated(
tmp.path(),
&MigratorRegistry::production(),
)
.unwrap();
let outcome =
ensure_project_yaml_migrated(tmp.path(), &MigratorRegistry::production()).unwrap();
assert_eq!(outcome.migrated_from, None);
// File unchanged.
let on_disk = std::fs::read_to_string(&yaml_path).unwrap();
@@ -413,36 +411,32 @@ mod tests {
let tmp = tempdir();
let yaml_path = tmp.path().join("project.yaml");
std::fs::write(&yaml_path, v1_body()).unwrap();
let outcome = ensure_project_yaml_migrated(
tmp.path(),
&registry_with_v1_to_v2(),
)
.unwrap();
let outcome = ensure_project_yaml_migrated(tmp.path(), &registry_with_v1_to_v2()).unwrap();
assert_eq!(outcome.migrated_from, Some(1));
let on_disk = std::fs::read_to_string(&yaml_path).unwrap();
assert!(on_disk.contains("version: 2"), "got: {on_disk}");
let bak = tmp.path().join("project.yaml.v1.bak");
assert!(bak.exists());
assert!(std::fs::read_to_string(&bak).unwrap().contains("version: 1"));
assert!(
std::fs::read_to_string(&bak)
.unwrap()
.contains("version: 1")
);
}
#[test]
fn ensure_yaml_migrated_handles_missing_yaml() {
let tmp = tempdir();
// No project.yaml exists.
let outcome = ensure_project_yaml_migrated(
tmp.path(),
&MigratorRegistry::production(),
)
.unwrap();
let outcome =
ensure_project_yaml_migrated(tmp.path(), &MigratorRegistry::production()).unwrap();
assert_eq!(outcome.migrated_from, None);
assert!(outcome.body.is_empty());
}
#[test]
fn migrator_that_returns_internal_error_propagates() {
let bad: MigrateFn =
|_| Err(MigrateError::VersionParse("simulated".to_string()));
let bad: MigrateFn = |_| Err(MigrateError::VersionParse("simulated".to_string()));
let registry = MigratorRegistry {
migrators: vec![bad],
};
+19 -19
View File
@@ -368,12 +368,11 @@ impl Persistence {
path: data_dir.clone(),
source,
})?;
let body =
csv_io::serialize_table(table).map_err(|message| PersistenceError::Encode {
kind: "CSV",
path: data_dir.join(format!("{}.csv", table.name)),
message,
})?;
let body = csv_io::serialize_table(table).map_err(|message| PersistenceError::Encode {
kind: "CSV",
path: data_dir.join(format!("{}.csv", table.name)),
message,
})?;
atomic_write(&data_dir.join(format!("{}.csv", table.name)), &body)
}
@@ -406,11 +405,8 @@ impl Persistence {
) -> Result<(), PersistenceError> {
let path = self.project_path.join(HISTORY_LOG);
let status = history::status_token(history::STATUS_OK, advanced);
let line = history::format_record_with_status(
command_text,
history::utc_iso8601_now(),
&status,
);
let line =
history::format_record_with_status(command_text, history::utc_iso8601_now(), &status);
debug!(
len = command_text.len(),
advanced, "persist: append ok record to history.log"
@@ -432,11 +428,8 @@ impl Persistence {
) -> Result<(), PersistenceError> {
let path = self.project_path.join(HISTORY_LOG);
let status = history::status_token(history::STATUS_ERR, advanced);
let line = history::format_record_with_status(
command_text,
history::utc_iso8601_now(),
&status,
);
let line =
history::format_record_with_status(command_text, history::utc_iso8601_now(), &status);
debug!(
len = command_text.len(),
advanced, "persist: append err record to history.log"
@@ -531,8 +524,14 @@ mod tests {
#[test]
fn extension_with_tmp_appends_to_existing_extension() {
assert_eq!(extension_with_tmp(Path::new("a/b/project.yaml")), "yaml.tmp");
assert_eq!(extension_with_tmp(Path::new("a/b/Customers.csv")), "csv.tmp");
assert_eq!(
extension_with_tmp(Path::new("a/b/project.yaml")),
"yaml.tmp"
);
assert_eq!(
extension_with_tmp(Path::new("a/b/Customers.csv")),
"csv.tmp"
);
assert_eq!(extension_with_tmp(Path::new("a/b/lockfile")), "tmp");
}
@@ -600,7 +599,8 @@ mod tests {
fn append_history_creates_and_appends() {
let dir = tempdir();
let p = Persistence::new(dir.path().to_path_buf());
p.append_history("create table Foo with pk id(serial)", false).unwrap();
p.append_history("create table Foo with pk id(serial)", false)
.unwrap();
p.append_history("insert into Foo (1)", false).unwrap();
let body = fs::read_to_string(dir.path().join(HISTORY_LOG)).unwrap();
let lines: Vec<&str> = body.trim_end().lines().collect();
+117 -35
View File
@@ -261,7 +261,10 @@ fn needs_quoting(s: &str) -> bool {
}
// Scalar text that looks like a YAML keyword needs quoting
// even if every character is safe.
if matches!(s, "true" | "false" | "null" | "~" | "yes" | "no" | "on" | "off") {
if matches!(
s,
"true" | "false" | "null" | "~" | "yes" | "no" | "on" | "off"
) {
return true;
}
s.chars().any(|c| !is_safe_yaml_char(c))
@@ -287,13 +290,14 @@ pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
for t in raw.tables {
let mut columns: Vec<ColumnSchema> = Vec::with_capacity(t.columns.len());
for c in t.columns {
let user_type = c.user_type.parse::<Type>().map_err(|_| {
YamlError::UnknownType {
let user_type = c
.user_type
.parse::<Type>()
.map_err(|_| YamlError::UnknownType {
table: t.name.clone(),
column: c.name.clone(),
raw: c.user_type.clone(),
}
})?;
})?;
columns.push(ColumnSchema {
name: c.name,
user_type,
@@ -308,7 +312,11 @@ pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
primary_key: t.primary_key,
columns,
unique_constraints: t.unique_constraints,
check_constraints: t.check_constraints.into_iter().map(TableCheck::from).collect(),
check_constraints: t
.check_constraints
.into_iter()
.map(TableCheck::from)
.collect(),
});
}
let mut relationships: Vec<RelationshipSchema> = Vec::with_capacity(raw.relationships.len());
@@ -381,10 +389,7 @@ pub(crate) enum YamlError {
impl std::fmt::Display for YamlError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Syntax(msg) => f.write_str(&crate::t!(
"persistence.yaml.syntax",
detail = msg,
)),
Self::Syntax(msg) => f.write_str(&crate::t!("persistence.yaml.syntax", detail = msg,)),
Self::UnsupportedVersion(v) => f.write_str(&crate::t!(
"persistence.yaml.unsupported_version",
version = v,
@@ -395,10 +400,9 @@ impl std::fmt::Display for YamlError {
column = column,
raw = raw,
)),
Self::UnknownAction(raw) => f.write_str(&crate::t!(
"persistence.yaml.unknown_action",
raw = raw,
)),
Self::UnknownAction(raw) => {
f.write_str(&crate::t!("persistence.yaml.unknown_action", raw = raw,))
}
}
}
}
@@ -545,8 +549,22 @@ mod tests {
name: "Customers".to_string(),
primary_key: vec!["id".to_string()],
columns: vec![
ColumnSchema { name: "id".to_string(), user_type: Type::Serial, unique: false, not_null: false, default: None, check: None },
ColumnSchema { name: "Name".to_string(), user_type: Type::Text, unique: false, not_null: false, default: None, check: None },
ColumnSchema {
name: "id".to_string(),
user_type: Type::Serial,
unique: false,
not_null: false,
default: None,
check: None,
},
ColumnSchema {
name: "Name".to_string(),
user_type: Type::Text,
unique: false,
not_null: false,
default: None,
check: None,
},
],
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
@@ -555,8 +573,22 @@ mod tests {
name: "Orders".to_string(),
primary_key: vec!["id".to_string()],
columns: vec![
ColumnSchema { name: "id".to_string(), user_type: Type::Serial, unique: false, not_null: false, default: None, check: None },
ColumnSchema { name: "CustId".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
ColumnSchema {
name: "id".to_string(),
user_type: Type::Serial,
unique: false,
not_null: false,
default: None,
check: None,
},
ColumnSchema {
name: "CustId".to_string(),
user_type: Type::Int,
unique: false,
not_null: false,
default: None,
check: None,
},
],
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
@@ -798,15 +830,33 @@ indexes:
name: "T".to_string(),
primary_key: vec![],
columns: vec![
ColumnSchema { name: "a".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
ColumnSchema { name: "b".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
ColumnSchema { name: "c".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
ColumnSchema {
name: "a".to_string(),
user_type: Type::Int,
unique: false,
not_null: false,
default: None,
check: None,
},
ColumnSchema {
name: "b".to_string(),
user_type: Type::Int,
unique: false,
not_null: false,
default: None,
check: None,
},
ColumnSchema {
name: "c".to_string(),
user_type: Type::Int,
unique: false,
not_null: false,
default: None,
check: None,
},
],
unique_constraints: vec![vec!["a".to_string(), "b".to_string()]],
check_constraints: vec![
TableCheck::unnamed("a < b"),
TableCheck::unnamed("b < c"),
],
check_constraints: vec![TableCheck::unnamed("a < b"), TableCheck::unnamed("b < c")],
}],
relationships: vec![],
indexes: vec![],
@@ -830,12 +880,29 @@ indexes:
name: "T".to_string(),
primary_key: vec!["id".to_string()],
columns: vec![
ColumnSchema { name: "id".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
ColumnSchema { name: "qty".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
ColumnSchema {
name: "id".to_string(),
user_type: Type::Int,
unique: false,
not_null: false,
default: None,
check: None,
},
ColumnSchema {
name: "qty".to_string(),
user_type: Type::Int,
unique: false,
not_null: false,
default: None,
check: None,
},
],
unique_constraints: vec![],
check_constraints: vec![
TableCheck { name: Some("qty_positive".to_string()), expr: "qty >= 0".to_string() },
TableCheck {
name: Some("qty_positive".to_string()),
expr: "qty >= 0".to_string(),
},
TableCheck::unnamed("qty < 1000"),
],
}],
@@ -844,7 +911,10 @@ indexes:
};
let body = serialize_schema(&snap);
let parsed = parse_schema(&body).expect("parse schema");
assert_eq!(parsed, snap, "named + unnamed table-CHECKs survive the yaml round-trip");
assert_eq!(
parsed, snap,
"named + unnamed table-CHECKs survive the yaml round-trip"
);
}
#[test]
@@ -968,8 +1038,22 @@ relationships:
name: "Items".to_string(),
primary_key: vec!["a".to_string(), "b".to_string()],
columns: vec![
ColumnSchema { name: "a".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
ColumnSchema { name: "b".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
ColumnSchema {
name: "a".to_string(),
user_type: Type::Int,
unique: false,
not_null: false,
default: None,
check: None,
},
ColumnSchema {
name: "b".to_string(),
user_type: Type::Int,
unique: false,
not_null: false,
default: None,
check: None,
},
],
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
@@ -1019,12 +1103,10 @@ relationships:
let absent = "version: 1\nproject:\n created_at: x\ntables: []\n";
assert_eq!(parse_stored_mode(absent), None);
let explicit_simple =
"version: 1\nproject:\n created_at: x\n mode: simple\ntables: []\n";
let explicit_simple = "version: 1\nproject:\n created_at: x\n mode: simple\ntables: []\n";
assert_eq!(parse_stored_mode(explicit_simple), Some(Mode::Simple));
let advanced =
"version: 1\nproject:\n created_at: x\n mode: advanced\ntables: []\n";
let advanced = "version: 1\nproject:\n created_at: x\n mode: advanced\ntables: []\n";
assert_eq!(parse_stored_mode(advanced), Some(Mode::Advanced));
}
+8 -2
View File
@@ -170,7 +170,10 @@ fn local_hostname() -> String {
/// Uses `sysinfo` to query the OS process table.
fn pid_is_alive(pid: u32) -> bool {
let mut sys = System::new();
sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(pid)]), true);
sys.refresh_processes(
sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(pid)]),
true,
);
sys.process(Pid::from_u32(pid)).is_some()
}
@@ -211,7 +214,10 @@ mod tests {
// The first lock writes our own PID; a second attempt
// should refuse because the PID is alive on this host.
let err = Lock::acquire(dir.path()).expect_err("should refuse second acquisition");
assert!(matches!(err, LockError::AlreadyHeld { .. }), "unexpected: {err:?}");
assert!(
matches!(err, LockError::AlreadyHeld { .. }),
"unexpected: {err:?}"
);
}
#[test]
+28 -29
View File
@@ -78,10 +78,7 @@ pub fn read_last_project(data_root: &Path) -> std::io::Result<Option<PathBuf>> {
/// a moved/deleted directory is the kind of error `--resume`
/// is supposed to surface clearly, not paper over by
/// resolving symlinks at write time.
pub fn write_last_project(
data_root: &Path,
project_path: &Path,
) -> std::io::Result<()> {
pub fn write_last_project(data_root: &Path, project_path: &Path) -> std::io::Result<()> {
fs::create_dir_all(data_root)?;
let final_path = data_root.join(LAST_PROJECT_FILE);
let tmp_path = data_root.join(format!("{LAST_PROJECT_FILE}.tmp"));
@@ -108,9 +105,8 @@ pub fn resolve_data_root(override_dir: Option<&Path>) -> Result<PathBuf, Project
if let Some(p) = override_dir {
return Ok(p.to_path_buf());
}
let dirs = ProjectDirs::from("", "", "rdbms-playground").ok_or(
ProjectError::DataRootUnavailable,
)?;
let dirs =
ProjectDirs::from("", "", "rdbms-playground").ok_or(ProjectError::DataRootUnavailable)?;
Ok(dirs.data_dir().to_path_buf())
}
@@ -255,21 +251,16 @@ pub enum ProjectError {
impl std::fmt::Display for ProjectError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DataRootUnavailable => {
f.write_str(&crate::t!("project.data_root_unavailable"))
Self::DataRootUnavailable => f.write_str(&crate::t!("project.data_root_unavailable")),
Self::PathNotFound(p) => {
f.write_str(&crate::t!("project.path_not_found", path = p.display(),))
}
Self::NotAProject(p) => {
f.write_str(&crate::t!("project.not_a_project", path = p.display(),))
}
Self::AlreadyExists(p) => {
f.write_str(&crate::t!("project.already_exists", path = p.display(),))
}
Self::PathNotFound(p) => f.write_str(&crate::t!(
"project.path_not_found",
path = p.display(),
)),
Self::NotAProject(p) => f.write_str(&crate::t!(
"project.not_a_project",
path = p.display(),
)),
Self::AlreadyExists(p) => f.write_str(&crate::t!(
"project.already_exists",
path = p.display(),
)),
Self::Io { path, source } => f.write_str(&crate::t!(
"project.io",
path = path.display(),
@@ -609,11 +600,10 @@ pub fn safely_delete_temp_project(
// 2. Canonicalize for the containment check. We do this
// only after the symlink-at-top check so we can't be
// tricked by a top-level symlink.
let project_canon =
fs::canonicalize(project_path).map_err(|source| SafeDeleteError::Io {
path: project_path.to_path_buf(),
source,
})?;
let project_canon = fs::canonicalize(project_path).map_err(|source| SafeDeleteError::Io {
path: project_path.to_path_buf(),
source,
})?;
// 3. Containment: canonical path must be inside the
// canonical data-root projects dir.
@@ -848,7 +838,10 @@ mod tests {
assert!(gi.contains("/playground.db"));
assert!(gi.contains("/.rdbms-playground.lock"));
assert!(gi.contains("/.snapshots/"), "undo ring should be ignored");
assert!(!gi.contains("history.log"), "history.log should NOT be ignored");
assert!(
!gi.contains("history.log"),
"history.log should NOT be ignored"
);
}
#[test]
@@ -890,7 +883,10 @@ mod tests {
let target = tmp.path().join("MyProject");
fs::create_dir(&target).unwrap();
let err = Project::create_named(&target).expect_err("must refuse");
assert!(matches!(err, ProjectError::AlreadyExists(_)), "got: {err:?}");
assert!(
matches!(err, ProjectError::AlreadyExists(_)),
"got: {err:?}"
);
}
#[test]
@@ -962,7 +958,10 @@ mod tests {
)
.unwrap();
let read_back = read_last_project(tmp.path()).unwrap();
assert_eq!(read_back, Some(std::path::PathBuf::from("/tmp/some/project")));
assert_eq!(
read_back,
Some(std::path::PathBuf::from("/tmp/some/project"))
);
}
#[test]
+30 -15
View File
@@ -17,8 +17,8 @@
use std::path::Path;
use rand::seq::IndexedRandom;
use rand::Rng;
use rand::seq::IndexedRandom;
const WORDLIST: &str = include_str!("wordlist.txt");
const MAX_COLLISION_RETRIES: usize = 100;
@@ -41,10 +41,9 @@ pub enum NamingError {
impl std::fmt::Display for NamingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::WordlistTooSmall(n) => f.write_str(&crate::t!(
"project.naming.wordlist_too_small",
count = n,
)),
Self::WordlistTooSmall(n) => {
f.write_str(&crate::t!("project.naming.wordlist_too_small", count = n,))
}
Self::TooManyCollisions(n) => f.write_str(&crate::t!(
"project.naming.too_many_collisions",
attempts = n,
@@ -189,10 +188,9 @@ impl std::fmt::Display for UserNameError {
match self {
Self::Empty => f.write_str(&crate::t!("project.user_name.empty")),
Self::LeadingDot => f.write_str(&crate::t!("project.user_name.leading_dot")),
Self::InvalidChar(c) => f.write_str(&crate::t!(
"project.user_name.invalid_char",
ch = c,
)),
Self::InvalidChar(c) => {
f.write_str(&crate::t!("project.user_name.invalid_char", ch = c,))
}
}
}
}
@@ -209,14 +207,22 @@ mod tests {
#[test]
fn wordlist_has_enough_entries() {
let pool = words();
assert!(pool.len() >= 100, "wordlist suspiciously small: {} entries", pool.len());
assert!(
pool.len() >= 100,
"wordlist suspiciously small: {} entries",
pool.len()
);
}
#[test]
fn wordlist_has_no_duplicates() {
let pool = words();
let unique: std::collections::HashSet<_> = pool.iter().collect();
assert_eq!(unique.len(), pool.len(), "wordlist contains duplicate entries");
assert_eq!(
unique.len(),
pool.len(),
"wordlist contains duplicate entries"
);
}
#[test]
@@ -290,7 +296,7 @@ mod tests {
assert!(!is_temp_dirname("MyOrders"));
assert!(!is_temp_dirname("term_planner"));
assert!(!is_temp_dirname("20260507-water-buffalo-skating")); // no marker
assert!(!is_temp_dirname("temp-project")); // no brackets
assert!(!is_temp_dirname("temp-project")); // no brackets
}
#[test]
@@ -301,9 +307,18 @@ mod tests {
assert!(validate_user_name("project.v2").is_ok());
assert_eq!(validate_user_name(""), Err(UserNameError::Empty));
assert_eq!(validate_user_name(".hidden"), Err(UserNameError::LeadingDot));
assert!(matches!(validate_user_name("a/b"), Err(UserNameError::InvalidChar('/'))));
assert!(matches!(validate_user_name("a b"), Err(UserNameError::InvalidChar(' '))));
assert_eq!(
validate_user_name(".hidden"),
Err(UserNameError::LeadingDot)
);
assert!(matches!(
validate_user_name("a/b"),
Err(UserNameError::InvalidChar('/'))
));
assert!(matches!(
validate_user_name("a b"),
Err(UserNameError::InvalidChar(' '))
));
}
fn tempdir() -> tempfile::TempDir {
+8 -2
View File
@@ -129,7 +129,10 @@ mod tests {
#[test]
fn strips_date_prefix_from_temp_project_names() {
assert_eq!(prettify("20260507-water-buffalo-skating"), "Water Buffalo Skating");
assert_eq!(
prettify("20260507-water-buffalo-skating"),
"Water Buffalo Skating"
);
}
#[test]
@@ -205,6 +208,9 @@ mod tests {
#[test]
fn handles_mixed_separators_and_case() {
assert_eq!(prettify("MyTeam_lessonPlan-2026"), "My Team Lesson Plan 2026");
assert_eq!(
prettify("MyTeam_lessonPlan-2026"),
"My Team Lesson Plan 2026"
);
}
}
+125 -149
View File
@@ -36,8 +36,8 @@ use crate::db::{
use crate::dsl::command::{
Constraint, ConstraintKind, IndexSelector, RelationshipSelector, TableConstraint,
};
use crate::dsl::{AlterTableAction, ChangeColumnMode, Command, ColumnSpec};
use crate::dsl::walker::Severity;
use crate::dsl::{AlterTableAction, ChangeColumnMode, ColumnSpec, Command};
use crate::event::AppEvent;
use crate::project::{
Project, ProjectKind, copy_project, list_projects, open_or_create, projects_dir,
@@ -130,8 +130,7 @@ pub async fn run(args: Args) -> Result<()> {
// to it for `new` (creates a temp) and `load` (lists
// projects). We can't easily recover this from the
// Project alone, so we keep it ourselves.
let data_root = resolve_data_root(args.data_dir.as_deref())
.context("resolve data root")?;
let data_root = resolve_data_root(args.data_dir.as_deref()).context("resolve data root")?;
// Resolve the initial project path: --resume reads it from
// <data-root>/last_project; otherwise an explicit positional
@@ -143,17 +142,12 @@ pub async fn run(args: Args) -> Result<()> {
// terminal so the message lands directly in the user's
// shell.
let initial_path: Option<PathBuf> = if args.resume {
match read_last_project(&data_root)
.context("read last_project")?
{
match read_last_project(&data_root).context("read last_project")? {
Some(p) if p.exists() => Some(p),
Some(p) => {
eprintln!(
"rdbms-playground: {}",
crate::t!(
"project.resume_recorded_missing",
path = p.display(),
),
crate::t!("project.resume_recorded_missing", path = p.display(),),
);
return Ok(());
}
@@ -488,19 +482,15 @@ async fn run_loop(
// Best-effort — a failure to record a failure must
// never escalate a user error into a fatal, so the
// result is logged and ignored.
if let Err(e) = crate::persistence::Persistence::new(
session.project().path().to_path_buf(),
)
.append_history_failure(&source, advanced)
if let Err(e) =
crate::persistence::Persistence::new(session.project().path().to_path_buf())
.append_history_failure(&source, advanced)
{
tracing::warn!(error = %e, "failed to journal err record (ignored)");
}
}
Action::PrepareRebuild => {
spawn_prepare_rebuild(
session.project().path().to_path_buf(),
event_tx.clone(),
);
spawn_prepare_rebuild(session.project().path().to_path_buf(), event_tx.clone());
}
Action::Rebuild { source } => {
spawn_rebuild(
@@ -671,8 +661,8 @@ async fn run_loop(
// mutually exclusive (one needs an unmodified temp, the
// other anything else).
let project_at_quit = session.project.as_ref();
let cleanup_on_quit: Option<std::path::PathBuf> = project_at_quit
.and_then(|p| p.is_unmodified_temp().then(|| p.path().to_path_buf()));
let cleanup_on_quit: Option<std::path::PathBuf> =
project_at_quit.and_then(|p| p.is_unmodified_temp().then(|| p.path().to_path_buf()));
let resume_target_on_quit: Option<std::path::PathBuf> = project_at_quit
.filter(|p| !p.is_unmodified_temp())
.map(|p| p.path().to_path_buf());
@@ -831,7 +821,10 @@ async fn perform_switch(
Some(p)
}
SwitchRequest::NewTemp => None,
SwitchRequest::Import { zip_path, as_target } => {
SwitchRequest::Import {
zip_path,
as_target,
} => {
if !zip_path.exists() {
return Err(crate::t!(
"project.import_zip_missing",
@@ -840,8 +833,7 @@ async fn perform_switch(
}
// Validate the zip up front so we don't drop the
// current project for an unimportable file.
let inspection = crate::archive::inspect_zip(zip_path)
.map_err(|e| e.to_string())?;
let inspection = crate::archive::inspect_zip(zip_path).map_err(|e| e.to_string())?;
let resolved = resolve_import_destination(
as_target.as_deref(),
&inspection.top_folder,
@@ -856,16 +848,19 @@ async fn perform_switch(
// state matches the in-memory db).
if let SwitchRequest::SaveAs { .. } = &req {
let src = session.project().path().to_path_buf();
let dst = resolved_target.as_ref().expect("SaveAs has resolved target");
let dst = resolved_target
.as_ref()
.expect("SaveAs has resolved target");
copy_project(&src, dst).map_err(|e| e.to_string())?;
}
// For Import: extract the zip into the resolved target.
// We do this *before* dropping the current project so
// a failure here leaves the user where they were.
if let SwitchRequest::Import { zip_path, .. } = &req {
let dst = resolved_target.as_ref().expect("Import has resolved target");
let inspection = crate::archive::inspect_zip(zip_path)
.map_err(|e| e.to_string())?;
let dst = resolved_target
.as_ref()
.expect("Import has resolved target");
let inspection = crate::archive::inspect_zip(zip_path).map_err(|e| e.to_string())?;
crate::archive::extract_into(zip_path, dst, &inspection.top_folder)
.map_err(|e| e.to_string())?;
}
@@ -874,10 +869,10 @@ async fn perform_switch(
// we drop it: if it was an unmodified empty temp, we
// delete its directory after the switch so the data dir
// doesn't accumulate empty scratch projects.
let outgoing_cleanup_path: Option<std::path::PathBuf> =
session.project.as_ref().and_then(|p| {
p.is_unmodified_temp().then(|| p.path().to_path_buf())
});
let outgoing_cleanup_path: Option<std::path::PathBuf> = session
.project
.as_ref()
.and_then(|p| p.is_unmodified_temp().then(|| p.path().to_path_buf()));
// Drop current project + database BEFORE opening the new
// ones, releasing the old lock and stopping the old
@@ -954,9 +949,7 @@ async fn perform_switch(
let new_database =
Database::open_with_persistence_and_undo(&db_path, persistence, undo_enabled)
.map_err(|e| e.to_string())?;
if !db_existed
&& let Err(e) = new_database.rebuild_from_text(new_path.clone(), None).await
{
if !db_existed && let Err(e) = new_database.rebuild_from_text(new_path.clone(), None).await {
return Err(e.friendly_message());
}
@@ -982,9 +975,7 @@ async fn perform_switch(
// fresh empty temp (a `new` command), which must not be
// recorded (see the gate in `run()`). Write failures are
// non-fatal.
if new_worth_recording
&& let Err(e) = write_last_project(&session.data_root, &new_path)
{
if new_worth_recording && let Err(e) = write_last_project(&session.data_root, &new_path) {
tracing::warn!(error = %e, "could not update last_project after switch");
}
@@ -1045,8 +1036,8 @@ fn spawn_export(
event_tx: mpsc::Sender<AppEvent>,
) {
// `export` app command: journalled simple (ADR-0052).
let _ = crate::persistence::Persistence::new(project_path.clone())
.append_history(&source, false);
let _ =
crate::persistence::Persistence::new(project_path.clone()).append_history(&source, false);
tokio::spawn(async move {
let outcome = tokio::task::spawn_blocking(move || {
do_export(&project_path, &project_name, &data_root, target.as_deref())
@@ -1081,9 +1072,8 @@ fn do_export(
}
None => {
std::fs::create_dir_all(data_root).map_err(|e| e.to_string())?;
let (filename, _) =
crate::archive::next_export_sequence(data_root, project_name)
.map_err(|e| e.to_string())?;
let (filename, _) = crate::archive::next_export_sequence(data_root, project_name)
.map_err(|e| e.to_string())?;
data_root.join(filename)
}
};
@@ -1143,10 +1133,7 @@ async fn seed_initial_tables(database: &Database, event_tx: &mpsc::Sender<AppEve
/// no completion. Called wherever `TablesRefreshed` is sent
/// today; the schema cache lives on the App and feeds Tab
/// completion for identifier slots.
async fn refresh_schema_cache(
database: &Database,
event_tx: &mpsc::Sender<AppEvent>,
) {
async fn refresh_schema_cache(database: &Database, event_tx: &mpsc::Sender<AppEvent>) {
let cache = build_schema_cache(database).await;
let _ = event_tx.send(AppEvent::SchemaCacheRefreshed(cache)).await;
// ADR-0046 DB2: full relationship records for the sidebar panel.
@@ -1234,10 +1221,7 @@ async fn build_schema_cache(database: &Database) -> crate::completion::SchemaCac
/// summary that the confirmation modal shows. Runs off the
/// event loop so the brief I/O doesn't stall input handling
/// even on slow filesystems.
fn spawn_prepare_rebuild(
project_path: std::path::PathBuf,
event_tx: mpsc::Sender<AppEvent>,
) {
fn spawn_prepare_rebuild(project_path: std::path::PathBuf, event_tx: mpsc::Sender<AppEvent>) {
tokio::spawn(async move {
let summary = match summarize_project(&project_path) {
Ok(s) => s,
@@ -1317,9 +1301,7 @@ fn spawn_rebuild(
}
let summary = summarize_project(&project_path)
.unwrap_or_else(|_| "rebuild complete".to_string());
let _ = event_tx
.send(AppEvent::RebuildSucceeded { summary })
.await;
let _ = event_tx.send(AppEvent::RebuildSucceeded { summary }).await;
// Refresh the table list so the items panel
// reflects whatever the rebuild produced.
if let Ok(tables) = database.list_tables().await {
@@ -1462,12 +1444,8 @@ fn spawn_dsl_dispatch(
}
let event = match outcome {
Ok(CommandOutcome::Schema(description)) => {
let schema_echo = build_schema_echo(
&command,
submission_mode,
description.as_ref(),
&lookups,
);
let schema_echo =
build_schema_echo(&command, submission_mode, description.as_ref(), &lookups);
AppEvent::DslSucceeded {
command: command.clone(),
description,
@@ -1484,12 +1462,10 @@ fn spawn_dsl_dispatch(
Ok(CommandOutcome::SchemaDropIndexSkipped) => AppEvent::DslDropIndexSkipped {
command: command.clone(),
},
Ok(CommandOutcome::SchemaCreateIndexSkipped(name)) => {
AppEvent::DslCreateIndexSkipped {
command: command.clone(),
name,
}
}
Ok(CommandOutcome::SchemaCreateIndexSkipped(name)) => AppEvent::DslCreateIndexSkipped {
command: command.clone(),
name,
},
Ok(CommandOutcome::Query(data)) => {
// ADR-0038: `show data` is the only DSL-form query that
// echoes; its limited form orders by the table's primary
@@ -1507,12 +1483,10 @@ fn spawn_dsl_dispatch(
command: command.clone(),
lines,
},
Ok(CommandOutcome::ShowRelationship(data)) => {
AppEvent::DslShowRelationshipSucceeded {
command: command.clone(),
data: data.map(|b| *b),
}
}
Ok(CommandOutcome::ShowRelationship(data)) => AppEvent::DslShowRelationshipSucceeded {
command: command.clone(),
data: data.map(|b| *b),
},
Ok(CommandOutcome::QueryPlan(plan)) => AppEvent::DslExplainSucceeded {
command: command.clone(),
plan,
@@ -1568,11 +1542,8 @@ fn spawn_dsl_dispatch(
// the covering indexes the rebuild removed — Bucket B
// category 2, ADR-0038 §7 Slice 2b). Non-cascade falls
// through to the pre-execution `echo` from `echo_for`.
let cascade_echo = build_drop_column_cascade_echo(
&command,
submission_mode,
&result,
);
let cascade_echo =
build_drop_column_cascade_echo(&command, submission_mode, &result);
AppEvent::DslDropColumnSucceeded {
command: command.clone(),
result,
@@ -1931,12 +1902,14 @@ fn build_schema_echo(
)])
}
}
Command::DropRelationship { .. } => lookups
.drop_relationship
.as_ref()
.map(|(name, child_table)| {
vec![crate::echo::render_drop_relationship(name, child_table)]
}),
Command::DropRelationship { .. } => {
lookups
.drop_relationship
.as_ref()
.map(|(name, child_table)| {
vec![crate::echo::render_drop_relationship(name, child_table)]
})
}
// `create m:n relationship` (ADR-0045): the resolved junction
// columns/FKs only exist on the post-exec description, so the
// teaching echo is rendered from it (not `command_to_sql`).
@@ -1946,14 +1919,29 @@ fn build_schema_echo(
.iter()
.filter_map(|c| c.user_type.map(|ty| (c.name.clone(), ty)))
.collect();
let primary_key: Vec<String> =
desc.columns.iter().filter(|c| c.primary_key).map(|c| c.name.clone()).collect();
let primary_key: Vec<String> = desc
.columns
.iter()
.filter(|c| c.primary_key)
.map(|c| c.name.clone())
.collect();
let foreign_keys: Vec<(Vec<String>, String, Vec<String>)> = desc
.outbound_relationships
.iter()
.map(|r| (r.local_columns.clone(), r.other_table.clone(), r.other_columns.clone()))
.map(|r| {
(
r.local_columns.clone(),
r.other_table.clone(),
r.other_columns.clone(),
)
})
.collect();
vec![crate::echo::render_create_m2n(&desc.name, &columns, &primary_key, &foreign_keys)]
vec![crate::echo::render_create_m2n(
&desc.name,
&columns,
&primary_key,
&foreign_keys,
)]
}),
// Everything else (Bucket A pure-Command, plus the no-echo Bucket C
// variants like `Sql*` / `ShowTable`) routes through the existing
@@ -2103,10 +2091,7 @@ async fn enrich_unique_violation(
facts
}
fn enrich_not_null_violation(
command: &Command,
message: &str,
) -> crate::friendly::FailureContext {
fn enrich_not_null_violation(command: &Command, message: &str) -> crate::friendly::FailureContext {
let mut facts = crate::friendly::FailureContext::default();
let Some((table, column)) = parse_qualified_target(message) else {
return facts;
@@ -2133,9 +2118,7 @@ async fn enrich_fk_violation(
// schema-aware lookup so natural-order multi-value
// INSERT (which `user_value_for_column` alone can't
// resolve) gets handled too.
let Ok((outbound, _)) =
database.read_relationships(table.clone()).await
else {
let Ok((outbound, _)) = database.read_relationships(table.clone()).await else {
return facts;
};
facts.table = Some(table.clone());
@@ -2173,8 +2156,7 @@ async fn enrich_fk_violation(
// children reference). Check inbound as a fallback.
if facts.parent_table.is_none()
&& matches!(command, Command::Update { .. })
&& let Ok((_, inbound)) =
database.read_relationships(table.clone()).await
&& let Ok((_, inbound)) = database.read_relationships(table.clone()).await
&& let Some(rel) = inbound.first()
{
facts.child_table = Some(rel.other_table.clone());
@@ -2184,9 +2166,7 @@ async fn enrich_fk_violation(
// Parent-side: inbound FK lookup. Surface a child
// table that still references the row(s) being
// deleted.
let Ok((_, inbound)) =
database.read_relationships(table.clone()).await
else {
let Ok((_, inbound)) = database.read_relationships(table.clone()).await else {
return facts;
};
facts.table = Some(table.clone());
@@ -2271,10 +2251,7 @@ async fn user_value_for_column_with_schema(
..
} = command
{
let desc = database
.describe_table(table.to_string())
.await
.ok()?;
let desc = database.describe_table(table.to_string()).await.ok()?;
// Build the natural-order column list the same way
// `do_insert` does: filter out serial / shortid columns
// because the engine auto-fills them and the user's
@@ -2285,8 +2262,7 @@ async fn user_value_for_column_with_schema(
.filter(|c| {
!matches!(
c.user_type,
Some(crate::dsl::Type::Serial)
| Some(crate::dsl::Type::ShortId)
Some(crate::dsl::Type::Serial) | Some(crate::dsl::Type::ShortId)
)
})
.map(|c| c.name.as_str())
@@ -2310,10 +2286,7 @@ async fn user_value_for_column_with_schema(
&& listed_columns.is_empty()
&& literal_rows.len() == 1
{
let desc = database
.describe_table(table.to_string())
.await
.ok()?;
let desc = database.describe_table(table.to_string()).await.ok()?;
let idx = desc.columns.iter().position(|c| c.name == column)?;
return literal_rows[0].get(idx).cloned().flatten();
}
@@ -2323,16 +2296,12 @@ async fn user_value_for_column_with_schema(
/// Render a `DataResult` as a `DiagnosticTable` for the
/// friendly-error layer's bordered renderer (ADR-0019 §7,
/// reusing ADR-0017 §7's renderer).
fn diagnostic_from_data_result(
data: &DataResult,
) -> crate::friendly::DiagnosticTable {
use crate::output_render::{numeric_alignment_for, Alignment};
fn diagnostic_from_data_result(data: &DataResult) -> crate::friendly::DiagnosticTable {
use crate::output_render::{Alignment, numeric_alignment_for};
let alignments: Vec<Alignment> = data
.column_types
.iter()
.map(|t| {
t.map_or(Alignment::Left, numeric_alignment_for)
})
.map(|t| t.map_or(Alignment::Left, numeric_alignment_for))
.collect();
let rows: Vec<Vec<String>> = data
.rows
@@ -2543,9 +2512,7 @@ pub async fn run_replay(
// command, which was skipped above) — report it with the line
// number and stop.
let schema = build_schema_cache(database).await;
let command = match crate::dsl::parser::parse_command_with_schema(
&command_text, &schema,
) {
let command = match crate::dsl::parser::parse_command_with_schema(&command_text, &schema) {
Ok(c) => c,
Err(e) => {
events.push(AppEvent::ReplayFailed {
@@ -2566,8 +2533,7 @@ pub async fn run_replay(
// Retain a clone for failure enrichment (the command is moved into
// dispatch). ADR-0035 Amendment 1, F2 follow-up.
let command_for_ctx = command.clone();
let outcome =
execute_command_typed(database, command, command_text.clone()).await;
let outcome = execute_command_typed(database, command, command_text.clone()).await;
match outcome {
Ok(_) => {
// ADR-0052: journal the replayed line at the dispatch
@@ -2873,7 +2839,10 @@ async fn execute_command_typed(
.drop_constraint(table, column, ConstraintKind::NotNull, src)
.await
.map(|d| CommandOutcome::Schema(Some(d))),
AlterTableAction::SetColumnDefault { column, default_sql } => database
AlterTableAction::SetColumnDefault {
column,
default_sql,
} => database
.set_column_default(table, column, default_sql, src)
.await
.map(|d| CommandOutcome::Schema(Some(d))),
@@ -2989,10 +2958,7 @@ async fn execute_command_typed(
// A SQL `SELECT` (advanced mode; ADR-0030 §6, ADR-0031).
// The grammar walker has already validated `sql` is in
// the supported subset; the worker runs it as text.
Command::Select { sql } => database
.run_select(sql)
.await
.map(CommandOutcome::Query),
Command::Select { sql } => database.run_select(sql).await.map(CommandOutcome::Query),
// A SQL `INSERT` (advanced mode; ADR-0033 §1). Grammar-as-
// text: the worker runs the validated `sql` and re-persists
// the parsed `target_table`'s CSV. Reuses the DSL insert
@@ -3112,12 +3078,9 @@ fn setup_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
Ok(terminal)
}
fn teardown_terminal(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<()> {
fn teardown_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
disable_raw_mode().context("disable raw mode")?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)
.context("leave alternate screen")?;
execute!(terminal.backend_mut(), LeaveAlternateScreen).context("leave alternate screen")?;
terminal.show_cursor().context("show cursor")?;
Ok(())
}
@@ -3257,7 +3220,9 @@ mod tests {
// Limited → ORDER BY the resolved primary key.
assert_eq!(
super::build_show_data_echo(&db, &limited, EffectiveMode::AdvancedPersistent).await,
Some(vec!["SELECT * FROM Customers ORDER BY id LIMIT 5".to_string()]),
Some(vec![
"SELECT * FROM Customers ORDER BY id LIMIT 5".to_string()
]),
);
// Simple mode → silent, gated before any lookup.
assert_eq!(
@@ -3288,10 +3253,10 @@ mod tests {
async fn bucket_b_resolved_name_echoes_against_real_worker() {
use crate::app::EffectiveMode;
use crate::db::Database;
use crate::dsl::Command;
use crate::dsl::ReferentialAction;
use crate::dsl::command::{ColumnSpec, IndexSelector, RelationshipSelector};
use crate::dsl::types::Type;
use crate::dsl::Command;
let db = Database::open(":memory:").expect("open in-memory");
db.create_table(
@@ -3319,7 +3284,12 @@ mod tests {
// --- add index (auto-named) ----------------------------------
let desc_after_add_index = db
.add_index(None, "Customers".to_string(), vec!["Email".to_string()], None)
.add_index(
None,
"Customers".to_string(),
vec!["Email".to_string()],
None,
)
.await
.expect("add index");
let add_idx_cmd = Command::AddIndex {
@@ -3439,7 +3409,10 @@ mod tests {
.await;
assert_eq!(
endpoints_lookups.drop_relationship,
Some(("Customers_id_to_Orders_CustId".to_string(), "Orders".to_string())),
Some((
"Customers_id_to_Orders_CustId".to_string(),
"Orders".to_string()
)),
"endpoints selector resolves name via child describe",
);
@@ -3454,7 +3427,10 @@ mod tests {
.await;
assert_eq!(
named_lookups.drop_relationship,
Some(("Customers_id_to_Orders_CustId".to_string(), "Orders".to_string())),
Some((
"Customers_id_to_Orders_CustId".to_string(),
"Orders".to_string()
)),
"named selector scans user tables to find the child",
);
@@ -3487,10 +3463,10 @@ mod tests {
async fn bucket_b_multi_statement_echoes_against_real_worker() {
use crate::app::EffectiveMode;
use crate::db::Database;
use crate::dsl::Command;
use crate::dsl::ReferentialAction;
use crate::dsl::command::ColumnSpec;
use crate::dsl::types::Type;
use crate::dsl::Command;
// --- drop column --cascade -----------------------------------
let db = Database::open(":memory:").expect("open in-memory");
@@ -3505,9 +3481,14 @@ mod tests {
)
.await
.expect("create Customers");
db.add_index(None, "Customers".to_string(), vec!["Email".to_string()], None)
.await
.expect("index Email");
db.add_index(
None,
"Customers".to_string(),
vec!["Email".to_string()],
None,
)
.await
.expect("index Email");
let drop_cmd = Command::DropColumn {
table: "Customers".to_string(),
@@ -3531,12 +3512,8 @@ mod tests {
);
// Simple mode → silent.
assert!(
super::build_drop_column_cascade_echo(
&drop_cmd,
EffectiveMode::Simple,
&drop_result,
)
.is_none(),
super::build_drop_column_cascade_echo(&drop_cmd, EffectiveMode::Simple, &drop_result,)
.is_none(),
);
// --- add relationship --create-fk (column newly created) ----
@@ -3673,11 +3650,11 @@ mod tests {
// switch (an unmodified temp would be cleaned up, taking its
// project.yaml with it). Without the unload persist the
// outgoing skeleton carries no `mode:` → `None`.
use super::{handle_project_switch, Session, SwitchRequest};
use super::{Session, SwitchRequest, handle_project_switch};
use crate::db::Database;
use crate::mode::Mode;
use crate::persistence::Persistence;
use crate::project::{projects_dir, Project};
use crate::project::{Project, projects_dir};
use tokio::sync::mpsc;
let data_root = tempfile::tempdir().unwrap();
@@ -3686,8 +3663,7 @@ mod tests {
let outgoing_path = projects.join("Outgoing");
let outgoing = Project::create_named(&outgoing_path).unwrap();
let db_path = outgoing.db_path();
let persistence =
Persistence::new(outgoing.path().to_path_buf()).with_mode(Mode::Advanced);
let persistence = Persistence::new(outgoing.path().to_path_buf()).with_mode(Mode::Advanced);
let database =
Database::open_with_persistence_and_undo(&db_path, persistence, true).unwrap();
let mut session = Session {
+7 -3
View File
@@ -18,7 +18,11 @@ pub fn parse_in_check_values(check: &str, column: &str) -> Option<Vec<String>> {
return None;
}
let values = extract_quoted_list(&check[paren_open..])?;
if values.is_empty() { None } else { Some(values) }
if values.is_empty() {
None
} else {
Some(values)
}
}
const fn is_ident_byte(b: u8) -> bool {
@@ -45,8 +49,8 @@ fn find_in_paren(check: &str) -> Option<(usize, usize)> {
i += 1;
continue;
}
let is_in = (b == b'i' || b == b'I')
&& bytes.get(i + 1).is_some_and(|n| *n == b'n' || *n == b'N');
let is_in =
(b == b'i' || b == b'I') && bytes.get(i + 1).is_some_and(|n| *n == b'n' || *n == b'N');
if is_in {
let before_ok = i == 0 || !is_ident_byte(bytes[i - 1]);
let after = i + 2;
+127 -42
View File
@@ -81,17 +81,22 @@ pub fn generate_value(generator: &Generator, ty: Type, rng: &mut SeedRng) -> Val
Generator::CurrencyAmount => currency_amount(ty, rng),
Generator::Age => Value::Number(rng.random_range(18..=80).to_string()),
Generator::SmallInt => Value::Number(rng.random_range(1..=100).to_string()),
Generator::YearRecent => {
Value::Number(rng.random_range((REF_YEAR - YEAR_RECENT_SPAN)..=REF_YEAR).to_string())
}
Generator::YearRecent => Value::Number(
rng.random_range((REF_YEAR - YEAR_RECENT_SPAN)..=REF_YEAR)
.to_string(),
),
Generator::YearBirth => Value::Number(
rng.random_range((REF_YEAR - YEAR_BIRTH_MAX_AGE)..=(REF_YEAR - YEAR_BIRTH_MIN_AGE))
.to_string(),
),
Generator::DateRecent => Value::Text(format_date(random_past_date(rng, 0, RECENT_WINDOW_DAYS))),
Generator::DateAdult => {
Value::Text(format_date(random_past_date(rng, ADULT_MIN_DAYS, ADULT_MAX_DAYS)))
Generator::DateRecent => {
Value::Text(format_date(random_past_date(rng, 0, RECENT_WINDOW_DAYS)))
}
Generator::DateAdult => Value::Text(format_date(random_past_date(
rng,
ADULT_MIN_DAYS,
ADULT_MAX_DAYS,
))),
Generator::DateTimeRecent => Value::Text(random_recent_datetime(rng)),
Generator::Boolean => Value::Bool(rng.random_range(0..2) == 1),
Generator::PickFrom(values) if !values.is_empty() => {
@@ -232,8 +237,7 @@ fn random_datetime_between(
} else {
rng.random_range(hi_s..=lo_s)
};
let dt = chrono::DateTime::from_timestamp(secs, 0)
.map_or(lo, |d| d.naive_utc());
let dt = chrono::DateTime::from_timestamp(secs, 0).map_or(lo, |d| d.naive_utc());
dt.format("%Y-%m-%dT%H:%M:%S").to_string()
}
@@ -294,20 +298,35 @@ fn currency_amount(ty: Type, rng: &mut SeedRng) -> Value {
// — the hand-rolled `product` generator (D9) —
const PRODUCT_ADJECTIVES: &[&str] = &[
"Sleek", "Rustic", "Ergonomic", "Handcrafted", "Refined", "Modern",
"Vintage", "Compact", "Premium", "Lightweight", "Durable", "Elegant",
"Sturdy", "Smooth", "Gorgeous", "Intelligent", "Practical", "Awesome",
"Incredible", "Recycled",
"Sleek",
"Rustic",
"Ergonomic",
"Handcrafted",
"Refined",
"Modern",
"Vintage",
"Compact",
"Premium",
"Lightweight",
"Durable",
"Elegant",
"Sturdy",
"Smooth",
"Gorgeous",
"Intelligent",
"Practical",
"Awesome",
"Incredible",
"Recycled",
];
const PRODUCT_MATERIALS: &[&str] = &[
"Wooden", "Copper", "Granite", "Cotton", "Steel", "Leather", "Bamboo",
"Plastic", "Ceramic", "Glass", "Concrete", "Rubber", "Bronze", "Marble",
"Linen", "Silk", "Aluminum", "Wool", "Gold", "Carbon",
"Wooden", "Copper", "Granite", "Cotton", "Steel", "Leather", "Bamboo", "Plastic", "Ceramic",
"Glass", "Concrete", "Rubber", "Bronze", "Marble", "Linen", "Silk", "Aluminum", "Wool", "Gold",
"Carbon",
];
const PRODUCT_NOUNS: &[&str] = &[
"Chair", "Lamp", "Table", "Bottle", "Backpack", "Keyboard", "Mug",
"Shoes", "Jacket", "Watch", "Wallet", "Bench", "Hat", "Gloves",
"Towel", "Ball", "Bike", "Knife", "Pillow", "Blanket",
"Chair", "Lamp", "Table", "Bottle", "Backpack", "Keyboard", "Mug", "Shoes", "Jacket", "Watch",
"Wallet", "Bench", "Hat", "Gloves", "Towel", "Ball", "Bike", "Knife", "Pillow", "Blanket",
];
fn product_name(rng: &mut SeedRng) -> String {
@@ -396,7 +415,9 @@ mod tests {
] {
let v = gen_once(&generator, Type::Text, 3);
match v {
Value::Text(s) => assert!(!s.trim().is_empty(), "{generator:?} produced empty text"),
Value::Text(s) => {
assert!(!s.trim().is_empty(), "{generator:?} produced empty text")
}
other => panic!("{generator:?} produced non-text {other:?}"),
}
}
@@ -405,18 +426,25 @@ mod tests {
#[test]
fn email_looks_like_an_email() {
let v = gen_once(&Generator::Email, Type::Text, 11);
let Value::Text(s) = v else { panic!("not text") };
let Value::Text(s) = v else {
panic!("not text")
};
assert!(s.contains('@'), "email should contain @: {s}");
}
#[test]
fn product_name_is_three_capitalised_words() {
let v = gen_once(&Generator::ProductName, Type::Text, 99);
let Value::Text(s) = v else { panic!("not text") };
let Value::Text(s) = v else {
panic!("not text")
};
let words: Vec<&str> = s.split(' ').collect();
assert_eq!(words.len(), 3, "product name should be 3 words: {s}");
for w in words {
assert!(w.chars().next().unwrap().is_ascii_uppercase(), "word `{w}` not capitalised");
assert!(
w.chars().next().unwrap().is_ascii_uppercase(),
"word `{w}` not capitalised"
);
}
}
@@ -429,9 +457,14 @@ mod tests {
let latest = reference_date();
for _ in 0..200 {
let v = generate_value(&Generator::DateRecent, Type::Date, &mut rng);
let Value::Text(s) = v else { panic!("date not text") };
let Value::Text(s) = v else {
panic!("date not text")
};
let d = NaiveDate::parse_from_str(&s, "%Y-%m-%d").expect("valid ISO date");
assert!(d >= earliest && d <= latest, "date {d} outside recent window");
assert!(
d >= earliest && d <= latest,
"date {d} outside recent window"
);
}
}
@@ -446,7 +479,9 @@ mod tests {
.unwrap();
for _ in 0..200 {
let v = generate_value(&Generator::DateAdult, Type::Date, &mut rng);
let Value::Text(s) = v else { panic!("date not text") };
let Value::Text(s) = v else {
panic!("date not text")
};
let d = NaiveDate::parse_from_str(&s, "%Y-%m-%d").expect("valid ISO date");
assert!(d >= earliest && d <= latest, "dob {d} outside adult window");
}
@@ -455,7 +490,9 @@ mod tests {
#[test]
fn datetime_is_iso_shaped() {
let v = gen_once(&Generator::DateTimeRecent, Type::DateTime, 5);
let Value::Text(s) = v else { panic!("not text") };
let Value::Text(s) = v else {
panic!("not text")
};
assert!(s.contains('T'), "datetime needs a T separator: {s}");
// Parses as a naive datetime.
chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S")
@@ -467,11 +504,17 @@ mod tests {
let Value::Number(int_amt) = gen_once(&Generator::CurrencyAmount, Type::Int, 4) else {
panic!("not a number")
};
assert!(!int_amt.contains('.'), "int currency should be whole: {int_amt}");
assert!(
!int_amt.contains('.'),
"int currency should be whole: {int_amt}"
);
let Value::Number(dec_amt) = gen_once(&Generator::CurrencyAmount, Type::Decimal, 4) else {
panic!("not a number")
};
assert!(dec_amt.contains('.'), "decimal currency should have cents: {dec_amt}");
assert!(
dec_amt.contains('.'),
"decimal currency should have cents: {dec_amt}"
);
}
#[test]
@@ -494,7 +537,10 @@ mod tests {
let Value::Text(s) = generate_value(&generator, Type::Text, &mut rng) else {
panic!("not text")
};
assert!(matches!(s.as_str(), "active" | "closed"), "unexpected pick {s}");
assert!(
matches!(s.as_str(), "active" | "closed"),
"unexpected pick {s}"
);
}
}
@@ -503,7 +549,10 @@ mod tests {
let generator = Generator::PickFrom(vec!["1".into(), "2".into(), "3".into()]);
let mut rng = make_rng(Some(6));
let v = generate_value(&generator, Type::Int, &mut rng);
assert!(matches!(v, Value::Number(_)), "numeric pick should be a Number: {v:?}");
assert!(
matches!(v, Value::Number(_)),
"numeric pick should be a Number: {v:?}"
);
}
#[test]
@@ -517,7 +566,10 @@ mod tests {
panic!("YearRecent must be a Number")
};
let n: i32 = s.parse().unwrap();
assert!((1950..=2025).contains(&n), "YearRecent {n} out of [1950,2025]");
assert!(
(1950..=2025).contains(&n),
"YearRecent {n} out of [1950,2025]"
);
}
for _ in 0..300 {
let Value::Number(s) = generate_value(&Generator::YearBirth, Type::Int, &mut rng)
@@ -525,7 +577,10 @@ mod tests {
panic!("YearBirth must be a Number")
};
let n: i32 = s.parse().unwrap();
assert!((1945..=2007).contains(&n), "YearBirth {n} out of [1945,2007]");
assert!(
(1945..=2007).contains(&n),
"YearBirth {n} out of [1945,2007]"
);
}
}
@@ -543,7 +598,10 @@ mod tests {
#[test]
fn int_range_stays_within_inclusive_bounds() {
let g = Generator::Range { low: "10".into(), high: "20".into() };
let g = Generator::Range {
low: "10".into(),
high: "20".into(),
};
let mut rng = make_rng(Some(5));
for _ in 0..200 {
let Value::Number(s) = generate_value(&g, Type::Int, &mut rng) else {
@@ -556,7 +614,10 @@ mod tests {
#[test]
fn real_range_stays_within_bounds_and_has_cents() {
let g = Generator::Range { low: "1.0".into(), high: "9.0".into() };
let g = Generator::Range {
low: "1.0".into(),
high: "9.0".into(),
};
let mut rng = make_rng(Some(5));
for _ in 0..200 {
let Value::Number(s) = generate_value(&g, Type::Real, &mut rng) else {
@@ -588,13 +649,19 @@ mod tests {
#[test]
fn reversed_bounds_are_tolerated() {
let g = Generator::Range { low: "20".into(), high: "10".into() };
let g = Generator::Range {
low: "20".into(),
high: "10".into(),
};
let mut rng = make_rng(Some(1));
let Value::Number(s) = generate_value(&g, Type::Int, &mut rng) else {
panic!("number")
};
let n: i64 = s.parse().unwrap();
assert!((10..=20).contains(&n), "reversed bounds still produce in-range: {n}");
assert!(
(10..=20).contains(&n),
"reversed bounds still produce in-range: {n}"
);
}
#[test]
@@ -603,7 +670,10 @@ mod tests {
assert!(range_bounds_reason(Type::Int, "1", "10").is_none());
assert!(range_bounds_reason(Type::Real, "1.5", "9.9").is_none());
assert!(range_bounds_reason(Type::Date, "2023-01-01", "2024-01-01").is_none());
assert!(range_bounds_reason(Type::DateTime, "2023-01-01T00:00:00", "2024-01-01T00:00:00").is_none());
assert!(
range_bounds_reason(Type::DateTime, "2023-01-01T00:00:00", "2024-01-01T00:00:00")
.is_none()
);
// Non-numeric bound on a numeric column.
assert!(range_bounds_reason(Type::Int, "abc", "10").is_some());
// A range on a text column is meaningless.
@@ -623,14 +693,29 @@ mod tests {
#[test]
fn generic_fallback_matches_each_type() {
let mut rng = make_rng(Some(0));
assert!(matches!(generate_value(&Generator::Generic, Type::Text, &mut rng), Value::Text(_)));
assert!(matches!(generate_value(&Generator::Generic, Type::Int, &mut rng), Value::Number(_)));
assert!(matches!(generate_value(&Generator::Generic, Type::Bool, &mut rng), Value::Bool(_)));
assert!(matches!(generate_value(&Generator::Generic, Type::Blob, &mut rng), Value::Null));
assert!(matches!(
generate_value(&Generator::Generic, Type::Text, &mut rng),
Value::Text(_)
));
assert!(matches!(
generate_value(&Generator::Generic, Type::Int, &mut rng),
Value::Number(_)
));
assert!(matches!(
generate_value(&Generator::Generic, Type::Bool, &mut rng),
Value::Bool(_)
));
assert!(matches!(
generate_value(&Generator::Generic, Type::Blob, &mut rng),
Value::Null
));
// shortid fallback is a valid base58 id.
let Value::Text(sid) = generate_value(&Generator::Generic, Type::ShortId, &mut rng) else {
panic!("shortid not text")
};
assert!(crate::dsl::shortid::validate(&sid).is_ok(), "invalid shortid {sid}");
assert!(
crate::dsl::shortid::validate(&sid).is_ok(),
"invalid shortid {sid}"
);
}
}
+181 -44
View File
@@ -63,8 +63,7 @@ pub fn is_enum_ish(name: &str) -> bool {
// `rating` / `stars` were never here. `status` stays — it is
// deliberately left to the advisory (no built-in set).
const ENUM_TOKENS: &[&str] = &[
"role", "status", "state", "type", "kind", "category", "level",
"tier", "stage", "gender",
"role", "status", "state", "type", "kind", "category", "level", "tier", "stage", "gender",
];
let toks = tokens(name);
toks.iter().any(|t| ENUM_TOKENS.contains(&t.as_str()))
@@ -81,9 +80,7 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Genera
if text && (has_any(toks, &["fname", "firstname"]) || has_seq(toks, "first", "name")) {
return Some(Generator::FirstName);
}
if text
&& (has_any(toks, &["lname", "lastname", "surname"]) || has_seq(toks, "last", "name"))
{
if text && (has_any(toks, &["lname", "lastname", "surname"]) || has_seq(toks, "last", "name")) {
return Some(Generator::LastName);
}
if text && (has_any(toks, &["username", "login", "handle"]) || has_seq(toks, "user", "name")) {
@@ -116,7 +113,10 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Genera
// `province` / explicit `state_name`/`state_abbr` → a real state name.
// Bare `state` is left to enum-ish (it usually means status), so we
// require `province` or a `state` token paired with name/abbr.
if text && (has_token(toks, "province") || (has_token(toks, "state") && has_any(toks, &["name", "abbr"]))) {
if text
&& (has_token(toks, "province")
|| (has_token(toks, "state") && has_any(toks, &["name", "abbr"])))
{
return Some(Generator::StateName);
}
if text && has_any(toks, &["street", "address", "addr"]) {
@@ -127,7 +127,12 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Genera
}
// — Organisation / job —
if text && has_any(toks, &["company", "employer", "org", "organization", "organisation"]) {
if text
&& has_any(
toks,
&["company", "employer", "org", "organization", "organisation"],
)
{
return Some(Generator::Company);
}
if text && has_any(toks, &["job", "position", "profession", "occupation"]) {
@@ -135,7 +140,21 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Genera
}
// — Free text —
if text && has_any(toks, &["description", "bio", "notes", "note", "summary", "comment", "comments", "about"]) {
if text
&& has_any(
toks,
&[
"description",
"bio",
"notes",
"note",
"summary",
"comment",
"comments",
"about",
],
)
{
return Some(Generator::Sentence);
}
if text && has_any(toks, &["url", "website", "homepage", "link"]) {
@@ -146,7 +165,14 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Genera
}
// — Numeric —
if numeric && has_any(toks, &["price", "amount", "cost", "salary", "balance", "total", "fee", "revenue"]) {
if numeric
&& has_any(
toks,
&[
"price", "amount", "cost", "salary", "balance", "total", "fee", "revenue",
],
)
{
return Some(Generator::CurrencyAmount);
}
if numeric && has_token(toks, "age") {
@@ -233,18 +259,50 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Genera
fn name_by_table_context(table: &str) -> Generator {
let toks = tokens(table);
const PRODUCTY: &[&str] = &[
"product", "products", "item", "items", "good", "goods",
"merchandise", "catalog", "catalogue", "inventory", "sku", "skus",
"product",
"products",
"item",
"items",
"good",
"goods",
"merchandise",
"catalog",
"catalogue",
"inventory",
"sku",
"skus",
];
const COMPANYISH: &[&str] = &[
"company", "companies", "vendor", "vendors", "supplier",
"suppliers", "manufacturer", "manufacturers", "brand", "brands",
"organization", "organisation",
"company",
"companies",
"vendor",
"vendors",
"supplier",
"suppliers",
"manufacturer",
"manufacturers",
"brand",
"brands",
"organization",
"organisation",
];
const PERSONISH: &[&str] = &[
"user", "users", "customer", "customers", "person", "people",
"employee", "employees", "member", "members", "contact",
"contacts", "author", "authors", "student", "students",
"user",
"users",
"customer",
"customers",
"person",
"people",
"employee",
"employees",
"member",
"members",
"contact",
"contacts",
"author",
"authors",
"student",
"students",
];
if has_any(&toks, PRODUCTY) {
Generator::ProductName
@@ -264,9 +322,8 @@ fn name_by_table_context(table: &str) -> Generator {
/// before this guard; this catches structural names.
fn is_name_false_positive(toks: &[String]) -> bool {
const NON_PERSON: &[&str] = &[
"file", "table", "host", "domain", "field", "class", "tag",
"event", "path", "col", "column", "db", "schema", "index", "key",
"page", "node", "type",
"file", "table", "host", "domain", "field", "class", "tag", "event", "path", "col",
"column", "db", "schema", "index", "key", "page", "node", "type",
];
has_any(toks, NON_PERSON) && has_any(toks, &["name", "title"])
}
@@ -357,9 +414,18 @@ mod tests {
#[test]
fn person_name_fields_map_to_name_generators() {
assert_eq!(choose("users", "first_name", Type::Text), Generator::FirstName);
assert_eq!(choose("users", "firstName", Type::Text), Generator::FirstName);
assert_eq!(choose("users", "last_name", Type::Text), Generator::LastName);
assert_eq!(
choose("users", "first_name", Type::Text),
Generator::FirstName
);
assert_eq!(
choose("users", "firstName", Type::Text),
Generator::FirstName
);
assert_eq!(
choose("users", "last_name", Type::Text),
Generator::LastName
);
assert_eq!(choose("users", "surname", Type::Text), Generator::LastName);
}
@@ -368,9 +434,15 @@ mod tests {
assert_eq!(choose("users", "email", Type::Text), Generator::Email);
assert_eq!(choose("users", "work_email", Type::Text), Generator::Email);
assert_eq!(choose("users", "username", Type::Text), Generator::Username);
assert_eq!(choose("users", "user_name", Type::Text), Generator::Username);
assert_eq!(
choose("users", "user_name", Type::Text),
Generator::Username
);
assert_eq!(choose("users", "phone", Type::Text), Generator::Phone);
assert_eq!(choose("accounts", "password", Type::Text), Generator::Password);
assert_eq!(
choose("accounts", "password", Type::Text),
Generator::Password
);
}
#[test]
@@ -386,7 +458,10 @@ mod tests {
#[test]
fn bare_name_uses_table_context() {
// D11 — the same column name resolves differently by table.
assert_eq!(choose("products", "name", Type::Text), Generator::ProductName);
assert_eq!(
choose("products", "name", Type::Text),
Generator::ProductName
);
assert_eq!(choose("items", "title", Type::Text), Generator::ProductName);
assert_eq!(choose("users", "name", Type::Text), Generator::FullName);
assert_eq!(choose("customers", "name", Type::Text), Generator::FullName);
@@ -399,7 +474,10 @@ mod tests {
fn name_false_positives_do_not_become_person_names() {
// These must NOT resolve to a person/product name.
assert_ne!(choose("files", "filename", Type::Text), Generator::FullName);
assert_ne!(choose("meta", "table_name", Type::Text), Generator::FullName);
assert_ne!(
choose("meta", "table_name", Type::Text),
Generator::FullName
);
// They fall through to a generic / non-person generator.
assert_eq!(choose("files", "filename", Type::Text), Generator::Generic);
}
@@ -408,7 +486,10 @@ mod tests {
fn numeric_name_heuristics_are_type_gated() {
// `price` on a numeric column → currency; on text → falls through.
assert_eq!(choose("p", "price", Type::Int), Generator::CurrencyAmount);
assert_eq!(choose("p", "price", Type::Decimal), Generator::CurrencyAmount);
assert_eq!(
choose("p", "price", Type::Decimal),
Generator::CurrencyAmount
);
assert_eq!(choose("p", "price", Type::Text), Generator::Generic);
assert_eq!(choose("u", "age", Type::Int), Generator::Age);
assert_eq!(choose("o", "quantity", Type::Int), Generator::SmallInt);
@@ -425,8 +506,14 @@ mod tests {
fn temporal_fields_are_bounded_and_type_gated() {
assert_eq!(choose("u", "dob", Type::Date), Generator::DateAdult);
assert_eq!(choose("o", "order_date", Type::Date), Generator::DateRecent);
assert_eq!(choose("o", "created_at", Type::DateTime), Generator::DateTimeRecent);
assert_eq!(choose("o", "timestamp", Type::DateTime), Generator::DateTimeRecent);
assert_eq!(
choose("o", "created_at", Type::DateTime),
Generator::DateTimeRecent
);
assert_eq!(
choose("o", "timestamp", Type::DateTime),
Generator::DateTimeRecent
);
// Wrong type → not a date generator.
assert_eq!(choose("o", "order_date", Type::Int), Generator::Generic);
}
@@ -440,17 +527,32 @@ mod tests {
#[test]
fn identifier_family_is_unique_sequential() {
assert_eq!(choose("t", "code", Type::Text), Generator::IdentitySequential);
assert_eq!(choose("t", "sku", Type::Text), Generator::IdentitySequential);
assert_eq!(choose("t", "order_number", Type::Int), Generator::IdentitySequential);
assert_eq!(choose("t", "external_id", Type::Int), Generator::IdentitySequential);
assert_eq!(
choose("t", "code", Type::Text),
Generator::IdentitySequential
);
assert_eq!(
choose("t", "sku", Type::Text),
Generator::IdentitySequential
);
assert_eq!(
choose("t", "order_number", Type::Int),
Generator::IdentitySequential
);
assert_eq!(
choose("t", "external_id", Type::Int),
Generator::IdentitySequential
);
}
#[test]
fn foreign_key_columns_defer_to_executor() {
let mut spec = ColumnSpec::plain("user_id", Type::Int);
spec.is_foreign_key = true;
assert_eq!(choose_generator("orders", &spec), Generator::ForeignKeySample);
assert_eq!(
choose_generator("orders", &spec),
Generator::ForeignKeySample
);
}
#[test]
@@ -481,13 +583,28 @@ mod tests {
fn year_like_int_columns_map_to_bounded_years() {
// Issue #33: `int`-gated year heuristics. `birth`/`born`/`dob`
// years pick the birth window; the rest a recent window.
assert_eq!(choose("authors", "birth_year", Type::Int), Generator::YearBirth);
assert_eq!(choose("authors", "birthYear", Type::Int), Generator::YearBirth);
assert_eq!(
choose("authors", "birth_year", Type::Int),
Generator::YearBirth
);
assert_eq!(
choose("authors", "birthYear", Type::Int),
Generator::YearBirth
);
assert_eq!(choose("u", "year_born", Type::Int), Generator::YearBirth);
assert_eq!(choose("books", "year", Type::Int), Generator::YearRecent);
assert_eq!(choose("films", "release_year", Type::Int), Generator::YearRecent);
assert_eq!(choose("books", "published", Type::Int), Generator::YearRecent);
assert_eq!(choose("companies", "founded", Type::Int), Generator::YearRecent);
assert_eq!(
choose("films", "release_year", Type::Int),
Generator::YearRecent
);
assert_eq!(
choose("books", "published", Type::Int),
Generator::YearRecent
);
assert_eq!(
choose("companies", "founded", Type::Int),
Generator::YearRecent
);
// Type-gated: a text `year` is not a bounded-year int.
assert_eq!(choose("books", "year", Type::Text), Generator::Generic);
// `year_count` is a count, not a year — the quantity rule wins.
@@ -507,7 +624,12 @@ mod tests {
);
assert_eq!(
choose("bugs", "severity", Type::Text),
Generator::PickFrom(vec!["low".into(), "medium".into(), "high".into(), "critical".into()]),
Generator::PickFrom(vec![
"low".into(),
"medium".into(),
"high".into(),
"critical".into()
]),
);
assert_eq!(
choose("bugs", "severity", Type::Int),
@@ -515,11 +637,23 @@ mod tests {
);
assert_eq!(
choose("reviews", "rating", Type::Int),
Generator::PickFrom(vec!["1".into(), "2".into(), "3".into(), "4".into(), "5".into()]),
Generator::PickFrom(vec![
"1".into(),
"2".into(),
"3".into(),
"4".into(),
"5".into()
]),
);
assert_eq!(
choose("reviews", "stars", Type::Int),
Generator::PickFrom(vec!["1".into(), "2".into(), "3".into(), "4".into(), "5".into()]),
Generator::PickFrom(vec![
"1".into(),
"2".into(),
"3".into(),
"4".into(),
"5".into()
]),
);
}
@@ -552,7 +686,10 @@ mod tests {
#[test]
fn unmatched_columns_use_type_based_fallback() {
assert_eq!(choose("t", "some_freeform_field", Type::Text), Generator::Generic);
assert_eq!(
choose("t", "some_freeform_field", Type::Text),
Generator::Generic
);
}
#[test]
+5 -2
View File
@@ -32,7 +32,7 @@ mod vocabulary;
pub use check::parse_in_check_values;
pub use generators::{generate_value, range_bounds_reason};
pub use heuristics::{choose_generator, is_enum_ish};
pub use vocabulary::{generator_for_name, is_known_generator_prefix, KNOWN_GENERATORS};
pub use vocabulary::{KNOWN_GENERATORS, generator_for_name, is_known_generator_prefix};
use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng};
@@ -183,7 +183,10 @@ pub enum Generator {
/// does not parse for the column type is a friendly error), so
/// [`generate_value`] only ever sees parseable bounds; a defensive
/// parse failure falls back to type-based generation.
Range { low: String, high: String },
Range {
low: String,
high: String,
},
/// Type-based fallback (D8) when no name heuristic matches.
Generic,
}
+38 -29
View File
@@ -104,15 +104,15 @@ impl Theme {
// remains restful; literals and flags get warm
// accent tones; keyword takes a cool accent tone
// distinct from the mode-banner blue.
tok_keyword: Color::Rgb(0xC7, 0x92, 0xEA), // muted purple
tok_keyword: Color::Rgb(0xC7, 0x92, 0xEA), // muted purple
tok_identifier: Color::Rgb(0x56, 0xB6, 0xC2), // cyan-teal — identifiers are the user's content, deserve a vivid distinct colour
tok_type: Color::Rgb(0xF0, 0x8F, 0xC0), // pink — types sit in the red-purple range, clearly apart from the lavender keyword and teal identifier
tok_number: Color::Rgb(0xF7, 0x8C, 0x6C), // warm orange
tok_string: Color::Rgb(0xC3, 0xE8, 0x8D), // soft green
tok_punct: Color::Rgb(0x8B, 0x90, 0x9A), // == muted
tok_flag: Color::Rgb(0xFF, 0xCB, 0x6B), // amber
tok_error: Color::Rgb(0xFF, 0x6B, 0x6B), // == error
tok_function: Color::Rgb(0x82, 0xCF, 0xFD), // sky blue — cool like keyword but bluer, clearly apart from purple keyword + teal identifier + pink type
tok_type: Color::Rgb(0xF0, 0x8F, 0xC0), // pink — types sit in the red-purple range, clearly apart from the lavender keyword and teal identifier
tok_number: Color::Rgb(0xF7, 0x8C, 0x6C), // warm orange
tok_string: Color::Rgb(0xC3, 0xE8, 0x8D), // soft green
tok_punct: Color::Rgb(0x8B, 0x90, 0x9A), // == muted
tok_flag: Color::Rgb(0xFF, 0xCB, 0x6B), // amber
tok_error: Color::Rgb(0xFF, 0x6B, 0x6B), // == error
tok_function: Color::Rgb(0x82, 0xCF, 0xFD), // sky blue — cool like keyword but bluer, clearly apart from purple keyword + teal identifier + pink type
}
}
@@ -135,15 +135,15 @@ impl Theme {
// Light-theme token palette: same intent as dark —
// identifier/punct close to fg/muted; warm tones for
// literals + flags; cool accent for keyword.
tok_keyword: Color::Rgb(0x6F, 0x42, 0xC1), // royal purple
tok_keyword: Color::Rgb(0x6F, 0x42, 0xC1), // royal purple
tok_identifier: Color::Rgb(0x0F, 0x6B, 0x76), // deep teal — same role as dark variant: identifiers stand out
tok_type: Color::Rgb(0xA8, 0x2D, 0x73), // deep magenta — red-purple, distinct from royal-purple keyword + teal identifier
tok_number: Color::Rgb(0xBC, 0x4F, 0x1F), // burnt orange
tok_string: Color::Rgb(0x22, 0x86, 0x3A), // forest green
tok_punct: Color::Rgb(0x60, 0x66, 0x73), // == muted
tok_flag: Color::Rgb(0xB0, 0x88, 0x00), // mustard
tok_error: Color::Rgb(0xC0, 0x39, 0x2B), // == error
tok_function: Color::Rgb(0x1A, 0x5F, 0xB0), // strong blue — cool like keyword but bluer, apart from royal-purple keyword + teal identifier + magenta type
tok_type: Color::Rgb(0xA8, 0x2D, 0x73), // deep magenta — red-purple, distinct from royal-purple keyword + teal identifier
tok_number: Color::Rgb(0xBC, 0x4F, 0x1F), // burnt orange
tok_string: Color::Rgb(0x22, 0x86, 0x3A), // forest green
tok_punct: Color::Rgb(0x60, 0x66, 0x73), // == muted
tok_flag: Color::Rgb(0xB0, 0x88, 0x00), // mustard
tok_error: Color::Rgb(0xC0, 0x39, 0x2B), // == error
tok_function: Color::Rgb(0x1A, 0x5F, 0xB0), // strong blue — cool like keyword but bluer, apart from royal-purple keyword + teal identifier + magenta type
}
}
@@ -192,10 +192,7 @@ mod tests {
("tok_function", t.tok_function),
("warning", t.warning),
] {
assert_ne!(
c, t.bg,
"{name} must contrast against bg in dark theme",
);
assert_ne!(c, t.bg, "{name} must contrast against bg in dark theme",);
}
}
@@ -212,24 +209,36 @@ mod tests {
("tok_function", t.tok_function),
("warning", t.warning),
] {
assert_ne!(
c, t.bg,
"{name} must contrast against bg in light theme",
);
assert_ne!(c, t.bg, "{name} must contrast against bg in light theme",);
}
}
#[test]
fn highlight_class_color_maps_each_variant() {
let t = Theme::dark();
assert_eq!(t.highlight_class_color(HighlightClass::Keyword), t.tok_keyword);
assert_eq!(t.highlight_class_color(HighlightClass::Identifier), t.tok_identifier);
assert_eq!(
t.highlight_class_color(HighlightClass::Keyword),
t.tok_keyword
);
assert_eq!(
t.highlight_class_color(HighlightClass::Identifier),
t.tok_identifier
);
assert_eq!(t.highlight_class_color(HighlightClass::Type), t.tok_type);
assert_eq!(t.highlight_class_color(HighlightClass::Number), t.tok_number);
assert_eq!(t.highlight_class_color(HighlightClass::String), t.tok_string);
assert_eq!(
t.highlight_class_color(HighlightClass::Number),
t.tok_number
);
assert_eq!(
t.highlight_class_color(HighlightClass::String),
t.tok_string
);
assert_eq!(t.highlight_class_color(HighlightClass::Punct), t.tok_punct);
assert_eq!(t.highlight_class_color(HighlightClass::Flag), t.tok_flag);
assert_eq!(t.highlight_class_color(HighlightClass::Function), t.tok_function);
assert_eq!(
t.highlight_class_color(HighlightClass::Function),
t.tok_function
);
assert_eq!(t.highlight_class_color(HighlightClass::Error), t.tok_error);
}
+18 -21
View File
@@ -87,9 +87,7 @@ pub fn static_refusal(src: Type, target: Type) -> Option<String> {
}
const fn is_in_matrix(src: Type, target: Type) -> bool {
use Type::{
Bool, Date, DateTime, Decimal, Int, Real, Serial, ShortId, Text,
};
use Type::{Bool, Date, DateTime, Decimal, Int, Real, Serial, ShortId, Text};
matches!(
(src, target),
// Always-clean transformers
@@ -130,9 +128,7 @@ pub fn transform_cell(src: Type, target: Type, value: &Value) -> CellOutcome {
if matches!(value, Value::Null) {
return CellOutcome::Clean(Value::Null);
}
use Type::{
Bool, Date, DateTime, Decimal, Int, Real, Serial, ShortId, Text,
};
use Type::{Bool, Date, DateTime, Decimal, Int, Real, Serial, ShortId, Text};
match (src, target) {
// ---- Always-clean: int / serial source ----
(Int | Serial, Real) => match value {
@@ -179,9 +175,11 @@ pub fn transform_cell(src: Type, target: Type, value: &Value) -> CellOutcome {
(Bool, Text) => match value {
// "true" / "false" matches the DSL boolean grammar
// (ADR-0014 §5), not raw 0/1 stringification.
Value::Integer(i) => CellOutcome::Clean(Value::Text(
if *i == 0 { "false".into() } else { "true".into() },
)),
Value::Integer(i) => CellOutcome::Clean(Value::Text(if *i == 0 {
"false".into()
} else {
"true".into()
})),
other => unexpected_storage("bool", other),
},
@@ -369,9 +367,7 @@ pub fn transform_cell(src: Type, target: Type, value: &Value) -> CellOutcome {
}
} else {
CellOutcome::Incompatible {
reason: format!(
"`{s}` is not a datetime in `YYYY-MM-DDTHH:MM:SS` form"
),
reason: format!("`{s}` is not a datetime in `YYYY-MM-DDTHH:MM:SS` form"),
}
}
}
@@ -450,10 +446,7 @@ fn real_to_int(r: f64) -> CellOutcome {
let discarded = r - r.trunc();
CellOutcome::Lossy {
new: Value::Integer(truncated),
reason: format!(
"truncated; would discard {}",
format_real(discarded)
),
reason: format!("truncated; would discard {}", format_real(discarded)),
}
}
}
@@ -555,9 +548,7 @@ fn format_real(r: f64) -> String {
fn unexpected_storage(label: &str, value: &Value) -> CellOutcome {
CellOutcome::Incompatible {
reason: format!(
"internal: cell stored unexpectedly for `{label}` source ({value:?})"
),
reason: format!("internal: cell stored unexpectedly for `{label}` source ({value:?})"),
}
}
@@ -638,7 +629,10 @@ mod tests {
(Type::Date, Type::Int),
(Type::ShortId, Type::Int),
] {
assert!(static_refusal(src, target).is_some(), "{src:?} -> {target:?}");
assert!(
static_refusal(src, target).is_some(),
"{src:?} -> {target:?}"
);
}
}
@@ -672,7 +666,10 @@ mod tests {
(Type::Bool, Type::Real),
];
for (s, t) in pairs {
assert_eq!(transform_cell(s, t, &Value::Null), CellOutcome::Clean(Value::Null));
assert_eq!(
transform_cell(s, t, &Value::Null),
CellOutcome::Clean(Value::Null)
);
}
}
+238 -135
View File
@@ -196,7 +196,16 @@ fn render_badge_box(label: &str, area: Rect, above: Option<Rect>, frame: &mut Fr
area.y + area.height - box_h - 1
}
};
fill_overlay_rect(Rect { x, y, width: box_w, height: box_h }, label.to_string(), frame);
fill_overlay_rect(
Rect {
x,
y,
width: box_w,
height: box_h,
},
label.to_string(),
frame,
);
}
/// A step-caption box inset one cell from the bottom-right of `area`
@@ -309,7 +318,9 @@ fn render_path_entry(
let inner_w = dialog_w.saturating_sub(4) as usize;
let prompt_lines = wrap_lines(&m.prompt, inner_w);
// Title + blank + prompt + blank + input box (1 row + borders) + blank + key hints.
let dialog_h = (prompt_lines.len() as u16).saturating_add(8).min(area.height);
let dialog_h = (prompt_lines.len() as u16)
.saturating_add(8)
.min(area.height);
let x = area.x + (area.width.saturating_sub(dialog_w)) / 2;
let y = area.y + (area.height.saturating_sub(dialog_h)) / 2;
let dialog_area = Rect {
@@ -320,9 +331,7 @@ fn render_path_entry(
};
frame.render_widget(ratatui::widgets::Clear, dialog_area);
let title_style = Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD);
let title_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
@@ -386,9 +395,7 @@ fn render_load_picker(
};
frame.render_widget(ratatui::widgets::Clear, dialog_area);
let title_style = Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD);
let title_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
@@ -411,9 +418,7 @@ fn render_load_picker(
let marker = if i == m.selected { "" } else { " " };
let temp_tag = if entry.is_temp { "[TEMP] " } else { "" };
let style = if i == m.selected {
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD)
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.fg)
};
@@ -447,11 +452,7 @@ fn render_load_picker(
let display_input = if *cursor == input.len() {
format!("{input}{cursor_marker}")
} else {
format!(
"{}{cursor_marker}{}",
&input[..*cursor],
&input[*cursor..]
)
format!("{}{cursor_marker}{}", &input[..*cursor], &input[*cursor..])
};
text_lines.push(Line::from(format!("> {display_input}")));
text_lines.push(Line::from(""));
@@ -500,9 +501,7 @@ fn render_rebuild_confirm(summary: &str, theme: &Theme, frame: &mut Frame<'_>, a
let bg = ratatui::widgets::Clear;
frame.render_widget(bg, dialog_area);
let title_style = Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD);
let title_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
@@ -524,16 +523,12 @@ fn render_rebuild_confirm(summary: &str, theme: &Theme, frame: &mut Frame<'_>, a
text_lines.push(Line::from(vec![
Span::styled(
"[Y]",
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
),
Span::raw(format!(" {} ", crate::t!("shortcut.yes"))),
Span::styled(
"[N]",
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
),
Span::raw(format!(" {} ", crate::t!("shortcut.no"))),
Span::styled("Esc", Style::default().fg(theme.muted)),
@@ -578,18 +573,14 @@ where
/// dialog (issue #13): wide enough to hold the longest content
/// line on a single row, clamped to sane bounds and the available
/// area so a short insert no longer wraps on roomy terminals.
fn undo_dialog_width(
content_widths: impl IntoIterator<Item = usize>,
area_width: u16,
) -> u16 {
fn undo_dialog_width(content_widths: impl IntoIterator<Item = usize>, area_width: u16) -> u16 {
/// Floor — comfortably fits the button row plus borders.
const MIN: u16 = 34;
/// Ceiling for outlier (ultra-wide) terminals.
const MAX: u16 = 100;
let widest = content_widths.into_iter().max().unwrap_or(0);
// +4: left/right border (2) + one padding column each side (2).
let preferred =
u16::try_from(widest).unwrap_or(u16::MAX).saturating_add(4);
let preferred = u16::try_from(widest).unwrap_or(u16::MAX).saturating_add(4);
let upper = area_width.min(MAX);
let lower = MIN.min(upper);
preferred.clamp(lower, upper)
@@ -617,8 +608,7 @@ fn render_undo_confirm(
let intro_line = format!("{intro} {}", m.command);
// Local-time, human-formatted snapshot stamp (issue #13).
let when_display = format_snapshot_timestamp(&m.timestamp);
let when_line =
crate::t!("modal.undo_confirm_when", timestamp = when_display);
let when_line = crate::t!("modal.undo_confirm_when", timestamp = when_display);
let prompt = crate::t!("modal.undo_confirm_prompt");
// Reconstruct the button row purely to measure its width — the
// styled spans are built below. Keep this in sync with them.
@@ -681,9 +671,15 @@ fn render_undo_confirm(
text_lines.push(Line::from(prompt));
text_lines.push(Line::from(""));
text_lines.push(Line::from(vec![
Span::styled("[Y]", Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)),
Span::styled(
"[Y]",
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
),
Span::raw(format!(" {} ", crate::t!("shortcut.yes"))),
Span::styled("[N]", Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)),
Span::styled(
"[N]",
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
),
Span::raw(format!(" {} ", crate::t!("shortcut.no"))),
Span::styled("Esc", Style::default().fg(theme.muted)),
Span::styled(
@@ -811,17 +807,13 @@ fn clamp_wrapped(text: &str, width: usize, max_rows: usize) -> Vec<String> {
fn render_project_label(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let label_style = Style::default().fg(theme.muted);
let value_style = Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD);
let value_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
let bar_style = Style::default().bg(theme.bg).fg(theme.muted);
let no_project = crate::t!("status.no_project");
let display = app.project_name.as_deref().unwrap_or(no_project.as_str());
let mut spans: Vec<Span<'_>> = vec![Span::styled(
crate::t!("status.project_label"),
label_style,
)];
let mut spans: Vec<Span<'_>> =
vec![Span::styled(crate::t!("status.project_label"), label_style)];
if app.project_is_temp {
spans.push(Span::styled(
"[TEMP] ",
@@ -875,9 +867,7 @@ fn render_items_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area:
))
.title(Span::styled(
format!(" {} ", crate::t!("panel.tables_title")),
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme.bg).fg(theme.fg));
@@ -918,9 +908,7 @@ fn render_items_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area:
let mut lines: Vec<Line<'_>> = Vec::new();
for name in &app.tables {
let style = if name == highlight {
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD)
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.fg)
};
@@ -937,7 +925,9 @@ fn render_items_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area:
}
}
}
let paragraph = Paragraph::new(lines).block(block).scroll((offset as u16, 0));
let paragraph = Paragraph::new(lines)
.block(block)
.scroll((offset as u16, 0));
frame.render_widget(paragraph, area);
}
@@ -956,9 +946,7 @@ fn render_relationships_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_
))
.title(Span::styled(
format!(" {} ", crate::t!("panel.relationships_title")),
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme.bg).fg(theme.fg));
@@ -992,12 +980,24 @@ fn render_relationships_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_
ellipsize(&rel.name, inner_w),
name_style,
)));
let parent = format!(" {}.{} ->", rel.parent_table, rel.parent_columns.join(", "));
lines.push(Line::from(Span::styled(ellipsize(&parent, inner_w), detail_style)));
let parent = format!(
" {}.{} ->",
rel.parent_table,
rel.parent_columns.join(", ")
);
lines.push(Line::from(Span::styled(
ellipsize(&parent, inner_w),
detail_style,
)));
let child = format!(" {}.{}", rel.child_table, rel.child_columns.join(", "));
lines.push(Line::from(Span::styled(ellipsize(&child, inner_w), detail_style)));
lines.push(Line::from(Span::styled(
ellipsize(&child, inner_w),
detail_style,
)));
}
let paragraph = Paragraph::new(lines).block(block).scroll((offset as u16, 0));
let paragraph = Paragraph::new(lines)
.block(block)
.scroll((offset as u16, 0));
frame.render_widget(paragraph, area);
}
@@ -1022,9 +1022,7 @@ fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area
.border_style(Style::default().fg(theme.border))
.title(Span::styled(
format!(" {} ", crate::t!("panel.output_title")),
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme.bg).fg(theme.fg));
@@ -1132,9 +1130,9 @@ const fn output_span_style(class: OutputStyleClass, theme: &Theme) -> Style {
OutputStyleClass::Neutral => Style::new().fg(theme.fg),
OutputStyleClass::Efficient => Style::new().fg(theme.plan_efficient),
OutputStyleClass::Expensive => Style::new().fg(theme.warning),
OutputStyleClass::AutomaticIndex => Style::new()
.fg(theme.warning)
.add_modifier(Modifier::BOLD),
OutputStyleClass::AutomaticIndex => {
Style::new().fg(theme.warning).add_modifier(Modifier::BOLD)
}
// ADR-0038 §4 / §6: de-emphasised text — the `Executing SQL:`
// prefix and every category-3 prose line (caveat + the
// existing `client_side.*` notes). `theme.muted` is the
@@ -1239,9 +1237,7 @@ fn render_output_line<'a>(line: &'a OutputLine, theme: &Theme) -> Line<'a> {
&line.text[..prefix_len],
Style::default().fg(theme.muted),
));
for run in
crate::input_render::lex_to_runs_in_mode(rest, theme, Mode::Advanced)
{
for run in crate::input_render::lex_to_runs_in_mode(rest, theme, Mode::Advanced) {
spans.push(Span::styled(
&rest[run.byte_range.0..run.byte_range.1],
run.style,
@@ -1350,9 +1346,7 @@ fn render_input_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area:
Span::raw(" "),
Span::styled(
label,
Style::default()
.fg(mode_color)
.add_modifier(Modifier::BOLD),
Style::default().fg(mode_color).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
]);
@@ -1474,7 +1468,10 @@ fn render_input_one_row(
if offset > 0 {
frame.render_widget(
Paragraph::new(Span::styled("<", marker)),
Rect { width: 1, ..text_area },
Rect {
width: 1,
..text_area
},
);
}
if offset + eff < line_cols {
@@ -1536,8 +1533,16 @@ fn render_input_two_rows(
// Overflowing both rows reserves a marker column on each row's
// outer edge; otherwise both rows use their full text width.
let overflow = line_cols >= capacity;
let row0_text_w = if overflow { row0_w.saturating_sub(1) } else { row0_w };
let row1_text_w = if overflow { row1_w.saturating_sub(1) } else { row1_w };
let row0_text_w = if overflow {
row0_w.saturating_sub(1)
} else {
row0_w
};
let row1_text_w = if overflow {
row1_w.saturating_sub(1)
} else {
row1_w
};
let eff_cap = row0_text_w + row1_text_w;
let start = offset.min(len);
@@ -1552,7 +1557,11 @@ fn render_input_two_rows(
)
};
let row0_x = if overflow { text_area.x + 1 } else { text_area.x };
let row0_x = if overflow {
text_area.x + 1
} else {
text_area.x
};
frame.render_widget(
Paragraph::new(to_line(&window[..split])),
Rect {
@@ -1622,10 +1631,7 @@ fn expand_runs_to_cells(
/// Convert `StyledRun`s into ratatui `Span`s borrowed from
/// `input`. The end-of-input cursor sentinel (empty range) is
/// rendered as an inverted space.
fn runs_to_spans<'a>(
input: &'a str,
runs: &[crate::input_render::StyledRun],
) -> Vec<Span<'a>> {
fn runs_to_spans<'a>(input: &'a str, runs: &[crate::input_render::StyledRun]) -> Vec<Span<'a>> {
runs.iter()
.map(|r| {
if r.byte_range.0 == r.byte_range.1 {
@@ -1710,21 +1716,14 @@ fn resolve_hint_lines(
}
}
fn render_hint_panel(
theme: &Theme,
frame: &mut Frame<'_>,
area: Rect,
lines: Vec<Line<'static>>,
) {
fn render_hint_panel(theme: &Theme, frame: &mut Frame<'_>, area: Rect, lines: Vec<Line<'static>>) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border))
.title(Span::styled(
format!(" {} ", crate::t!("panel.hint_title")),
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme.bg).fg(theme.fg));
@@ -1918,9 +1917,7 @@ fn status_bar_bindings(app: &App) -> Vec<(&'static str, String)> {
}
fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let key_style = Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD);
let key_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
let sep_style = Style::default().fg(theme.muted);
let label_style = Style::default().fg(theme.muted);
let bar_style = Style::default().bg(theme.bg).fg(theme.muted);
@@ -2016,9 +2013,16 @@ mod tests {
};
let rendered = render_output_line(&line, &theme);
// [system] tag, then the dim prefix, then ≥1 SQL spans.
assert!(rendered.spans.len() >= 3, "tag + prefix + sql: {:?}", rendered.spans);
assert!(
rendered.spans.len() >= 3,
"tag + prefix + sql: {:?}",
rendered.spans
);
assert_eq!(rendered.spans[0].content.as_ref(), "[system] ");
assert_eq!(rendered.spans[1].content.as_ref(), crate::echo::TEACHING_ECHO_LABEL);
assert_eq!(
rendered.spans[1].content.as_ref(),
crate::echo::TEACHING_ECHO_LABEL
);
assert_eq!(
rendered.spans[1].style.fg,
Some(theme.muted),
@@ -2152,17 +2156,41 @@ mod tests {
use crate::completion::{Candidate, CandidateKind, ModeClass};
let theme = Theme::dark();
let items = vec![
Candidate { text: "table".into(), kind: CandidateKind::Keyword, mode: ModeClass::Both },
Candidate { text: "index".into(), kind: CandidateKind::Keyword, mode: ModeClass::Advanced },
Candidate { text: "relationship".into(), kind: CandidateKind::Keyword, mode: ModeClass::Simple },
Candidate {
text: "table".into(),
kind: CandidateKind::Keyword,
mode: ModeClass::Both,
},
Candidate {
text: "index".into(),
kind: CandidateKind::Keyword,
mode: ModeClass::Advanced,
},
Candidate {
text: "relationship".into(),
kind: CandidateKind::Keyword,
mode: ModeClass::Simple,
},
];
let line = render_candidate_line(&items, None, 100, &theme);
assert_eq!(line.spans[0].content.as_ref(), "table");
assert_eq!(line.spans[0].style.fg, Some(theme.tok_keyword), "Both keeps the kind colour");
assert_eq!(
line.spans[0].style.fg,
Some(theme.tok_keyword),
"Both keeps the kind colour"
);
assert_eq!(line.spans[2].content.as_ref(), "index");
assert_eq!(line.spans[2].style.fg, Some(theme.mode_advanced), "Advanced → advanced colour");
assert_eq!(
line.spans[2].style.fg,
Some(theme.mode_advanced),
"Advanced → advanced colour"
);
assert_eq!(line.spans[4].content.as_ref(), "relationship");
assert_eq!(line.spans[4].style.fg, Some(theme.mode_simple), "Simple → simple colour");
assert_eq!(
line.spans[4].style.fg,
Some(theme.mode_simple),
"Simple → simple colour"
);
}
#[test]
@@ -2173,8 +2201,16 @@ mod tests {
use crate::completion::{Candidate, CandidateKind, ModeClass};
let theme = Theme::dark();
let items = vec![
Candidate { text: "values".into(), kind: CandidateKind::Keyword, mode: ModeClass::Advanced },
Candidate { text: "select".into(), kind: CandidateKind::Keyword, mode: ModeClass::Advanced },
Candidate {
text: "values".into(),
kind: CandidateKind::Keyword,
mode: ModeClass::Advanced,
},
Candidate {
text: "select".into(),
kind: CandidateKind::Keyword,
mode: ModeClass::Advanced,
},
];
let line = render_candidate_line(&items, None, 100, &theme);
assert_eq!(
@@ -2248,7 +2284,10 @@ mod tests {
"the error body is neutral fg, not flooded red",
);
assert!(
rendered.spans[1].style.add_modifier.contains(Modifier::BOLD),
rendered.spans[1]
.style
.add_modifier
.contains(Modifier::BOLD),
"the error body is bold for weight without the red-wall readability cost",
);
}
@@ -2509,10 +2548,14 @@ mod tests {
"the tail around the cursor must be visible:\n{out}"
);
assert!(
!out.lines().any(|l| l.contains("select * from Customers where")),
!out.lines()
.any(|l| l.contains("select * from Customers where")),
"the head must be scrolled off:\n{out}"
);
assert!(out.contains('<'), "a left scroll marker signals the hidden head:\n{out}");
assert!(
out.contains('<'),
"a left scroll marker signals the hidden head:\n{out}"
);
}
#[test]
@@ -2525,9 +2568,18 @@ mod tests {
let theme = Theme::dark();
// Narrow (sidebar hidden, DB1) so the line overflows the field.
let out = render_to_string(&mut app, &theme, 60, 24);
assert!(out.contains("select * from"), "head visible at Home:\n{out}");
assert!(out.contains('>'), "a right scroll marker signals the hidden tail:\n{out}");
assert!(!out.contains("Wonderland"), "the tail must be scrolled off:\n{out}");
assert!(
out.contains("select * from"),
"head visible at Home:\n{out}"
);
assert!(
out.contains('>'),
"a right scroll marker signals the hidden tail:\n{out}"
);
assert!(
!out.contains("Wonderland"),
"the tail must be scrolled off:\n{out}"
);
}
// ---- ADR-0046 DA4: two-row input on tall terminals -----------
@@ -2569,8 +2621,14 @@ mod tests {
let theme = Theme::dark();
// Very narrow + tall: two rows, but the line exceeds both.
let out = render_to_string(&mut app, &theme, 38, 44);
assert!(out.contains("Wonderland"), "the tail/cursor stays visible:\n{out}");
assert!(out.contains('<'), "a left marker signals the hidden head:\n{out}");
assert!(
out.contains("Wonderland"),
"the tail/cursor stays visible:\n{out}"
);
assert!(
out.contains('<'),
"a left marker signals the hidden head:\n{out}"
);
}
#[test]
@@ -2644,7 +2702,10 @@ mod tests {
/// The `key` column of the strip's bindings, in order.
fn strip_keys(app: &App) -> Vec<&'static str> {
status_bar_bindings(app).into_iter().map(|(k, _)| k).collect()
status_bar_bindings(app)
.into_iter()
.map(|(k, _)| k)
.collect()
}
/// The full rendered strip text (keys + labels + separators).
@@ -2659,7 +2720,12 @@ mod tests {
fn hint_text(lines: &[Line<'_>]) -> String {
lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.clone()).collect::<String>())
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
@@ -2686,10 +2752,7 @@ mod tests {
fn strip_sidebar_focus_state_is_pane_scroll_input() {
let mut app = App::new();
app.nav_focus = NavFocus::SidebarTables;
assert_eq!(
strip_keys(&app),
vec!["Ctrl-O", "↑↓/PgUp/PgDn", "Esc"],
);
assert_eq!(strip_keys(&app), vec!["Ctrl-O", "↑↓/PgUp/PgDn", "Esc"],);
// ...and the relationships sidebar is the same state.
app.nav_focus = NavFocus::SidebarRelationships;
assert_eq!(strip_keys(&app), vec!["Ctrl-O", "↑↓/PgUp/PgDn", "Esc"]);
@@ -2802,11 +2865,7 @@ mod tests {
assert_eq!(two, vec!["alpha beta", "gamma delta"]);
// > max rows: clamp to max, last row ends with an ellipsis,
// and every row stays within the width.
let many = clamp_wrapped(
"alpha beta gamma delta epsilon zeta eta theta iota",
11,
3,
);
let many = clamp_wrapped("alpha beta gamma delta epsilon zeta eta theta iota", 11, 3);
assert_eq!(many.len(), 3);
assert!(many[2].ends_with('…'), "last row ellipsized: {many:?}");
for row in &many {
@@ -2931,9 +2990,18 @@ mod tests {
app.output.push_back(err);
let out = render_to_string(&mut app, &Theme::dark(), 100, 20);
assert!(out.contains("running: drop table Orders"), "pending keeps running::\n{out}");
assert!(out.contains("create table T with pk ✓"), "ok shows ✓:\n{out}");
assert!(out.contains("insert into T values (1) ✗"), "err shows ✗:\n{out}");
assert!(
out.contains("running: drop table Orders"),
"pending keeps running::\n{out}"
);
assert!(
out.contains("create table T with pk ✓"),
"ok shows ✓:\n{out}"
);
assert!(
out.contains("insert into T values (1) ✗"),
"err shows ✗:\n{out}"
);
assert!(
!out.contains("running: create table"),
"a completed echo drops the running: prefix:\n{out}"
@@ -2970,7 +3038,10 @@ mod tests {
#[test]
fn format_snapshot_timestamp_falls_back_on_garbage() {
assert_eq!(format_snapshot_timestamp("not a timestamp"), "not a timestamp");
assert_eq!(
format_snapshot_timestamp("not a timestamp"),
"not a timestamp"
);
}
#[test]
@@ -2999,9 +3070,9 @@ mod tests {
let theme = Theme::dark();
let out = render_to_string(&mut app, &theme, 120, 30);
assert!(
out.lines().any(|l| l.contains(
"This will undo: insert into Customers values (1, 'Oliver Sturm')"
)),
out.lines()
.any(|l| l
.contains("This will undo: insert into Customers values (1, 'Oliver Sturm')")),
"command must sit on one row on a wide terminal:\n{out}"
);
}
@@ -3017,7 +3088,10 @@ mod tests {
}));
let theme = Theme::dark();
let out = render_to_string(&mut app, &theme, 120, 30);
assert!(out.contains("Snapshot taken"), "capitalized Snapshot:\n{out}");
assert!(
out.contains("Snapshot taken"),
"capitalized Snapshot:\n{out}"
);
assert!(out.contains("[Y] Yes"), "capitalized Yes:\n{out}");
assert!(out.contains("[N] No"), "capitalized No:\n{out}");
assert!(
@@ -3113,8 +3187,14 @@ mod tests {
app.schema_cache.table_indexes.insert(
"Customers".to_string(),
vec![
IndexEntry { name: "idx_email".to_string(), unique: false },
IndexEntry { name: "uidx_login".to_string(), unique: true },
IndexEntry {
name: "idx_email".to_string(),
unique: false,
},
IndexEntry {
name: "uidx_login".to_string(),
unique: true,
},
],
);
let theme = Theme::dark();
@@ -3123,7 +3203,10 @@ mod tests {
assert!(out.contains("Customers"), "table listed:\n{out}");
assert!(out.contains("Orders"), "table listed:\n{out}");
assert!(out.contains("idx_email"), "index nested in panel:\n{out}");
assert!(out.contains("uidx_login [unique]"), "unique index marked:\n{out}");
assert!(
out.contains("uidx_login [unique]"),
"unique index marked:\n{out}"
);
}
#[test]
@@ -3143,10 +3226,19 @@ mod tests {
app.tables = vec!["Customers".to_string()];
let theme = Theme::dark();
let narrow = render_to_string(&mut app, &theme, 80, 24);
assert!(!narrow.contains("Tables"), "sidebar hidden at 80 wide:\n{narrow}");
assert!(
!narrow.contains("Tables"),
"sidebar hidden at 80 wide:\n{narrow}"
);
let wide = render_to_string(&mut app, &theme, 110, 24);
assert!(wide.contains("Tables"), "sidebar shown at 110 wide:\n{wide}");
assert!(wide.contains("Customers"), "tables listed when shown:\n{wide}");
assert!(
wide.contains("Tables"),
"sidebar shown at 110 wide:\n{wide}"
);
assert!(
wide.contains("Customers"),
"tables listed when shown:\n{wide}"
);
}
#[test]
@@ -3181,7 +3273,10 @@ mod tests {
let theme = Theme::dark();
let out = render_to_string(&mut app, &theme, 110, 24);
assert!(out.contains("Relationships"), "panel title present:\n{out}");
assert!(out.contains("Customers_Orders"), "relationship name:\n{out}");
assert!(
out.contains("Customers_Orders"),
"relationship name:\n{out}"
);
assert!(
out.lines().any(|l| l.contains("Customers.id ->")),
"parent endpoint, broken at the arrow:\n{out}"
@@ -3228,8 +3323,14 @@ mod tests {
app.nav_focus = NavFocus::SidebarTables;
let focused = render_to_string(&mut app, &theme, 80, 24);
assert!(focused.contains("Tables"), "sidebar revealed in nav mode:\n{focused}");
assert!(focused.contains("Customers"), "tables in the overlay:\n{focused}");
assert!(
focused.contains("Tables"),
"sidebar revealed in nav mode:\n{focused}"
);
assert!(
focused.contains("Customers"),
"tables in the overlay:\n{focused}"
);
assert!(
focused.contains("Relationships"),
"relationships panel in the overlay:\n{focused}"
@@ -3365,7 +3466,9 @@ mod tests {
}
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("create terminal");
terminal.draw(|f| render(app, theme, f)).expect("draw frame");
terminal
.draw(|f| render(app, theme, f))
.expect("draw frame");
terminal.backend().buffer().clone()
}
+15 -3
View File
@@ -737,7 +737,11 @@ mod tests {
let payload_dirs = fs::read_dir(&store.root)
.unwrap()
.filter_map(std::result::Result::ok)
.filter(|e| e.file_name().to_str().is_some_and(|n| n.parse::<u64>().is_ok()))
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.parse::<u64>().is_ok())
})
.count();
assert_eq!(payload_dirs, 2, "ring capped at 2 payloads");
}
@@ -766,7 +770,11 @@ mod tests {
let payload_dirs = fs::read_dir(&store.root)
.unwrap()
.filter_map(std::result::Result::ok)
.filter(|e| e.file_name().to_str().is_some_and(|n| n.parse::<u64>().is_ok()))
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.parse::<u64>().is_ok())
})
.count();
assert_eq!(payload_dirs, 1, "only the surviving undo payload remains");
}
@@ -820,6 +828,10 @@ mod tests {
fs::create_dir_all(store.payload_dir(41)).unwrap();
stage_finalize(&store, &fx.conn, "cmd");
let meta = store.peek_undo().unwrap().unwrap();
assert!(meta.id >= 42, "id allocated above the orphan, got {}", meta.id);
assert!(
meta.id >= 42,
"id allocated above the orphan, got {}",
meta.id
);
}
}
+47 -13
View File
@@ -24,8 +24,7 @@ fn rt() -> tokio::runtime::Runtime {
fn open() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir");
let project =
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(project.path().to_path_buf()),
@@ -78,7 +77,10 @@ fn rename_column_with_case_variant_table_keeps_metadata_in_step() {
let r = rt();
r.block_on(db.create_table(
"Items".to_string(),
vec![ColumnSpec::new("id", Type::Int), ColumnSpec::new("qty", Type::Int)],
vec![
ColumnSpec::new("id", Type::Int),
ColumnSpec::new("qty", Type::Int),
],
vec!["id".to_string()],
Some("create".to_string()),
))
@@ -129,7 +131,11 @@ fn insert_with_case_variant_table_persists_and_survives_rebuild() {
.block_on(db.query_data("Items".to_string(), None, None))
.expect("query")
.rows;
assert_eq!(rows.len(), 1, "the wrong-case insert survived the rebuild (no data loss)");
assert_eq!(
rows.len(),
1,
"the wrong-case insert survived the rebuild (no data loss)"
);
assert_eq!(rows[0][1].as_deref(), Some("kept"));
}
@@ -146,9 +152,19 @@ fn add_column_with_case_variant_table_survives_rebuild() {
);
let db = fresh_rebuild(db, &project, &r);
let desc = r.block_on(db.describe_table("Items".to_string())).expect("describe");
let qty = desc.columns.iter().find(|c| c.name == "qty").expect("qty added");
assert_eq!(qty.user_type, Some(Type::Int), "qty's user-type survived the rebuild");
let desc = r
.block_on(db.describe_table("Items".to_string()))
.expect("describe");
let qty = desc
.columns
.iter()
.find(|c| c.name == "qty")
.expect("qty added");
assert_eq!(
qty.user_type,
Some(Type::Int),
"qty's user-type survived the rebuild"
);
// The CHECK is intact too (a negative qty is refused under the real table).
assert!(
r.block_on(db.insert(
@@ -175,9 +191,15 @@ fn drop_table_with_case_variant_name_clears_table_and_csv() {
insert into Items (id, note) values (1, 'x')\n\
drop table items\n",
);
assert!(!tables(&db, &r).contains(&"Items".to_string()), "the table was dropped");
assert!(
!tables(&db, &r).contains(&"Items".to_string()),
"the table was dropped"
);
let csv = project.path().join(project::DATA_DIR).join("Items.csv");
assert!(!csv.exists(), "the CSV was removed despite the case-variant drop");
assert!(
!csv.exists(),
"the CSV was removed despite the case-variant drop"
);
// A fresh rebuild yields no Items (the metadata/yaml has no orphan).
let db = fresh_rebuild(db, &project, &r);
@@ -224,12 +246,24 @@ fn add_relationship_with_case_variant_tables_survives_rebuild() {
add 1:n relationship from parent.id to child.parent_id\n",
);
// The parent's inbound relationship is visible under the stored case.
let p = r.block_on(db.describe_table("Parent".to_string())).expect("describe Parent");
assert_eq!(p.inbound_relationships.len(), 1, "relationship recorded under the stored case");
let p = r
.block_on(db.describe_table("Parent".to_string()))
.expect("describe Parent");
assert_eq!(
p.inbound_relationships.len(),
1,
"relationship recorded under the stored case"
);
assert_eq!(p.inbound_relationships[0].other_table, "Child");
let db = fresh_rebuild(db, &project, &r);
let p = r.block_on(db.describe_table("Parent".to_string())).expect("describe Parent");
assert_eq!(p.inbound_relationships.len(), 1, "relationship survived the rebuild");
let p = r
.block_on(db.describe_table("Parent".to_string()))
.expect("describe Parent");
assert_eq!(
p.inbound_relationships.len(),
1,
"relationship survived the rebuild"
);
assert_eq!(p.inbound_relationships[0].other_table, "Child");
}
+22 -8
View File
@@ -24,8 +24,7 @@ fn rt() -> tokio::runtime::Runtime {
fn open() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir");
let project =
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
let persistence = Persistence::new(project.path().to_path_buf());
let db = Database::open_with_persistence_and_undo(project.db_path(), persistence, true)
.expect("open db with persistence");
@@ -48,7 +47,9 @@ fn make_t_with_check(db: &Database, r: &tokio::runtime::Runtime) {
vec!["a < b".to_string()],
vec![],
false,
Some("create table T (id int primary key, a int, b int, c text, check (a < b))".to_string()),
Some(
"create table T (id int primary key, a int, b int, c text, check (a < b))".to_string(),
),
))
.expect("create T with table CHECK");
}
@@ -285,7 +286,10 @@ fn drop_column_covered_by_a_composite_unique_is_refused_with_the_derived_name()
.block_on(db.drop_column("T".to_string(), "a".to_string(), false, None))
.expect_err("dropping a composite-UNIQUE column is refused");
let msg = err.friendly_message();
assert!(msg.contains("unique_a_b"), "names the derived constraint; got: {msg}");
assert!(
msg.contains("unique_a_b"),
"names the derived constraint; got: {msg}"
);
assert!(
msg.contains("drop constraint unique_a_b"),
"points at the actionable drop command; got: {msg}"
@@ -351,14 +355,24 @@ fn rename_column_with_a_column_level_check_is_refused() {
make_t_with_column_checks(&db, &r);
// `qty`'s own check `qty >= 0` references qty → refused.
assert!(
r.block_on(db.rename_column("T".to_string(), "qty".to_string(), "amount".to_string(), None))
.is_err(),
r.block_on(db.rename_column(
"T".to_string(),
"qty".to_string(),
"amount".to_string(),
None
))
.is_err(),
"renaming a column with its own column-level CHECK is refused"
);
// `price` is referenced by `discount`'s check `discount < price`.
assert!(
r.block_on(db.rename_column("T".to_string(), "price".to_string(), "cost".to_string(), None))
.is_err(),
r.block_on(db.rename_column(
"T".to_string(),
"price".to_string(),
"cost".to_string(),
None
))
.is_err(),
"renaming a column referenced by another column's CHECK is refused"
);
// `id` is referenced by no CHECK → rename succeeds.
+51 -28
View File
@@ -9,7 +9,7 @@
use rdbms_playground::db::Database;
use rdbms_playground::dsl::{
parse_command, ColumnSpec, Command, ReferentialAction, SqlForeignKey, Type, Value,
ColumnSpec, Command, ReferentialAction, SqlForeignKey, Type, Value, parse_command,
};
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project;
@@ -18,10 +18,8 @@ use rdbms_playground::project;
#[test]
fn parenthesized_compound_endpoint_parses_to_column_lists() {
let cmd = parse_command(
"add 1:n relationship from Parent.(a, b) to Child.(x, y)",
)
.expect("parses");
let cmd =
parse_command("add 1:n relationship from Parent.(a, b) to Child.(x, y)").expect("parses");
match cmd {
Command::AddRelationship {
parent_table,
@@ -41,8 +39,7 @@ fn parenthesized_compound_endpoint_parses_to_column_lists() {
#[test]
fn single_column_endpoint_still_parses_unparenthesized() {
let cmd = parse_command("add 1:n relationship from Parent.id to Child.pid")
.expect("parses");
let cmd = parse_command("add 1:n relationship from Parent.id to Child.pid").expect("parses");
match cmd {
Command::AddRelationship {
parent_columns,
@@ -148,7 +145,10 @@ fn sql_create_table_compound_fk_executes_and_enforces() {
db.insert(
"Region".to_string(),
Some(vec!["country".to_string(), "code".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("10".to_string())],
vec![
Value::Number("1".to_string()),
Value::Number("10".to_string()),
],
None,
)
.await
@@ -157,7 +157,10 @@ fn sql_create_table_compound_fk_executes_and_enforces() {
.insert(
"City".to_string(),
Some(vec!["country".to_string(), "region_code".to_string()]),
vec![Value::Number("9".to_string()), Value::Number("9".to_string())],
vec![
Value::Number("9".to_string()),
Value::Number("9".to_string()),
],
None,
)
.await;
@@ -176,8 +179,7 @@ fn rt() -> tokio::runtime::Runtime {
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir");
let project =
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
let persistence = Persistence::new(project.path().to_path_buf());
let db = Database::open_with_persistence(project.db_path(), persistence)
.expect("open db with persistence");
@@ -241,7 +243,10 @@ fn compound_fk_declares_enforces_and_round_trips() {
db.insert(
"Region".to_string(),
Some(vec!["country".to_string(), "code".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("10".to_string())],
vec![
Value::Number("1".to_string()),
Value::Number("10".to_string()),
],
None,
)
.await
@@ -253,7 +258,11 @@ fn compound_fk_declares_enforces_and_round_trips() {
"region_code".to_string(),
"name".to_string(),
]),
vec![Value::Number("1".to_string()), Value::Number("10".to_string()), Value::Text("Metropolis".to_string())],
vec![
Value::Number("1".to_string()),
Value::Number("10".to_string()),
Value::Text("Metropolis".to_string()),
],
None,
)
.await
@@ -266,7 +275,11 @@ fn compound_fk_declares_enforces_and_round_trips() {
"region_code".to_string(),
"name".to_string(),
]),
vec![Value::Number("9".to_string()), Value::Number("9".to_string()), Value::Text("Nowhere".to_string())],
vec![
Value::Number("9".to_string()),
Value::Number("9".to_string()),
Value::Text("Nowhere".to_string()),
],
None,
)
.await;
@@ -360,7 +373,10 @@ fn compound_fk_arity_mismatch_is_refused() {
None,
)
.await;
assert!(err.is_err(), "mismatched child/parent arity must be refused");
assert!(
err.is_err(),
"mismatched child/parent arity must be refused"
);
});
}
@@ -386,10 +402,8 @@ fn inline_fk_referencing_compound_pk_points_at_table_level_form() {
.expect("create Region");
// Parse the inline form so the `inline` flag is set by the grammar.
let cmd = parse_command(
"create table City (country int references Region(country, code))",
)
.expect("parses");
let cmd = parse_command("create table City (country int references Region(country, code))")
.expect("parses");
let Command::SqlCreateTable {
name,
columns,
@@ -465,7 +479,10 @@ fn compound_fk_type_mismatch_per_pair_is_refused() {
None,
)
.await;
assert!(err.is_err(), "a type-incompatible column pair must be refused");
assert!(
err.is_err(),
"a type-incompatible column pair must be refused"
);
});
}
@@ -478,11 +495,8 @@ fn compound_fk_survives_rebuild_from_text() {
let path = project.path().to_path_buf();
let rt = rt();
{
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.expect("open db");
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
.expect("open db");
rt.block_on(async {
seed_compound(&db).await;
db.add_relationship(
@@ -512,7 +526,10 @@ fn compound_fk_survives_rebuild_from_text() {
db.insert(
"Region".to_string(),
Some(vec!["country".to_string(), "code".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("10".to_string())],
vec![
Value::Number("1".to_string()),
Value::Number("10".to_string()),
],
None,
)
.await
@@ -521,11 +538,17 @@ fn compound_fk_survives_rebuild_from_text() {
.insert(
"City".to_string(),
Some(vec!["country".to_string(), "region_code".to_string()]),
vec![Value::Number("9".to_string()), Value::Number("9".to_string())],
vec![
Value::Number("9".to_string()),
Value::Number("9".to_string()),
],
None,
)
.await;
assert!(bad.is_err(), "compound FK still enforced after rebuild from text");
assert!(
bad.is_err(),
"compound FK still enforced after rebuild from text"
);
// Endpoints survived the round-trip intact.
let city = db.describe_table("City".to_string()).await.unwrap();
assert_eq!(
+4 -9
View File
@@ -32,10 +32,8 @@ use rdbms_playground::event::AppEvent;
const FORBIDDEN: &[&str] = &[
// Product names.
"SQLite", "sqlite",
// Crate name.
"rusqlite",
// Engine-specific keywords / idioms.
"SQLite", "sqlite", // Crate name.
"rusqlite", // Engine-specific keywords / idioms.
"STRICT", "PRAGMA",
];
@@ -52,9 +50,7 @@ fn engine_vocab_leak(s: &str) -> Option<(&'static str, usize)> {
fn assert_clean(label: &str, s: &str) {
if let Some((needle, pos)) = engine_vocab_leak(s) {
panic!(
"ADR-0002 leak in {label}: found `{needle}` at byte {pos} in:\n{s}"
);
panic!("ADR-0002 leak in {label}: found `{needle}` at byte {pos} in:\n{s}");
}
}
@@ -118,8 +114,7 @@ fn parse_errors_use_no_engine_vocabulary() {
"this is not a command",
];
for input in inputs {
let err = parse_command(input)
.expect_err(&format!("expected parse failure for `{input}`"));
let err = parse_command(input).expect_err(&format!("expected parse failure for `{input}`"));
let rendered = format!("{err:?}");
assert_clean(&format!("parse error for `{input}`"), &rendered);
}
+59 -25
View File
@@ -18,10 +18,10 @@
use tokio::runtime::Runtime;
use rdbms_playground::db::{Database, DbError, SqliteErrorKind};
use rdbms_playground::dsl::{
action::ReferentialAction, ColumnSpec, Command, RowFilter, Type, Value,
};
use rdbms_playground::dsl::parser::parse_command;
use rdbms_playground::dsl::{
ColumnSpec, Command, RowFilter, Type, Value, action::ReferentialAction,
};
use rdbms_playground::runtime::enrich_dsl_failure;
fn rt() -> Runtime {
@@ -57,7 +57,10 @@ fn enrich_unique_insert_resolves_table_column_value_and_pinpoint() {
db.insert(
"Customers".to_string(),
None,
vec![Value::Number("5".to_string()), Value::Text("Alice".to_string())],
vec![
Value::Number("5".to_string()),
Value::Text("Alice".to_string()),
],
None,
)
.await
@@ -86,7 +89,10 @@ fn enrich_unique_insert_resolves_table_column_value_and_pinpoint() {
.unwrap_err();
assert!(matches!(
err,
DbError::Sqlite { kind: SqliteErrorKind::UniqueViolation, .. }
DbError::Sqlite {
kind: SqliteErrorKind::UniqueViolation,
..
}
));
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
@@ -169,7 +175,10 @@ fn enrich_unique_sql_insert_natural_order_resolves_value_via_schema() {
db.insert(
"Customers".to_string(),
None,
vec![Value::Number("5".to_string()), Value::Text("Alice".to_string())],
vec![
Value::Number("5".to_string()),
Value::Text("Alice".to_string()),
],
None,
)
.await
@@ -189,7 +198,10 @@ fn enrich_unique_sql_insert_natural_order_resolves_value_via_schema() {
else {
panic!("expected Command::SqlInsert, got {cmd:?}");
};
assert!(listed_columns.is_empty(), "natural-order form has no column list");
assert!(
listed_columns.is_empty(),
"natural-order form has no column list"
);
let err = db
.run_sql_insert_with_literals(
sql,
@@ -204,7 +216,10 @@ fn enrich_unique_sql_insert_natural_order_resolves_value_via_schema() {
.unwrap_err();
assert!(matches!(
err,
DbError::Sqlite { kind: SqliteErrorKind::UniqueViolation, .. }
DbError::Sqlite {
kind: SqliteErrorKind::UniqueViolation,
..
}
));
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
@@ -235,7 +250,10 @@ fn enrich_unique_update_resolves_value_from_assignments() {
db.insert(
"Customers".to_string(),
None,
vec![Value::Number("1".to_string()), Value::Text("Alice".to_string())],
vec![
Value::Number("1".to_string()),
Value::Text("Alice".to_string()),
],
None,
)
.await
@@ -243,7 +261,10 @@ fn enrich_unique_update_resolves_value_from_assignments() {
db.insert(
"Customers".to_string(),
None,
vec![Value::Number("2".to_string()), Value::Text("Bob".to_string())],
vec![
Value::Number("2".to_string()),
Value::Text("Bob".to_string()),
],
None,
)
.await
@@ -294,7 +315,10 @@ fn enrich_unique_sql_update_resolves_value_from_set_literals() {
db.insert(
"Customers".to_string(),
None,
vec![Value::Number("1".to_string()), Value::Text("Alice".to_string())],
vec![
Value::Number("1".to_string()),
Value::Text("Alice".to_string()),
],
None,
)
.await
@@ -302,7 +326,10 @@ fn enrich_unique_sql_update_resolves_value_from_set_literals() {
db.insert(
"Customers".to_string(),
None,
vec![Value::Number("2".to_string()), Value::Text("Bob".to_string())],
vec![
Value::Number("2".to_string()),
Value::Text("Bob".to_string()),
],
None,
)
.await
@@ -328,7 +355,10 @@ fn enrich_unique_sql_update_resolves_value_from_set_literals() {
.unwrap_err();
assert!(matches!(
err,
DbError::Sqlite { kind: SqliteErrorKind::UniqueViolation, .. }
DbError::Sqlite {
kind: SqliteErrorKind::UniqueViolation,
..
}
));
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
@@ -666,7 +696,10 @@ fn enrich_fk_delete_resolves_child_table() {
db.insert(
"Orders".to_string(),
None,
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
vec![
Value::Number("1".to_string()),
Value::Number("1".to_string()),
],
None,
)
.await
@@ -708,16 +741,15 @@ fn enrich_check_insert_resolves_table_column_value_and_rule() {
)
.await
.unwrap();
let score_spec = match parse_command(
"create table __probe with pk score(int) check (score >= 0)",
)
.expect("probe create parses")
{
Command::CreateTable { columns, .. } => {
columns.into_iter().next().expect("one column")
}
other => panic!("expected CreateTable, got {other:?}"),
};
let score_spec =
match parse_command("create table __probe with pk score(int) check (score >= 0)")
.expect("probe create parses")
{
Command::CreateTable { columns, .. } => {
columns.into_iter().next().expect("one column")
}
other => panic!("expected CreateTable, got {other:?}"),
};
db.add_column("Scores".to_string(), score_spec, None)
.await
.unwrap();
@@ -757,7 +789,9 @@ fn enrich_unsupported_returns_default_facts() {
let db = db();
rt().block_on(async {
let err = DbError::Unsupported("nope".to_string());
let cmd = Command::DropTable { name: "X".to_string() };
let cmd = Command::DropTable {
name: "X".to_string(),
};
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
assert!(facts.table.is_none());
assert!(facts.column.is_none());
+1 -1
View File
@@ -11,7 +11,7 @@
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use rdbms_playground::app::App;
use rdbms_playground::dsl::{parse_command, AppCommand, Command};
use rdbms_playground::dsl::{AppCommand, Command, parse_command};
use rdbms_playground::event::AppEvent;
const fn key(code: KeyCode) -> AppEvent {
+22 -11
View File
@@ -14,9 +14,7 @@ use std::path::Path;
use rdbms_playground::db::Database;
use rdbms_playground::dsl::{ColumnSpec, ReferentialAction, RowFilter, Type, Value};
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project::{
self, DATA_DIR, PROJECT_YAML,
};
use rdbms_playground::project::{self, DATA_DIR, PROJECT_YAML};
fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("create tempdir")
@@ -33,9 +31,7 @@ fn rt() -> tokio::runtime::Runtime {
/// `Database` (with persistence wired) plus the path so the
/// test can inspect on-disk state. The project is held alive
/// implicitly via the leaked `TempDir` returned alongside.
fn open_project(
data: &tempfile::TempDir,
) -> (project::Project, Database, std::path::PathBuf) {
fn open_project(data: &tempfile::TempDir) -> (project::Project, Database, std::path::PathBuf) {
let project = project::open_or_create(None, Some(data.path())).expect("open project");
let path = project.path().to_path_buf();
let persistence = Persistence::new(path.clone());
@@ -72,7 +68,10 @@ fn create_table_writes_yaml_and_history() {
});
let yaml = read_yaml(&path);
assert!(yaml.contains("- name: Customers"), "yaml missing table:\n{yaml}");
assert!(
yaml.contains("- name: Customers"),
"yaml missing table:\n{yaml}"
);
assert!(yaml.contains("primary_key: [id]"), "yaml: {yaml}");
assert!(yaml.contains("type: serial"), "yaml: {yaml}");
assert!(yaml.contains("type: text"), "yaml: {yaml}");
@@ -151,9 +150,15 @@ fn drop_table_removes_its_csv() {
.unwrap();
});
assert!(read_csv(&path, "Customers").is_none(), "CSV should be deleted");
assert!(
read_csv(&path, "Customers").is_none(),
"CSV should be deleted"
);
let yaml = read_yaml(&path);
assert!(!yaml.contains("- name: Customers"), "table should be gone from yaml:\n{yaml}");
assert!(
!yaml.contains("- name: Customers"),
"table should be gone from yaml:\n{yaml}"
);
}
#[test]
@@ -263,7 +268,10 @@ fn create_table_does_not_write_csv_for_empty_table() {
// Schema landed in YAML.
let yaml = read_yaml(&path);
assert!(yaml.contains("- name: Customers"), "yaml missing table:\n{yaml}");
assert!(
yaml.contains("- name: Customers"),
"yaml missing table:\n{yaml}"
);
// ...but no CSV until there's data.
assert!(
read_csv(&path, "Customers").is_none(),
@@ -394,7 +402,10 @@ fn project_yaml_carries_relationship_after_add() {
});
let yaml = read_yaml(&path);
assert!(yaml.contains("- name: Customers_id_to_Orders_CustId"), "yaml: {yaml}");
assert!(
yaml.contains("- name: Customers_id_to_Orders_CustId"),
"yaml: {yaml}"
);
assert!(yaml.contains("on_delete: cascade"), "yaml: {yaml}");
assert!(yaml.contains("on_update: no_action"), "yaml: {yaml}");
}
+28 -37
View File
@@ -35,11 +35,8 @@ fn rebuild_restores_schema_only_project() {
let project_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.unwrap();
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
.unwrap();
rt().block_on(async {
db.create_table(
"Customers".to_string(),
@@ -89,11 +86,8 @@ fn rebuild_restores_rows_from_csv() {
let project_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.unwrap();
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
.unwrap();
rt().block_on(async {
db.create_table(
"Customers".to_string(),
@@ -157,11 +151,8 @@ fn rebuild_restores_relationships_and_cascade_behaviour() {
let project_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.unwrap();
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
.unwrap();
rt().block_on(async {
db.create_table(
"Customers".to_string(),
@@ -244,7 +235,11 @@ fn rebuild_restores_relationships_and_cascade_behaviour() {
})
.expect("delete");
assert_eq!(result.rows_affected, 1);
assert_eq!(result.cascade.len(), 1, "expected one cascade entry: {result:?}");
assert_eq!(
result.cascade.len(),
1,
"expected one cascade entry: {result:?}"
);
assert_eq!(result.cascade[0].child_table, "Orders");
}
@@ -256,11 +251,8 @@ fn rebuild_reports_fatal_error_on_bad_csv_row() {
let project_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.unwrap();
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
.unwrap();
rt().block_on(async {
db.create_table(
"Numbers".to_string(),
@@ -303,13 +295,17 @@ fn rebuild_reports_fatal_error_on_bad_csv_row() {
.unwrap();
let err = rt()
.block_on(async {
db.rebuild_from_text(project.path().to_path_buf(), None).await
db.rebuild_from_text(project.path().to_path_buf(), None)
.await
})
.expect_err("must fail with row-level error");
let msg = format!("{err}");
assert!(msg.contains("row 2"), "msg should name the row: {msg}");
assert!(msg.contains("Numbers"), "msg should name the table: {msg}");
assert!(msg.contains("integer"), "msg should explain the type mismatch: {msg}");
assert!(
msg.contains("integer"),
"msg should explain the type mismatch: {msg}"
);
}
#[test]
@@ -318,11 +314,8 @@ fn rebuild_preserves_created_at_from_yaml() {
let project_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.unwrap();
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
.unwrap();
rt().block_on(async {
db.create_table(
"T".to_string(),
@@ -371,9 +364,7 @@ fn rebuild_preserves_created_at_from_yaml() {
// Trigger any successful command so project.yaml is
// rewritten from the now-rebuilt db state.
rt().block_on(async {
db.describe_table("T".to_string())
.await
.unwrap();
db.describe_table("T".to_string()).await.unwrap();
// describe is read-only; force a rewrite by adding a column.
db.add_column(
"T".to_string(),
@@ -400,11 +391,8 @@ fn rebuild_restores_indexes() {
let project_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.unwrap();
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
.unwrap();
rt().block_on(async {
db.create_table(
"Customers".to_string(),
@@ -434,7 +422,10 @@ fn rebuild_restores_indexes() {
// The index must be recorded in project.yaml — the `.db` is
// a derived artifact and gets discarded next.
let yaml = fs::read_to_string(project_path.join(project::PROJECT_YAML)).unwrap();
assert!(yaml.contains("idx_email"), "yaml should record the index:\n{yaml}");
assert!(
yaml.contains("idx_email"),
"yaml should record the index:\n{yaml}"
);
fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
+9 -7
View File
@@ -113,7 +113,10 @@ fn modal_swallows_unrelated_keys() {
// field while the modal is up.
app.update(key(KeyCode::Char('x')));
assert!(app.input.is_empty(), "modal should swallow key input");
assert!(app.modal.is_some(), "modal still active after unrelated key");
assert!(
app.modal.is_some(),
"modal still active after unrelated key"
);
}
#[test]
@@ -122,11 +125,8 @@ fn rebuild_against_populated_db_wipes_and_reloads() {
let project_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.unwrap();
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
.unwrap();
rt().block_on(async {
db.create_table(
"Customers".to_string(),
@@ -156,7 +156,9 @@ fn rebuild_against_populated_db_wipes_and_reloads() {
// Hand-edit the CSV to introduce a different row content.
// Rebuild should pick up the edited content.
let csv_path = project_path.join("data").join("Customers.csv");
let edited = fs::read_to_string(&csv_path).unwrap().replace("Alice", "Edna");
let edited = fs::read_to_string(&csv_path)
.unwrap()
.replace("Alice", "Edna");
fs::write(&csv_path, edited).unwrap();
// Reopen with persistence (the .db still exists but has
+3 -6
View File
@@ -16,9 +16,9 @@ use rdbms_playground::app::{
App, LoadPickerEntry, LoadPickerModal, LoadPickerSubMode, Modal, PathEntryModal,
PathEntryPurpose,
};
use rdbms_playground::event::AppEvent;
use rdbms_playground::db::Database;
use rdbms_playground::dsl::{ColumnSpec, Type};
use rdbms_playground::event::AppEvent;
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project::{
self, Project, ProjectKind, copy_project, safely_delete_temp_project,
@@ -462,11 +462,8 @@ fn temp_with_a_table_is_no_longer_unmodified() {
let data = tempdir();
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.unwrap();
let db =
Database::open_with_persistence(project.db_path(), Persistence::new(path.clone())).unwrap();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
+10 -8
View File
@@ -14,8 +14,8 @@ use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use rdbms_playground::action::Action;
use rdbms_playground::app::App;
use rdbms_playground::archive::{
default_export_filename, export_project, extract_into, inspect_zip,
next_export_sequence, resolve_import_target,
default_export_filename, export_project, extract_into, inspect_zip, next_export_sequence,
resolve_import_target,
};
use rdbms_playground::event::AppEvent;
use rdbms_playground::project::{HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML};
@@ -295,11 +295,9 @@ fn end_to_end_export_then_import_real_project() {
// Build a populated source project.
let src_path = {
let p = project::Project::create_named(&data.path().join("Source")).unwrap();
let db = Database::open_with_persistence(
p.db_path(),
Persistence::new(p.path().to_path_buf()),
)
.unwrap();
let db =
Database::open_with_persistence(p.db_path(), Persistence::new(p.path().to_path_buf()))
.unwrap();
rt().block_on(async {
db.create_table(
"Customers".to_string(),
@@ -362,7 +360,11 @@ fn end_to_end_export_then_import_real_project() {
// Round-trip: the inserted row is back.
let data_view = rt()
.block_on(async { imported_db.query_data("Customers".to_string(), None, None).await })
.block_on(async {
imported_db
.query_data("Customers".to_string(), None, None)
.await
})
.expect("query data");
assert_eq!(data_view.rows.len(), 1);
// Serial id auto-filled to 1; Name was the inserted value.
+2 -1
View File
@@ -166,7 +166,8 @@ fn hydration_reads_both_ok_and_err_records() {
let project = Project::create_temp(tmp.path()).unwrap();
let p = Persistence::new(project.path().to_path_buf());
p.append_history("create table A with pk", false).unwrap();
p.append_history_failure("insert into A (1, 2, 3)", false).unwrap();
p.append_history_failure("insert into A (1, 2, 3)", false)
.unwrap();
p.append_history("show data A", false).unwrap();
let entries = p.read_recent_history(10).unwrap();
assert_eq!(
+165 -45
View File
@@ -8,7 +8,7 @@
use rdbms_playground::db::Database;
use rdbms_playground::dsl::command::RowFilter;
use rdbms_playground::dsl::{parse_command, ColumnSpec, Command, Type, Value};
use rdbms_playground::dsl::{ColumnSpec, Command, Type, Value, parse_command};
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project::{self, PLAYGROUND_DB};
@@ -22,8 +22,11 @@ fn rt() -> tokio::runtime::Runtime {
fn open() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let project = project::open_or_create(None, Some(dir.path())).expect("project");
let db = Database::open_with_persistence(project.db_path(), Persistence::new(project.path().to_path_buf()))
.expect("db");
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(project.path().to_path_buf()),
)
.expect("db");
(project, db, dir)
}
@@ -45,7 +48,10 @@ fn open_with_undo() -> (project::Project, Database, tempfile::TempDir) {
async fn serial_pk_table(db: &Database, name: &str) {
db.create_table(
name.to_string(),
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("label", Type::Text)],
vec![
ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("label", Type::Text),
],
vec!["id".to_string()],
None,
)
@@ -84,7 +90,9 @@ fn parses_with_as_name() {
match parse_command("create m:n relationship from Students to Courses as Enrollments")
.expect("parses")
{
Command::CreateM2nRelationship { name, .. } => assert_eq!(name.as_deref(), Some("Enrollments")),
Command::CreateM2nRelationship { name, .. } => {
assert_eq!(name.as_deref(), Some("Enrollments"))
}
other => panic!("expected CreateM2nRelationship, got {other:?}"),
}
}
@@ -104,12 +112,21 @@ fn generates_junction_with_compound_pk_and_two_enforced_fks() {
// Auto-named `Students_Courses` exists.
let tables = db.list_tables().await.unwrap();
assert!(tables.contains(&"Students_Courses".to_string()), "tables: {tables:?}");
assert!(
tables.contains(&"Students_Courses".to_string()),
"tables: {tables:?}"
);
// Two FK columns, both part of the compound PK.
let desc = db.describe_table("Students_Courses".to_string()).await.unwrap();
let cols: Vec<(&str, bool)> =
desc.columns.iter().map(|c| (c.name.as_str(), c.primary_key)).collect();
let desc = db
.describe_table("Students_Courses".to_string())
.await
.unwrap();
let cols: Vec<(&str, bool)> = desc
.columns
.iter()
.map(|c| (c.name.as_str(), c.primary_key))
.collect();
assert_eq!(
cols,
vec![("Students_id", true), ("Courses_id", true)],
@@ -124,7 +141,10 @@ fn generates_junction_with_compound_pk_and_two_enforced_fks() {
db.insert(
"Students_Courses".to_string(),
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
vec![
Value::Number("1".to_string()),
Value::Number("1".to_string()),
],
None,
)
.await
@@ -134,21 +154,33 @@ fn generates_junction_with_compound_pk_and_two_enforced_fks() {
.insert(
"Students_Courses".to_string(),
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
vec![
Value::Number("1".to_string()),
Value::Number("1".to_string()),
],
None,
)
.await;
assert!(dup.is_err(), "duplicate (Students_id, Courses_id) must be refused");
assert!(
dup.is_err(),
"duplicate (Students_id, Courses_id) must be refused"
);
// A link to a non-existent parent is refused by the FK.
let orphan = db
.insert(
"Students_Courses".to_string(),
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("99".to_string())],
vec![
Value::Number("1".to_string()),
Value::Number("99".to_string()),
],
None,
)
.await;
assert!(orphan.is_err(), "link to a non-existent Course must be refused");
assert!(
orphan.is_err(),
"link to a non-existent Course must be refused"
);
});
}
@@ -167,7 +199,10 @@ fn as_name_overrides_the_junction_table_name() {
.await
.expect("create m:n as Enrollments");
let tables = db.list_tables().await.unwrap();
assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}");
assert!(
tables.contains(&"Enrollments".to_string()),
"tables: {tables:?}"
);
assert!(!tables.contains(&"Students_Courses".to_string()));
});
}
@@ -179,7 +214,10 @@ fn compound_parent_pk_contributes_one_fk_column_each() {
// Sections has a 2-column PK (course_id, term).
db.create_table(
"Sections".to_string(),
vec![ColumnSpec::new("course_id", Type::Int), ColumnSpec::new("term", Type::Int)],
vec![
ColumnSpec::new("course_id", Type::Int),
ColumnSpec::new("term", Type::Int),
],
vec!["course_id".to_string(), "term".to_string()],
None,
)
@@ -191,11 +229,20 @@ fn compound_parent_pk_contributes_one_fk_column_each() {
.await
.expect("create m:n");
let desc = db.describe_table("Students_Sections".to_string()).await.unwrap();
let desc = db
.describe_table("Students_Sections".to_string())
.await
.unwrap();
let names: Vec<&str> = desc.columns.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["Students_id", "Sections_course_id", "Sections_term"]);
assert_eq!(
names,
vec!["Students_id", "Sections_course_id", "Sections_term"]
);
// All three form the compound PK.
assert!(desc.columns.iter().all(|c| c.primary_key), "all columns are PK: {names:?}");
assert!(
desc.columns.iter().all(|c| c.primary_key),
"all columns are PK: {names:?}"
);
});
}
@@ -213,16 +260,28 @@ fn deleting_a_parent_cascades_to_the_junction() {
db.insert(
"Students_Courses".to_string(),
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
vec![
Value::Number("1".to_string()),
Value::Number("1".to_string()),
],
None,
)
.await
.unwrap();
// Deleting the student cascades to the junction (ON DELETE CASCADE).
db.delete("Students".to_string(), RowFilter::AllRows, None).await.unwrap();
let rows = db.query_data("Students_Courses".to_string(), None, None).await.unwrap();
assert!(rows.rows.is_empty(), "junction rows should cascade-delete, got {:?}", rows.rows);
db.delete("Students".to_string(), RowFilter::AllRows, None)
.await
.unwrap();
let rows = db
.query_data("Students_Courses".to_string(), None, None)
.await
.unwrap();
assert!(
rows.rows.is_empty(),
"junction rows should cascade-delete, got {:?}",
rows.rows
);
});
}
@@ -242,15 +301,26 @@ fn create_m2n_is_one_undo_step() {
)
.await
.unwrap();
assert!(db.list_tables().await.unwrap().contains(&"Students_Courses".to_string()));
assert!(
db.list_tables()
.await
.unwrap()
.contains(&"Students_Courses".to_string())
);
// One undo removes the junction table AND both relationships.
db.undo().await.unwrap();
let tables = db.list_tables().await.unwrap();
assert!(!tables.contains(&"Students_Courses".to_string()), "undo should remove the junction: {tables:?}");
assert!(
!tables.contains(&"Students_Courses".to_string()),
"undo should remove the junction: {tables:?}"
);
// The parents' relationships are gone too (the junction held them).
let students = db.describe_table("Students".to_string()).await.unwrap();
assert!(students.inbound_relationships.is_empty(), "no leftover relationship after undo");
assert!(
students.inbound_relationships.is_empty(),
"no leftover relationship after undo"
);
});
}
@@ -265,7 +335,10 @@ fn self_referential_m2n_is_refused() {
.create_m2n_relationship("Users".to_string(), "Users".to_string(), None, None)
.await
.expect_err("self m:n must be refused");
assert!(format!("{err}").contains("two different tables"), "got: {err}");
assert!(
format!("{err}").contains("two different tables"),
"got: {err}"
);
});
}
@@ -275,11 +348,19 @@ fn missing_parent_table_is_refused() {
rt().block_on(async {
serial_pk_table(&db, "Students").await;
let err = db
.create_m2n_relationship("Students".to_string(), "Nonexistent".to_string(), None, None)
.create_m2n_relationship(
"Students".to_string(),
"Nonexistent".to_string(),
None,
None,
)
.await
.expect_err("a missing parent table must be refused");
// The standard "no such table" guard (require_canonical_table).
assert!(format!("{err}").to_lowercase().contains("no such table"), "got: {err}");
assert!(
format!("{err}").to_lowercase().contains("no such table"),
"got: {err}"
);
});
}
@@ -297,7 +378,10 @@ fn junction_name_collision_is_refused() {
.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
.await
.expect_err("a junction-name collision must be refused");
assert!(format!("{err}").to_lowercase().contains("exist"), "got: {err}");
assert!(
format!("{err}").to_lowercase().contains("exist"),
"got: {err}"
);
});
}
@@ -314,15 +398,26 @@ fn the_junction_can_be_renamed() {
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
.await
.unwrap();
db.rename_table("Students_Courses".to_string(), "Enrollments".to_string(), None)
.await
.expect("rename the junction");
db.rename_table(
"Students_Courses".to_string(),
"Enrollments".to_string(),
None,
)
.await
.expect("rename the junction");
let tables = db.list_tables().await.unwrap();
assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}");
assert!(
tables.contains(&"Enrollments".to_string()),
"tables: {tables:?}"
);
assert!(!tables.contains(&"Students_Courses".to_string()));
// Both relationships survive the rename (rebuild-preserving).
let desc = db.describe_table("Enrollments".to_string()).await.unwrap();
assert_eq!(desc.outbound_relationships.len(), 2, "FKs preserved across rename");
assert_eq!(
desc.outbound_relationships.len(),
2,
"FKs preserved across rename"
);
});
}
@@ -355,16 +450,33 @@ fn junction_survives_save_and_rebuild() {
// Discard the derived .db so the next open rebuilds from text.
std::fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
let project = project::Project::open(&project_path).unwrap();
let db =
Database::open_with_persistence(project.db_path(), Persistence::new(project.path().to_path_buf()))
.unwrap();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(project.path().to_path_buf()),
)
.unwrap();
rt().block_on(async {
db.rebuild_from_text(project.path().to_path_buf(), None).await.expect("rebuild");
db.rebuild_from_text(project.path().to_path_buf(), None)
.await
.expect("rebuild");
let tables = db.list_tables().await.unwrap();
assert!(tables.contains(&"Students_Courses".to_string()), "junction survived: {tables:?}");
let desc = db.describe_table("Students_Courses".to_string()).await.unwrap();
assert_eq!(desc.outbound_relationships.len(), 2, "both FKs reconstructed");
assert!(desc.columns.iter().all(|c| c.primary_key), "compound PK reconstructed");
assert!(
tables.contains(&"Students_Courses".to_string()),
"junction survived: {tables:?}"
);
let desc = db
.describe_table("Students_Courses".to_string())
.await
.unwrap();
assert_eq!(
desc.outbound_relationships.len(),
2,
"both FKs reconstructed"
);
assert!(
desc.columns.iter().all(|c| c.primary_key),
"compound PK reconstructed"
);
});
}
@@ -387,7 +499,12 @@ fn as_an_internal_name_is_refused() {
.await
.expect_err("an internal junction name must be refused");
assert!(format!("{err}").contains("no such table"), "got: {err}");
assert!(!db.list_tables().await.unwrap().contains(&"__rdbms_evil".to_string()));
assert!(
!db.list_tables()
.await
.unwrap()
.contains(&"__rdbms_evil".to_string())
);
});
}
@@ -442,7 +559,10 @@ fn read_all_relationships_returns_the_junction_relationships() {
);
// Both have the junction (Students_Courses) as their child.
for r in &rels {
assert_eq!(r.child_table, "Students_Courses", "child is the junction: {r:?}");
assert_eq!(
r.child_table, "Students_Courses",
"child is the junction: {r:?}"
);
}
// One points back to each parent.
let parents: std::collections::BTreeSet<&str> =
+1 -1
View File
@@ -24,6 +24,7 @@ mod parse_error_pedagogy;
mod project_lifecycle;
mod replay_command;
mod seed;
mod show_list;
mod sql_alter_table;
mod sql_create_index;
mod sql_create_table;
@@ -34,6 +35,5 @@ mod sql_drop_table;
mod sql_insert;
mod sql_select;
mod sql_update;
mod show_list;
mod undo_snapshots;
mod walking_skeleton;
+457 -83
View File
@@ -54,10 +54,7 @@ fn error_lines_for(input: &str) -> Vec<String> {
}
fn dump(input: &str, lines: &[String]) -> String {
format!(
"INPUT: {input:?}\nERROR LINES:\n{}",
lines.join("\n"),
)
format!("INPUT: {input:?}\nERROR LINES:\n{}", lines.join("\n"),)
}
/// The simple-mode near-miss matrix (ADR-0042 §1). Each row is a
@@ -71,57 +68,228 @@ fn near_miss_matrix_simple_mode() {
// app-lifecycle arg errors. The arg-less commands all reject
// trailing junk with "expected end of input" + their usage
// (audited 2026-06-05); locked here as regression insurance.
("quit now", &["after `quit`, expected end of input", " quit"]),
(
"quit now",
&["after `quit`, expected end of input", " quit"],
),
// `help` now takes an optional single-word topic (H3), so
// `help foo` parses (topic lookup); only a *multi-word*
// topic is the near-miss that rejects trailing junk.
("help foo bar", &["after `help foo`, expected end of input", "help [<command>]"]),
("rebuild now", &["after `rebuild`, expected end of input", " rebuild"]),
(
"help foo bar",
&[
"after `help foo`, expected end of input",
"help [<command>]",
],
),
(
"rebuild now",
&["after `rebuild`, expected end of input", " rebuild"],
),
("new foo", &["after `new`, expected end of input", " new"]),
("load foo", &["after `load`, expected end of input", " load"]),
("undo foo", &["after `undo`, expected end of input", " undo"]),
("redo foo", &["after `redo`, expected end of input", " redo"]),
("export foo bar", &["after `export foo`, expected end of input", "export [<path>]"]),
("import a b c", &["after `import a`, expected end of input", "import <zip-path>"]),
("save sideways", &["after `save`, expected end of input", "save | save as"]),
("mode sideways", &["unknown mode 'sideways'", "mode simple | mode advanced"]),
("messages louder", &["unknown messages mode 'louder'", "messages short"]),
("copy everything", &["unknown copy target 'everything'", "copy all"]),
(
"load foo",
&["after `load`, expected end of input", " load"],
),
(
"undo foo",
&["after `undo`, expected end of input", " undo"],
),
(
"redo foo",
&["after `redo`, expected end of input", " redo"],
),
(
"export foo bar",
&[
"after `export foo`, expected end of input",
"export [<path>]",
],
),
(
"import a b c",
&[
"after `import a`, expected end of input",
"import <zip-path>",
],
),
(
"save sideways",
&["after `save`, expected end of input", "save | save as"],
),
(
"mode sideways",
&["unknown mode 'sideways'", "mode simple | mode advanced"],
),
(
"messages louder",
&["unknown messages mode 'louder'", "messages short"],
),
(
"copy everything",
&["unknown copy target 'everything'", "copy all"],
),
// DDL bare + missing-slot
("create", &["after `create`, expected `table`", "create table <Name> with pk"]),
("create table", &["after `create table`, expected identifier", "create table <Name> with pk"]),
("create table T", &["with pk", "create table <Name> with pk"]),
(
"create",
&[
"after `create`, expected `table`",
"create table <Name> with pk",
],
),
(
"create table",
&[
"after `create table`, expected identifier",
"create table <Name> with pk",
],
),
(
"create table T",
&["with pk", "create table <Name> with pk"],
),
// G1: relationship cardinality reads as the named construct.
("add", &["after `add`, expected `column`, `1:n relationship`", "add 1:n relationship"]),
("drop table", &["after `drop table`, expected table name", "drop table <Name>"]),
("add column", &["after `add column`, expected table name", "add column [to] [table]"]),
("rename", &["after `rename`, expected `column`", "rename column [in] [table]"]),
("rename column", &["after `rename column`, expected table name", "rename column [in] [table]"]),
("change", &["after `change`, expected `column`", "change column [in] [table]"]),
("change column", &["after `change column`, expected table name", "change column [in] [table]"]),
(
"add",
&[
"after `add`, expected `column`, `1:n relationship`",
"add 1:n relationship",
],
),
(
"drop table",
&[
"after `drop table`, expected table name",
"drop table <Name>",
],
),
(
"add column",
&[
"after `add column`, expected table name",
"add column [to] [table]",
],
),
(
"rename",
&[
"after `rename`, expected `column`",
"rename column [in] [table]",
],
),
(
"rename column",
&[
"after `rename column`, expected table name",
"rename column [in] [table]",
],
),
(
"change",
&[
"after `change`, expected `column`",
"change column [in] [table]",
],
),
(
"change column",
&[
"after `change column`, expected table name",
"change column [in] [table]",
],
),
// data bare + missing-clause
("insert", &["after `insert`, expected `into`", "insert into <Table>"]),
("insert into", &["after `insert into`, expected table name", "insert into <Table>"]),
("insert into T", &["after `insert into T`, expected `values` or `(`", "insert into <Table>"]),
("update", &["after `update`, expected table name", "update <Table> set"]),
("update T", &["after `update T`, expected `set`", "update <Table> set"]),
("update T set x=1", &["expected `where` or `--all-rows`", "update <Table> set"]),
("delete", &["after `delete`, expected `from`", "delete from <Table>"]),
("delete from", &["after `delete from`, expected table name", "delete from <Table>"]),
("delete from T", &["expected `where` or `--all-rows`", "delete from <Table>"]),
("seed", &["after `seed`, expected table name", "seed <Table> [count]"]),
(
"insert",
&["after `insert`, expected `into`", "insert into <Table>"],
),
(
"insert into",
&[
"after `insert into`, expected table name",
"insert into <Table>",
],
),
(
"insert into T",
&[
"after `insert into T`, expected `values` or `(`",
"insert into <Table>",
],
),
(
"update",
&["after `update`, expected table name", "update <Table> set"],
),
(
"update T",
&["after `update T`, expected `set`", "update <Table> set"],
),
(
"update T set x=1",
&["expected `where` or `--all-rows`", "update <Table> set"],
),
(
"delete",
&["after `delete`, expected `from`", "delete from <Table>"],
),
(
"delete from",
&[
"after `delete from`, expected table name",
"delete from <Table>",
],
),
(
"delete from T",
&["expected `where` or `--all-rows`", "delete from <Table>"],
),
(
"seed",
&["after `seed`, expected table name", "seed <Table> [count]"],
),
// Phase 2 (ADR-0048 D2/D1): malformed `set` clause + column-fill.
("seed T set", &["after `seed T set`, expected column name", "seed <Table>.<col>"]),
(
"seed T set",
&[
"after `seed T set`, expected column name",
"seed <Table>.<col>",
],
),
(
"seed T set role",
&["after `seed T set role`, expected `=`, `in`, `between`, or `as`", "seed <Table>.<col>"],
&[
"after `seed T set role`, expected `=`, `in`, `between`, or `as`",
"seed <Table>.<col>",
],
),
(
"seed T.",
&[
"after `seed T.`, expected column name",
"seed <Table>.<col>",
],
),
(
"replay",
&[
"after `replay`, expected string literal or path",
"replay <path>",
],
),
(
"explain",
&[
"after `explain`, expected `show`, `update`, or `delete`",
"explain show data",
],
),
("seed T.", &["after `seed T.`, expected column name", "seed <Table>.<col>"]),
("replay", &["after `replay`, expected string literal or path", "replay <path>"]),
("explain", &["after `explain`, expected `show`, `update`, or `delete`", "explain show data"]),
// advanced-only entry word typed in simple mode → "this is SQL" rail
("select * from T", &["`select` is SQL", "mode advanced"]),
("alter table T add column c int", &["`alter` is SQL", "mode advanced"]),
(
"alter table T add column c int",
&["`alter` is SQL", "mode advanced"],
),
];
for (input, needles) in matrix {
let lines = error_lines_for(input);
@@ -164,26 +332,160 @@ fn near_miss_matrix_committed_multiforms() {
// (input, advanced?, required-substrings)
let matrix: &[(&str, bool, &[&str])] = &[
// add / drop multi-forms (simple)
("add index", false, &["after `add index`, expected `on` or `as`", "add index [as <Name>] on"]),
("add index on T", false, &["after `add index on T`, expected `(`", "add index [as <Name>] on"]),
("add constraint", false, &["after `add constraint`, expected `not`, `unique`, `default`, or `check`", "add constraint not null to"]),
("add constraint not null", false, &["after `add constraint not null`, expected `to`", "add constraint not null to"]),
("add 1:n relationship", false, &["after `add 1:n relationship`, expected `from` or `as`", "add 1:n relationship"]),
("add 1:n relationship from", false, &["after `add 1:n relationship from`, expected table name", "from <Parent>.<col>"]),
("drop constraint", false, &["after `drop constraint`, expected `not`, `unique`, `default`, or `check`", "drop constraint (not null"]),
("drop constraint not null", false, &["after `drop constraint not null`, expected `from`", "drop constraint (not null"]),
("drop index", false, &["after `drop index`, expected `on` or index name", "drop index <Name>", "drop index on <Table>"]),
("drop index on T", false, &["after `drop index on T`, expected `(`", "drop index on <Table>"]),
("drop relationship", false, &["after `drop relationship`, expected `from` or relationship name", "drop relationship <Name>"]),
("show table", false, &["after `show table`, expected table name", "show table <Table>"]),
("show relationship", false, &["after `show relationship`, expected relationship name", "show relationship <name>"]),
("show index", false, &["after `show index`, expected index name", "show index <name>"]),
("change column in table T: c", false, &["after `change column in table T: c`, expected `(`", "change column [in] [table]"]),
(
"add index",
false,
&[
"after `add index`, expected `on` or `as`",
"add index [as <Name>] on",
],
),
(
"add index on T",
false,
&[
"after `add index on T`, expected `(`",
"add index [as <Name>] on",
],
),
(
"add constraint",
false,
&[
"after `add constraint`, expected `not`, `unique`, `default`, or `check`",
"add constraint not null to",
],
),
(
"add constraint not null",
false,
&[
"after `add constraint not null`, expected `to`",
"add constraint not null to",
],
),
(
"add 1:n relationship",
false,
&[
"after `add 1:n relationship`, expected `from` or `as`",
"add 1:n relationship",
],
),
(
"add 1:n relationship from",
false,
&[
"after `add 1:n relationship from`, expected table name",
"from <Parent>.<col>",
],
),
(
"drop constraint",
false,
&[
"after `drop constraint`, expected `not`, `unique`, `default`, or `check`",
"drop constraint (not null",
],
),
(
"drop constraint not null",
false,
&[
"after `drop constraint not null`, expected `from`",
"drop constraint (not null",
],
),
(
"drop index",
false,
&[
"after `drop index`, expected `on` or index name",
"drop index <Name>",
"drop index on <Table>",
],
),
(
"drop index on T",
false,
&[
"after `drop index on T`, expected `(`",
"drop index on <Table>",
],
),
(
"drop relationship",
false,
&[
"after `drop relationship`, expected `from` or relationship name",
"drop relationship <Name>",
],
),
(
"show table",
false,
&[
"after `show table`, expected table name",
"show table <Table>",
],
),
(
"show relationship",
false,
&[
"after `show relationship`, expected relationship name",
"show relationship <name>",
],
),
(
"show index",
false,
&[
"after `show index`, expected index name",
"show index <name>",
],
),
(
"change column in table T: c",
false,
&[
"after `change column in table T: c`, expected `(`",
"change column [in] [table]",
],
),
// advanced committed multi-forms
("create index on", true, &["after `create index on`, expected table name", "create [unique] index"]),
("create unique index", true, &["after `create unique index`, expected `on`, identifier, or `if`", "create [unique] index"]),
("alter table T add", true, &["after `alter table T add`, expected `column`, `constraint`, `check`, `unique`, `foreign`, or `primary`", "alter table <Table> add column"]),
("alter table T drop", true, &["after `alter table T drop`, expected `column` or `constraint`", "alter table <Table> drop column"]),
(
"create index on",
true,
&[
"after `create index on`, expected table name",
"create [unique] index",
],
),
(
"create unique index",
true,
&[
"after `create unique index`, expected `on`, identifier, or `if`",
"create [unique] index",
],
),
(
"alter table T add",
true,
&[
"after `alter table T add`, expected `column`, `constraint`, `check`, `unique`, `foreign`, or `primary`",
"alter table <Table> add column",
],
),
(
"alter table T drop",
true,
&[
"after `alter table T drop`, expected `column` or `constraint`",
"alter table <Table> drop column",
],
),
];
for (input, advanced, needles) in matrix {
let lines = if *advanced {
@@ -265,7 +567,10 @@ fn advanced_mode_usage_block_shows_sql_and_dsl_forms() {
// (mode-primary first).
let sql_at = joined.find("create table [if not exists]").unwrap();
let dsl_at = joined.find("create table <Name> with pk").unwrap();
assert!(sql_at < dsl_at, "SQL form should precede the DSL form\n{dump_msg}");
assert!(
sql_at < dsl_at,
"SQL form should precede the DSL form\n{dump_msg}"
);
}
#[test]
@@ -307,23 +612,94 @@ fn advanced_cross_join_with_on_teaches_no_on_clause() {
fn near_miss_matrix_advanced_mode() {
let matrix: &[(&str, &[&str])] = &[
// SQL select / with (G2, G4)
("select", &["expected a projection: `*`, a column, or an expression", "select (* |"]),
("select * from", &["after `select * from`, expected table name", "select (* |"]),
("with", &["after `with`, expected identifier or `recursive`", "with [recursive]", "as ("]),
(
"select",
&[
"expected a projection: `*`, a column, or an expression",
"select (* |",
],
),
(
"select * from",
&["after `select * from`, expected table name", "select (* |"],
),
(
"with",
&[
"after `with`, expected identifier or `recursive`",
"with [recursive]",
"as (",
],
),
// create / drop / alter — SQL forms AND the still-valid DSL
// fallback forms, SQL-primary first (G3).
("create", &["after `create`, expected `table`", "create table [if not exists]", "create [unique] index", "create table <Name> with pk"]),
("create table", &["after `create table`, expected identifier or `if`", "create table [if not exists]"]),
("create index", &["after `create index`, expected `on`", "create [unique] index"]),
("drop", &["after `drop`, expected `table`", "drop table [if exists]", "drop column [from]", "drop relationship"]),
("alter", &["after `alter`, expected `table`", "alter table <Table> add column"]),
("alter table T", &["expected `add`, `drop`, `rename`, or `alter`", "alter table <Table>"]),
(
"create",
&[
"after `create`, expected `table`",
"create table [if not exists]",
"create [unique] index",
"create table <Name> with pk",
],
),
(
"create table",
&[
"after `create table`, expected identifier or `if`",
"create table [if not exists]",
],
),
(
"create index",
&[
"after `create index`, expected `on`",
"create [unique] index",
],
),
(
"drop",
&[
"after `drop`, expected `table`",
"drop table [if exists]",
"drop column [from]",
"drop relationship",
],
),
(
"alter",
&[
"after `alter`, expected `table`",
"alter table <Table> add column",
],
),
(
"alter table T",
&[
"expected `add`, `drop`, `rename`, or `alter`",
"alter table <Table>",
],
),
// shared insert/update/delete — must show usage, not the
// available-commands fallback (regression guard for the
// empty-usage_ids SQL nodes).
("insert into T", &["after `insert into T`, expected `values`, `with`, `select`, or `(`", "insert into <Table>"]),
("update T", &["after `update T`, expected `set`", "update <Table> set"]),
("delete from", &["after `delete from`, expected table name", "delete from <Table>"]),
(
"insert into T",
&[
"after `insert into T`, expected `values`, `with`, `select`, or `(`",
"insert into <Table>",
],
),
(
"update T",
&["after `update T`, expected `set`", "update <Table> set"],
),
(
"delete from",
&[
"after `delete from`, expected table name",
"delete from <Table>",
],
),
];
for (input, needles) in matrix {
let lines = advanced_error_lines_for(input);
@@ -365,7 +741,9 @@ fn with_alone_renders_cte_usage_not_select() {
.collect();
let dump_msg = dump("with", &lines);
assert!(
lines.iter().any(|l| l.trim_start().starts_with("with ") && l.contains("as (")),
lines
.iter()
.any(|l| l.trim_start().starts_with("with ") && l.contains("as (")),
"missing CTE-specific `with … as (…)` usage template\n{dump_msg}",
);
}
@@ -383,7 +761,9 @@ fn create_alone_renders_create_table_usage() {
"missing usage: header\n{dump_msg}",
);
assert!(
lines.iter().any(|l| l.contains("create table") && l.contains("with pk")),
lines
.iter()
.any(|l| l.contains("create table") && l.contains("with pk")),
"missing create_table usage template\n{dump_msg}",
);
}
@@ -464,8 +844,7 @@ fn unknown_command_falls_back_to_available_commands_list() {
.unwrap_or_else(|| panic!("missing available commands line\n{dump_msg}"));
// The list must include all ten command-entry keywords.
for cmd in [
"add", "change", "create", "delete", "drop", "insert",
"rename", "replay", "show", "update",
"add", "change", "create", "delete", "drop", "insert", "rename", "replay", "show", "update",
] {
assert!(
available.contains(&format!("`{cmd}`")),
@@ -543,8 +922,3 @@ fn caret_aligns_under_offending_token() {
"caret should sit at column 9 (under `f` of `frobulate` after the `running: ` prefix); got {leading_spaces} spaces in {caret:?}",
);
}
+23 -10
View File
@@ -22,14 +22,16 @@ fn tempdir() -> tempfile::TempDir {
#[test]
fn no_args_creates_temp_project_under_data_root() {
let data = tempdir();
let project = project::open_or_create(None, Some(data.path()))
.expect("open_or_create with empty CLI");
let project =
project::open_or_create(None, Some(data.path())).expect("open_or_create with empty CLI");
let path = project.path();
assert!(path.exists(), "project dir should exist");
assert!(path.starts_with(data.path()));
assert_eq!(
path.parent().and_then(|p| p.file_name()).map(|s| s.to_string_lossy().into_owned()),
path.parent()
.and_then(|p| p.file_name())
.map(|s| s.to_string_lossy().into_owned()),
Some(PROJECTS_SUBDIR.to_string()),
);
@@ -96,8 +98,7 @@ fn positional_path_opens_existing_project() {
// Now drive open_or_create with the path as if it were a
// CLI positional argument.
let project = project::open_or_create(Some(&path), None)
.expect("open via positional path");
let project = project::open_or_create(Some(&path), None).expect("open via positional path");
assert_eq!(project.path(), path);
}
@@ -142,7 +143,10 @@ fn data_dir_override_does_not_touch_default_os_dir() {
assert!(p1_path.starts_with(data.path()));
assert!(p2_path.starts_with(data.path()));
assert_ne!(p1_path, p2_path, "two temp projects must have distinct names");
assert_ne!(
p1_path, p2_path,
"two temp projects must have distinct names"
);
}
#[test]
@@ -167,11 +171,18 @@ fn db_persists_across_open_close_cycles() {
db.create_table(
"Customers".to_string(),
vec![
rdbms_playground::dsl::ColumnSpec::new("id".to_string(), rdbms_playground::dsl::Type::Serial),
rdbms_playground::dsl::ColumnSpec::new("Name".to_string(), rdbms_playground::dsl::Type::Text),
rdbms_playground::dsl::ColumnSpec::new(
"id".to_string(),
rdbms_playground::dsl::Type::Serial,
),
rdbms_playground::dsl::ColumnSpec::new(
"Name".to_string(),
rdbms_playground::dsl::Type::Text,
),
],
vec!["id".to_string()],
None)
None,
)
.await
.expect("create_table");
});
@@ -187,7 +198,9 @@ fn db_persists_across_open_close_cycles() {
.enable_all()
.build()
.unwrap();
let tables = rt.block_on(async { db.list_tables().await }).expect("list_tables");
let tables = rt
.block_on(async { db.list_tables().await })
.expect("list_tables");
assert!(tables.iter().any(|t| t == "Customers"), "got: {tables:?}");
// Sanity: the project.yaml and history.log are still empty
+61 -52
View File
@@ -47,8 +47,7 @@ fn tempdir() -> tempfile::TempDir {
/// harness — most tests only need to write a script file and
/// call `run_replay`.
fn open_project_db(data_root: &Path) -> (project::Project, Database) {
let project = project::open_or_create(None, Some(data_root))
.expect("open_or_create");
let project = project::open_or_create(None, Some(data_root)).expect("open_or_create");
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(project.path().to_path_buf()),
@@ -132,9 +131,7 @@ fn replay_three_lines_dispatches_three_commands() {
insert into T (1, 'Alice')\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "seed.commands").await
});
let events = rt().block_on(async { run_replay(&db, project.path(), "seed.commands").await });
assert_completed(&events, 3);
// The dispatched commands actually mutated state.
@@ -167,8 +164,7 @@ fn replay_of_actual_history_log_runs_ok_commands_and_skips_err() {
2026-05-24T10:00:03Z|ok|insert into T (id, v) values (1, 'alpha')\n",
);
let events =
rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
let events = rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
// Three `ok` records replayed; the `err` record is skipped (not
// counted, not a failure).
assert_completed(&events, 3);
@@ -215,14 +211,21 @@ fn replay_skips_app_lifecycle_commands_silently() {
2026-05-24T10:00:13Z|ok|add column T: v (text)\n\
2026-05-24T10:00:14Z|ok|insert into T (id, v) values (1, 'alpha')\n",
);
let events =
rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
let events = rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
// Three data/schema commands ran; every app-lifecycle line was
// skipped silently (no panic, no abort, no warnings, no quit).
match events.last().expect("event") {
AppEvent::ReplayCompleted { count, warnings, .. } => {
assert_eq!(*count, 3, "only the 3 write commands ran; events: {events:?}");
assert!(warnings.is_empty(), "these skips are silent; got {warnings:?}");
AppEvent::ReplayCompleted {
count, warnings, ..
} => {
assert_eq!(
*count, 3,
"only the 3 write commands ran; events: {events:?}"
);
assert!(
warnings.is_empty(),
"these skips are silent; got {warnings:?}"
);
}
other => panic!("expected ReplayCompleted, got {other:?}"),
}
@@ -251,10 +254,11 @@ fn replay_skips_import_with_a_warning() {
"2026-05-24T10:00:00Z|ok|create table T with pk id(int)\n\
2026-05-24T10:00:01Z|ok|import shared.zip as Imported\n",
);
let events =
rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
let events = rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
match events.last().expect("event") {
AppEvent::ReplayCompleted { count, warnings, .. } => {
AppEvent::ReplayCompleted {
count, warnings, ..
} => {
assert_eq!(*count, 1, "only the create ran; events: {events:?}");
assert!(
warnings.iter().any(|w| w.contains("import shared.zip")),
@@ -282,9 +286,7 @@ fn replay_skips_blank_lines_and_comments() {
\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "seed.commands").await
});
let events = rt().block_on(async { run_replay(&db, project.path(), "seed.commands").await });
// Only two non-blank, non-comment lines.
assert_completed(&events, 2);
}
@@ -295,9 +297,7 @@ fn replay_empty_file_completes_with_zero_commands() {
let (project, db) = open_project_db(data.path());
write_script(project.path(), "empty.commands", "");
let events = rt().block_on(async {
run_replay(&db, project.path(), "empty.commands").await
});
let events = rt().block_on(async { run_replay(&db, project.path(), "empty.commands").await });
assert_completed(&events, 0);
}
@@ -311,9 +311,8 @@ fn replay_only_comments_completes_with_zero_commands() {
"# just\n# comments\n\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "comments.commands").await
});
let events =
rt().block_on(async { run_replay(&db, project.path(), "comments.commands").await });
assert_completed(&events, 0);
}
@@ -350,8 +349,14 @@ fn replay_constraint_failure_shows_real_names_not_placeholders() {
// INSERT command — the **real offending value** is shown too (it used
// to degrade to the neutral "that value" because `SqlInsert` discarded
// its literals).
assert!(error.contains("T.email"), "names the real table.column; got: {error}");
assert!(error.contains("a@b.com"), "shows the real offending value; got: {error}");
assert!(
error.contains("T.email"),
"names the real table.column; got: {error}"
);
assert!(
error.contains("a@b.com"),
"shows the real offending value; got: {error}"
);
}
#[test]
@@ -359,9 +364,8 @@ fn replay_missing_file_fails_with_line_number_zero() {
let data = tempdir();
let (project, db) = open_project_db(data.path());
let events = rt().block_on(async {
run_replay(&db, project.path(), "no-such-file.commands").await
});
let events =
rt().block_on(async { run_replay(&db, project.path(), "no-such-file.commands").await });
let failed = assert_failed_at(&events, 0);
let AppEvent::ReplayFailed { error, .. } = failed else {
unreachable!()
@@ -387,9 +391,7 @@ fn replay_aborts_on_first_parse_failure_and_reports_line() {
insert into T (1, 'should not happen')\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "bad.commands").await
});
let events = rt().block_on(async { run_replay(&db, project.path(), "bad.commands").await });
let failed = assert_failed_at(&events, 3);
let AppEvent::ReplayFailed { error, command, .. } = failed else {
unreachable!()
@@ -452,9 +454,7 @@ fn replay_rejects_wrong_type_value_in_a_hand_built_script() {
insert into T values (1, 'not a number')\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "typed.commands").await
});
let events = rt().block_on(async { run_replay(&db, project.path(), "typed.commands").await });
let failed = assert_failed_at(&events, 3);
let AppEvent::ReplayFailed { error, .. } = failed else {
unreachable!()
@@ -489,9 +489,7 @@ fn replay_aborts_on_first_runtime_failure_and_reports_line() {
insert into T (1)\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "bad.commands").await
});
let events = rt().block_on(async { run_replay(&db, project.path(), "bad.commands").await });
let _ = assert_failed_at(&events, 2);
}
@@ -504,23 +502,29 @@ fn replay_skips_nested_replay_with_a_warning() {
// because the nested file's commands are not reconstructed.
let data = tempdir();
let (project, db) = open_project_db(data.path());
write_script(project.path(), "inner.commands", "create table T with pk id(int)\n");
write_script(
project.path(),
"inner.commands",
"create table T with pk id(int)\n",
);
write_script(
project.path(),
"outer.commands",
"create table U with pk id(int)\nreplay inner.commands\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "outer.commands").await
});
let events = rt().block_on(async { run_replay(&db, project.path(), "outer.commands").await });
// The outer `create table U` ran; the nested `replay` was
// skipped (count 1), with a warning.
match events.last().expect("event") {
AppEvent::ReplayCompleted { count, warnings, .. } => {
AppEvent::ReplayCompleted {
count, warnings, ..
} => {
assert_eq!(*count, 1, "only the outer create ran; events: {events:?}");
assert!(
warnings.iter().any(|w| w.contains("nested") && w.contains("replay inner.commands")),
warnings
.iter()
.any(|w| w.contains("nested") && w.contains("replay inner.commands")),
"expected a nested-replay skip warning; got {warnings:?}",
);
}
@@ -528,7 +532,10 @@ fn replay_skips_nested_replay_with_a_warning() {
}
// The nested file's table was NOT created (the replay was skipped).
let cols = rt().block_on(async { db.query_data("T".to_string(), None, None).await });
assert!(cols.is_err(), "inner.commands' table T must not exist (nested replay skipped)");
assert!(
cols.is_err(),
"inner.commands' table T must not exist (nested replay skipped)"
);
}
#[test]
@@ -546,20 +553,22 @@ fn replay_history_log_records_subcommands_only() {
"create table T with pk id(int)\nadd column T: name (text)\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "seed.commands").await
});
let events = rt().block_on(async { run_replay(&db, project.path(), "seed.commands").await });
assert_completed(&events, 2);
let history = fs::read_to_string(project.path().join("history.log"))
.expect("history.log exists");
let history =
fs::read_to_string(project.path().join("history.log")).expect("history.log exists");
// Per-command entries landed.
assert!(
history.lines().any(|l| l.contains("create table T with pk id(int)")),
history
.lines()
.any(|l| l.contains("create table T with pk id(int)")),
"history.log missing create line:\n{history}"
);
assert!(
history.lines().any(|l| l.contains("add column T: name (text)")),
history
.lines()
.any(|l| l.contains("add column T: name (text)")),
"history.log missing add column line:\n{history}"
);
// The replay invocation itself did NOT land — that's
+376 -93
View File
@@ -18,8 +18,7 @@ fn rt() -> tokio::runtime::Runtime {
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir");
let project =
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
let persistence = Persistence::new(project.path().to_path_buf());
let db = Database::open_with_persistence(project.db_path(), persistence)
.expect("open db with persistence");
@@ -76,7 +75,10 @@ fn seed_parses_with_and_without_count() {
match parse_command("seed People").expect("`seed People` parses") {
Command::Seed { table, count, .. } => {
assert_eq!(table, "People");
assert_eq!(count, None, "omitted count is None (executor defaults to 20)");
assert_eq!(
count, None,
"omitted count is None (executor defaults to 20)"
);
}
other => panic!("expected Command::Seed, got {other:?}"),
}
@@ -134,7 +136,10 @@ fn seed_set_fixed_value_override_parses() {
let (_t, ov) = seed_overrides("seed users 5 set status = 'active'");
assert_eq!(ov.len(), 1);
assert_eq!(ov[0].column, "status");
assert_eq!(ov[0].kind, SeedOverrideKind::Fixed(Value::Text("active".into())));
assert_eq!(
ov[0].kind,
SeedOverrideKind::Fixed(Value::Text("active".into()))
);
}
#[test]
@@ -177,8 +182,7 @@ fn seed_set_numeric_range_override_parses() {
#[test]
fn seed_set_date_range_override_parses_with_quoted_dates() {
// ADR-0048 D2 amendment: dates in the range form are quoted strings.
let (_t, ov) =
seed_overrides("seed users set signup between '2023-01-01' and '2024-12-31'");
let (_t, ov) = seed_overrides("seed users set signup between '2023-01-01' and '2024-12-31'");
assert_eq!(
ov[0].kind,
SeedOverrideKind::Range {
@@ -207,7 +211,9 @@ fn seed_count_is_not_confused_by_a_range_value() {
// No positional count, but `between 18 and 80` carries NumberLits —
// they must not be read as the count (bounded to before `set`).
match parse_command("seed users set age between 18 and 80").expect("parses") {
Command::Seed { count, overrides, .. } => {
Command::Seed {
count, overrides, ..
} => {
assert_eq!(count, None, "the count is None, not 18");
assert_eq!(overrides.len(), 1);
}
@@ -267,7 +273,14 @@ fn seed_populates_a_table_and_persists_rows() {
create_people(&db, &rt);
let result = rt
.block_on(db.seed("People".into(), None, Some(7), Vec::new(), Some(42), Some("seed People 7".into())))
.block_on(db.seed(
"People".into(),
None,
Some(7),
Vec::new(),
Some(42),
Some("seed People 7".into()),
))
.expect("seed succeeds");
assert_eq!(result.produced, 7);
@@ -278,22 +291,34 @@ fn seed_populates_a_table_and_persists_rows() {
"CSV should hold 7 generated rows:\n{csv}"
);
// The generated `email` column produces address-shaped values.
assert!(csv.contains('@'), "seeded emails should appear in the CSV:\n{csv}");
assert!(
csv.contains('@'),
"seeded emails should appear in the CSV:\n{csv}"
);
}
/// Parse a seeded table's CSV into per-column value lists (simple
/// comma-split — the values under test carry no commas/quotes).
fn csv_columns(csv: &str) -> (Vec<String>, Vec<Vec<String>>) {
let mut lines = csv.lines().filter(|l| !l.trim().is_empty());
let header: Vec<String> = lines.next().unwrap().split(',').map(str::to_string).collect();
let rows: Vec<Vec<String>> =
lines.map(|l| l.split(',').map(str::to_string).collect()).collect();
let header: Vec<String> = lines
.next()
.unwrap()
.split(',')
.map(str::to_string)
.collect();
let rows: Vec<Vec<String>> = lines
.map(|l| l.split(',').map(str::to_string).collect())
.collect();
(header, rows)
}
fn column_values(csv: &str, col: &str) -> Vec<String> {
let (header, rows) = csv_columns(csv);
let idx = header.iter().position(|h| h == col).expect("column present");
let idx = header
.iter()
.position(|h| h == col)
.expect("column present");
rows.iter().map(|r| r[idx].clone()).collect()
}
@@ -321,20 +346,36 @@ fn seed_year_and_choice_set_heuristics() {
))
.expect("create Records");
rt.block_on(db.seed("Records".into(), None, Some(30), Vec::new(), Some(99), Some("seed Records 30".into())))
.expect("seed succeeds");
rt.block_on(db.seed(
"Records".into(),
None,
Some(30),
Vec::new(),
Some(99),
Some("seed Records 30".into()),
))
.expect("seed succeeds");
let csv = read_csv(&project, "Records").expect("Records CSV exists");
for y in column_values(&csv, "birth_year") {
let n: i32 = y.parse().expect("birth_year is an int");
assert!((1945..=2007).contains(&n), "birth_year {n} must be a plausible birth year");
assert!(
(1945..=2007).contains(&n),
"birth_year {n} must be a plausible birth year"
);
}
for y in column_values(&csv, "published") {
let n: i32 = y.parse().expect("published is an int");
assert!((1950..=2025).contains(&n), "published {n} must be a plausible recent year");
assert!(
(1950..=2025).contains(&n),
"published {n} must be a plausible recent year"
);
}
for p in column_values(&csv, "priority") {
assert!(["low", "medium", "high"].contains(&p.as_str()), "priority `{p}` must be low/medium/high");
assert!(
["low", "medium", "high"].contains(&p.as_str()),
"priority `{p}` must be low/medium/high"
);
}
for s in column_values(&csv, "severity") {
assert!(
@@ -405,7 +446,14 @@ fn seed_count_defaults_to_twenty() {
create_people(&db, &rt);
let result = rt
.block_on(db.seed("People".into(), None, None, Vec::new(), Some(1), Some("seed People".into())))
.block_on(db.seed(
"People".into(),
None,
None,
Vec::new(),
Some(1),
Some("seed People".into()),
))
.expect("seed succeeds");
assert_eq!(result.produced, 20, "omitted count defaults to 20");
let csv = read_csv(&project, "People").expect("People CSV exists");
@@ -420,10 +468,24 @@ fn seed_is_reproducible_with_a_fixed_seed() {
create_people(&db1, &rt);
create_people(&db2, &rt);
rt.block_on(db1.seed("People".into(), None, Some(4), Vec::new(), Some(123), Some("seed People 4".into())))
.expect("seed run 1");
rt.block_on(db2.seed("People".into(), None, Some(4), Vec::new(), Some(123), Some("seed People 4".into())))
.expect("seed run 2");
rt.block_on(db1.seed(
"People".into(),
None,
Some(4),
Vec::new(),
Some(123),
Some("seed People 4".into()),
))
.expect("seed run 1");
rt.block_on(db2.seed(
"People".into(),
None,
Some(4),
Vec::new(),
Some(123),
Some("seed People 4".into()),
))
.expect("seed run 2");
let csv1 = read_csv(&p1, "People").expect("csv 1");
let csv2 = read_csv(&p2, "People").expect("csv 2");
@@ -493,10 +555,24 @@ fn seed_fills_foreign_keys_from_existing_parents() {
create_users_and_orders(&db, &rt, true);
// 5 parents → serial ids 1..=5.
rt.block_on(db.seed("Users".into(), None, Some(5), Vec::new(), Some(1), Some("seed Users 5".into())))
.expect("seed Users");
rt.block_on(db.seed(
"Users".into(),
None,
Some(5),
Vec::new(),
Some(1),
Some("seed Users 5".into()),
))
.expect("seed Users");
let res = rt
.block_on(db.seed("Orders".into(), None, Some(10), Vec::new(), Some(2), Some("seed Orders 10".into())))
.block_on(db.seed(
"Orders".into(),
None,
Some(10),
Vec::new(),
Some(2),
Some("seed Orders 10".into()),
))
.expect("seed Orders");
assert_eq!(res.produced, 10, "every child row must insert (valid FK)");
@@ -520,10 +596,20 @@ fn seed_refuses_when_a_parent_table_is_empty() {
// Users is empty — no valid FK can be fabricated.
let err = rt
.block_on(db.seed("Orders".into(), None, Some(3), Vec::new(), Some(1), Some("seed Orders 3".into())))
.block_on(db.seed(
"Orders".into(),
None,
Some(3),
Vec::new(),
Some(1),
Some("seed Orders 3".into()),
))
.expect_err("seed must refuse an empty parent");
let msg = err.to_string();
assert!(msg.contains("Users"), "error should name the empty parent: {msg}");
assert!(
msg.contains("Users"),
"error should name the empty parent: {msg}"
);
let lower = msg.to_lowercase();
assert!(
lower.contains("no rows") || lower.contains("first"),
@@ -546,7 +632,14 @@ fn seed_refuses_a_not_null_blob_column() {
.expect("create Files");
let err = rt
.block_on(db.seed("Files".into(), None, Some(2), Vec::new(), Some(1), Some("seed Files 2".into())))
.block_on(db.seed(
"Files".into(),
None,
Some(2),
Vec::new(),
Some(1),
Some("seed Files 2".into()),
))
.expect_err("seed must refuse a NOT NULL blob");
let msg = err.to_string();
assert!(
@@ -573,7 +666,14 @@ fn seed_omits_a_nullable_blob_column() {
.expect("create Files");
let res = rt
.block_on(db.seed("Files".into(), None, Some(3), Vec::new(), Some(1), Some("seed Files 3".into())))
.block_on(db.seed(
"Files".into(),
None,
Some(3),
Vec::new(),
Some(1),
Some("seed Files 3".into()),
))
.expect("seed succeeds despite the nullable blob");
assert_eq!(res.produced, 3);
let csv = read_csv(&project, "Files").expect("Files CSV");
@@ -607,14 +707,25 @@ fn seed_keeps_unique_columns_distinct() {
.expect("create Tags");
let res = rt
.block_on(db.seed("Tags".into(), None, Some(8), Vec::new(), Some(3), Some("seed Tags 8".into())))
.block_on(db.seed(
"Tags".into(),
None,
Some(8),
Vec::new(),
Some(3),
Some("seed Tags 8".into()),
))
.expect("seed");
assert_eq!(res.produced, 8);
let csv = read_csv(&project, "Tags").expect("Tags CSV");
let labels = nth_column_values(&csv, 1);
let distinct: std::collections::HashSet<&String> = labels.iter().collect();
assert_eq!(distinct.len(), labels.len(), "UNIQUE column has duplicates:\n{csv}");
assert_eq!(
distinct.len(),
labels.len(),
"UNIQUE column has duplicates:\n{csv}"
);
}
#[test]
@@ -636,7 +747,14 @@ fn seed_sequences_identifier_int_columns() {
.expect("create Items");
let res = rt
.block_on(db.seed("Items".into(), None, Some(5), Vec::new(), Some(1), Some("seed Items 5".into())))
.block_on(db.seed(
"Items".into(),
None,
Some(5),
Vec::new(),
Some(1),
Some("seed Items 5".into()),
))
.expect("seed");
assert_eq!(res.produced, 5);
@@ -646,7 +764,11 @@ fn seed_sequences_identifier_int_columns() {
.map(|s| s.parse().expect("code is an int"))
.collect();
let distinct: std::collections::HashSet<i64> = codes.iter().copied().collect();
assert_eq!(distinct.len(), 5, "identifier ints must be unique: {codes:?}");
assert_eq!(
distinct.len(),
5,
"identifier ints must be unique: {codes:?}"
);
}
#[test]
@@ -667,14 +789,24 @@ fn seed_junction_produces_distinct_combinations_and_caps() {
)
.await
.expect("create parent");
db.seed(t.into(), None, Some(2), Vec::new(), Some(1), Some(format!("seed {t} 2")))
.await
.expect("seed parent");
db.seed(
t.into(),
None,
Some(2),
Vec::new(),
Some(1),
Some(format!("seed {t} 2")),
)
.await
.expect("seed parent");
}
// Junction with a compound PK over its two FK columns.
db.create_table(
"J".to_string(),
vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)],
vec![
ColumnSpec::new("a", Type::Int),
ColumnSpec::new("b", Type::Int),
],
vec!["a".to_string(), "b".to_string()],
None,
)
@@ -709,11 +841,21 @@ fn seed_junction_produces_distinct_combinations_and_caps() {
// Requesting 10 caps at the 4 available distinct combinations.
let res = db
.seed("J".into(), None, Some(10), Vec::new(), Some(7), Some("seed J 10".into()))
.seed(
"J".into(),
None,
Some(10),
Vec::new(),
Some(7),
Some("seed J 10".into()),
)
.await
.expect("seed J");
assert_eq!(res.produced, 4, "junction caps at available combos");
assert_eq!(res.requested, 10, "the requested count is reported for the cap note");
assert_eq!(
res.requested, 10,
"the requested count is reported for the cap note"
);
});
let csv = read_csv(&project, "J").expect("J CSV");
@@ -724,7 +866,11 @@ fn seed_junction_produces_distinct_combinations_and_caps() {
.map(str::to_string)
.collect();
let distinct: std::collections::HashSet<&String> = pairs.iter().collect();
assert_eq!(distinct.len(), pairs.len(), "junction rows must be distinct:\n{csv}");
assert_eq!(
distinct.len(),
pairs.len(),
"junction rows must be distinct:\n{csv}"
);
}
#[test]
@@ -743,9 +889,19 @@ fn seed_draws_enum_values_from_an_in_check() {
// Every generated status must satisfy the CHECK, so all rows insert.
let res = rt
.block_on(db.seed("Tickets".into(), None, Some(12), Vec::new(), Some(2), Some("seed Tickets 12".into())))
.block_on(db.seed(
"Tickets".into(),
None,
Some(12),
Vec::new(),
Some(2),
Some("seed Tickets 12".into()),
))
.expect("seed");
assert_eq!(res.produced, 12, "all rows insert — values satisfy the CHECK");
assert_eq!(
res.produced, 12,
"all rows insert — values satisfy the CHECK"
);
let csv = read_csv(&project, "Tickets").expect("Tickets CSV");
for v in nth_column_values(&csv, 1) {
@@ -780,7 +936,14 @@ fn seed_advises_on_enum_ish_columns() {
.expect("create Tasks");
let res = rt
.block_on(db.seed("Tasks".into(), None, Some(3), Vec::new(), Some(1), Some("seed Tasks 3".into())))
.block_on(db.seed(
"Tasks".into(),
None,
Some(3),
Vec::new(),
Some(1),
Some("seed Tasks 3".into()),
))
.expect("seed");
assert!(
res.advisory_columns.contains(&"status".to_string()),
@@ -795,7 +958,14 @@ fn seed_refuses_an_excessive_count() {
let rt = rt();
create_people(&db, &rt);
let err = rt
.block_on(db.seed("People".into(), None, Some(1_000_000), Vec::new(), Some(1), Some("seed People 1000000".into())))
.block_on(db.seed(
"People".into(),
None,
Some(1_000_000),
Vec::new(),
Some(1),
Some("seed People 1000000".into()),
))
.expect_err("an excessive count must be refused");
assert!(
err.to_string().to_lowercase().contains("maximum"),
@@ -810,7 +980,14 @@ fn seed_preview_is_capped_but_count_is_full() {
create_people(&db, &rt);
let res = rt
.block_on(db.seed("People".into(), None, Some(25), Vec::new(), Some(1), Some("seed People 25".into())))
.block_on(db.seed(
"People".into(),
None,
Some(25),
Vec::new(),
Some(1),
Some("seed People 25".into()),
))
.expect("seed");
assert_eq!(res.produced, 25, "the full count is produced");
assert_eq!(res.data.rows.len(), 20, "the preview is capped at 20 rows");
@@ -860,14 +1037,24 @@ fn seed_is_one_undo_step() {
.expect("open db with undo");
let rt = rt();
create_people(&db, &rt);
rt.block_on(db.seed("People".into(), None, Some(6), Vec::new(), Some(1), Some("seed People 6".into())))
.expect("seed");
rt.block_on(db.seed(
"People".into(),
None,
Some(6),
Vec::new(),
Some(1),
Some("seed People 6".into()),
))
.expect("seed");
assert_eq!(data_row_count(&read_csv(&project, "People").unwrap()), 6);
// One undo removes the whole seed batch (ADR-0048 D15).
rt.block_on(db.undo()).unwrap().expect("undo applied");
let rows = read_csv(&project, "People").map_or(0, |c| data_row_count(&c));
assert_eq!(rows, 0, "one undo must remove every seeded row in a single step");
assert_eq!(
rows, 0,
"one undo must remove every seeded row in a single step"
);
}
#[test]
@@ -882,10 +1069,17 @@ fn seed_column_fill_is_one_undo_step() {
create_members(&db, &rt);
run_seed(&db, &rt, "seed Members 5 --seed 1").expect("seed");
// Fill `status` across all 5 rows with a constant, then undo once.
run_seed(&db, &rt, "seed Members.status set status = 'flagged' --seed 2")
.expect("column-fill");
run_seed(
&db,
&rt,
"seed Members.status set status = 'flagged' --seed 2",
)
.expect("column-fill");
let before = named_column_values(&read_csv(&project, "Members").unwrap(), "status");
assert!(before.iter().all(|s| s == "flagged"), "all rows filled: {before:?}");
assert!(
before.iter().all(|s| s == "flagged"),
"all rows filled: {before:?}"
);
rt.block_on(db.undo()).unwrap().expect("undo applied");
let after = named_column_values(&read_csv(&project, "Members").unwrap(), "status");
@@ -893,7 +1087,11 @@ fn seed_column_fill_is_one_undo_step() {
after.iter().all(|s| s != "flagged"),
"one undo reverts the whole column-fill in a single step: {after:?}"
);
assert_eq!(after.len(), 5, "undo restores the original rows, not removes them");
assert_eq!(
after.len(),
5,
"undo restores the original rows, not removes them"
);
}
#[test]
@@ -930,10 +1128,23 @@ fn seed_rolls_back_atomically_on_a_constraint_failure() {
))
.expect("create Bad");
let res = rt.block_on(db.seed("Bad".into(), None, Some(5), Vec::new(), Some(1), Some("seed Bad 5".into())));
assert!(res.is_err(), "seed must fail when generated rows violate the CHECK");
let res = rt.block_on(db.seed(
"Bad".into(),
None,
Some(5),
Vec::new(),
Some(1),
Some("seed Bad 5".into()),
));
assert!(
res.is_err(),
"seed must fail when generated rows violate the CHECK"
);
let rows = read_csv(&project, "Bad").map_or(0, |c| data_row_count(&c));
assert_eq!(rows, 0, "a failed seed must leave the table unchanged (atomic)");
assert_eq!(
rows, 0,
"a failed seed must leave the table unchanged (atomic)"
);
}
#[test]
@@ -942,7 +1153,14 @@ fn seed_zero_is_a_no_op() {
let rt = rt();
create_people(&db, &rt);
let res = rt
.block_on(db.seed("People".into(), None, Some(0), Vec::new(), Some(1), Some("seed People 0".into())))
.block_on(db.seed(
"People".into(),
None,
Some(0),
Vec::new(),
Some(1),
Some("seed People 0".into()),
))
.expect("seed 0 succeeds");
assert_eq!(res.produced, 0);
let rows = read_csv(&project, "People").map_or(0, |c| data_row_count(&c));
@@ -967,7 +1185,14 @@ fn seed_advises_on_a_complex_check_column() {
.expect("create Widgets");
let res = rt
.block_on(db.seed("Widgets".into(), None, Some(3), Vec::new(), Some(1), Some("seed Widgets 3".into())))
.block_on(db.seed(
"Widgets".into(),
None,
Some(3),
Vec::new(),
Some(1),
Some("seed Widgets 3".into()),
))
.expect("seed");
assert!(
res.advisory_columns.contains(&"label".to_string()),
@@ -981,10 +1206,24 @@ fn seed_foreign_keys_are_reproducible_with_a_fixed_seed() {
let rt = rt();
let seed_one = |db: &Database| {
create_users_and_orders(db, &rt, true);
rt.block_on(db.seed("Users".into(), None, Some(4), Vec::new(), Some(1), Some("seed Users 4".into())))
.expect("seed users");
rt.block_on(db.seed("Orders".into(), None, Some(8), Vec::new(), Some(99), Some("seed Orders 8".into())))
.expect("seed orders");
rt.block_on(db.seed(
"Users".into(),
None,
Some(4),
Vec::new(),
Some(1),
Some("seed Users 4".into()),
))
.expect("seed users");
rt.block_on(db.seed(
"Orders".into(),
None,
Some(8),
Vec::new(),
Some(99),
Some("seed Orders 8".into()),
))
.expect("seed orders");
};
let (p1, db1, _d1) = open_project_db();
let (p2, db2, _d2) = open_project_db();
@@ -1013,8 +1252,15 @@ fn seed_shortid_columns_are_reproducible_with_a_fixed_seed() {
None,
))
.expect("create Contacts");
rt.block_on(db.seed("Contacts".into(), None, Some(5), Vec::new(), Some(42), Some("seed Contacts 5".into())))
.expect("seed");
rt.block_on(db.seed(
"Contacts".into(),
None,
Some(5),
Vec::new(),
Some(42),
Some("seed Contacts 5".into()),
))
.expect("seed");
};
let (p1, db1, _d1) = open_project_db();
let (p2, db2, _d2) = open_project_db();
@@ -1023,13 +1269,20 @@ fn seed_shortid_columns_are_reproducible_with_a_fixed_seed() {
let csv1 = read_csv(&p1, "Contacts").unwrap();
let csv2 = read_csv(&p2, "Contacts").unwrap();
assert_eq!(csv1, csv2, "shortid values must reproduce under a fixed --seed");
assert_eq!(
csv1, csv2,
"shortid values must reproduce under a fixed --seed"
);
// The shortid PK is populated with distinct 10-char base58 ids.
let codes = nth_column_values(&csv1, 0);
assert_eq!(codes.len(), 5);
let distinct: std::collections::HashSet<&String> = codes.iter().collect();
assert_eq!(distinct.len(), 5, "shortid PK values must be distinct: {codes:?}");
assert_eq!(
distinct.len(),
5,
"shortid PK values must be distinct: {codes:?}"
);
for code in &codes {
assert_eq!(code.len(), 10, "shortid should be 10 chars: {code}");
}
@@ -1105,7 +1358,10 @@ fn seed_set_fixed_value_fills_every_row() {
let csv = read_csv(&project, "Members").unwrap();
let statuses = named_column_values(&csv, "status");
assert_eq!(statuses.len(), 6);
assert!(statuses.iter().all(|s| s == "active"), "every status pinned: {statuses:?}");
assert!(
statuses.iter().all(|s| s == "active"),
"every status pinned: {statuses:?}"
);
}
#[test]
@@ -1113,7 +1369,12 @@ fn seed_set_pick_list_draws_only_from_the_list() {
let (project, db, _d) = open_project_db();
let rt = rt();
create_members(&db, &rt);
run_seed(&db, &rt, "seed Members 20 set role in ('admin', 'user') --seed 2").expect("seed");
run_seed(
&db,
&rt,
"seed Members 20 set role in ('admin', 'user') --seed 2",
)
.expect("seed");
let csv = read_csv(&project, "Members").unwrap();
let roles = named_column_values(&csv, "role");
assert!(
@@ -1131,7 +1392,10 @@ fn seed_set_as_generator_forces_the_shape() {
run_seed(&db, &rt, "seed Members 5 set name as email --seed 3").expect("seed");
let csv = read_csv(&project, "Members").unwrap();
let names = named_column_values(&csv, "name");
assert!(names.iter().all(|n| n.contains('@')), "name forced to email shape: {names:?}");
assert!(
names.iter().all(|n| n.contains('@')),
"name forced to email shape: {names:?}"
);
}
#[test]
@@ -1139,7 +1403,12 @@ fn seed_set_numeric_range_stays_within_bounds() {
let (project, db, _d) = open_project_db();
let rt = rt();
create_members(&db, &rt);
run_seed(&db, &rt, "seed Members 30 set age between 30 and 40 --seed 4").expect("seed");
run_seed(
&db,
&rt,
"seed Members 30 set age between 30 and 40 --seed 4",
)
.expect("seed");
let csv = read_csv(&project, "Members").unwrap();
for a in named_column_values(&csv, "age") {
let n: i64 = a.parse().unwrap_or_else(|_| panic!("age `{a}` not an int"));
@@ -1190,7 +1459,10 @@ fn seed_incompatible_range_is_a_friendly_error() {
// A numeric range on a text column (`name`) is rejected.
let err = run_seed(&db, &rt, "seed Members 3 set name between 1 and 10").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("between"), "range error should mention `between`: {msg}");
assert!(
msg.contains("between"),
"range error should mention `between`: {msg}"
);
}
#[test]
@@ -1221,8 +1493,12 @@ fn seed_column_fill_updates_existing_rows_without_adding() {
let before = data_row_count(&read_csv(&project, "Members").unwrap());
assert_eq!(before, 5);
let res = run_seed(&db, &rt, "seed Members.status set status in ('x', 'y') --seed 2")
.expect("column-fill");
let res = run_seed(
&db,
&rt,
"seed Members.status set status in ('x', 'y') --seed 2",
)
.expect("column-fill");
assert_eq!(res.produced, 5, "column-fill touches the 5 existing rows");
let csv = read_csv(&project, "Members").unwrap();
assert_eq!(data_row_count(&csv), 5, "no new rows added");
@@ -1240,7 +1516,10 @@ fn seed_column_fill_refuses_a_pk_target() {
create_members(&db, &rt);
run_seed(&db, &rt, "seed Members 3 --seed 1").expect("seed");
let err = run_seed(&db, &rt, "seed Members.id").unwrap_err();
assert!(format!("{err}").contains("primary key"), "PK target refused: {err}");
assert!(
format!("{err}").contains("primary key"),
"PK target refused: {err}"
);
}
#[test]
@@ -1282,7 +1561,10 @@ fn seed_column_fill_rejects_a_row_count() {
Some("seed Members.status 5".into()),
))
.unwrap_err();
assert!(format!("{err}").contains("no row count"), "count refused: {err}");
assert!(
format!("{err}").contains("no row count"),
"count refused: {err}"
);
}
#[test]
@@ -1298,7 +1580,11 @@ fn seed_column_fill_fk_target_samples_the_parent() {
assert_eq!(res.produced, 8);
let csv = read_csv(&project, "Orders").unwrap();
let user_ids = named_column_values(&csv, "user_id");
assert!(user_ids.iter().all(|v| (1..=4).contains(&v.parse::<i64>().unwrap())));
assert!(
user_ids
.iter()
.all(|v| (1..=4).contains(&v.parse::<i64>().unwrap()))
);
}
#[test]
@@ -1310,14 +1596,11 @@ fn seed_fixed_override_on_unique_column_is_a_friendly_error() {
let rt = rt();
rt.block_on(db.create_table(
"U".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
{
let mut c = ColumnSpec::new("email", Type::Text);
c.unique = true;
c
},
],
vec![ColumnSpec::new("id", Type::Serial), {
let mut c = ColumnSpec::new("email", Type::Text);
c.unique = true;
c
}],
vec!["id".to_string()],
None,
))
@@ -1330,7 +1613,10 @@ fn seed_fixed_override_on_unique_column_is_a_friendly_error() {
);
// A short pick-list (< count) is likewise refused...
let err2 = run_seed(&db, &rt, "seed U 5 set email in ('a@b.c', 'd@e.f')").unwrap_err();
assert!(format!("{err2}").contains("distinct"), "short list refused: {err2}");
assert!(
format!("{err2}").contains("distinct"),
"short list refused: {err2}"
);
// ...but a pick-list with enough distinct values succeeds.
let ok = run_seed(
&db,
@@ -1354,14 +1640,11 @@ fn seed_column_fill_fixed_on_unique_column_is_a_friendly_error() {
let rt = rt();
rt.block_on(db.create_table(
"U".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
{
let mut c = ColumnSpec::new("email", Type::Text);
c.unique = true;
c
},
],
vec![ColumnSpec::new("id", Type::Serial), {
let mut c = ColumnSpec::new("email", Type::Text);
c.unique = true;
c
}],
vec!["id".to_string()],
None,
))
+43 -27
View File
@@ -17,7 +17,7 @@ use rdbms_playground::action::Action;
use rdbms_playground::app::App;
use rdbms_playground::db::Database;
use rdbms_playground::dsl::{
parse_command, ColumnSpec, Command, ReferentialAction, ShowListKind, Type,
ColumnSpec, Command, ReferentialAction, ShowListKind, Type, parse_command,
};
use rdbms_playground::event::AppEvent;
use rdbms_playground::mode::Mode;
@@ -108,8 +108,7 @@ fn rt() -> tokio::runtime::Runtime {
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir");
let project =
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
let persistence = Persistence::new(project.path().to_path_buf());
let db = Database::open_with_persistence(project.db_path(), persistence)
.expect("open db with persistence");
@@ -195,8 +194,7 @@ fn show_relationships_lists_name_endpoints_and_nondefault_action() {
// Name, both endpoints, and the non-default ON DELETE CASCADE
// (ON UPDATE NO ACTION is the default and is omitted).
assert_eq!(
lines[1],
" orders_customer: Customers.id → Orders.customer_id on delete cascade",
lines[1], " orders_customer: Customers.id → Orders.customer_id on delete cascade",
"relationship summary line: {lines:?}",
);
}
@@ -222,7 +220,8 @@ fn show_lists_report_empty_collections_with_friendly_lines() {
let rt = rt();
// No schema seeded — every kind is empty.
assert_eq!(
rt.block_on(db.show_list(ShowListKind::Tables, None)).unwrap(),
rt.block_on(db.show_list(ShowListKind::Tables, None))
.unwrap(),
vec!["No tables in this project yet.".to_string()],
);
assert_eq!(
@@ -231,7 +230,8 @@ fn show_lists_report_empty_collections_with_friendly_lines() {
vec!["No relationships in this project yet.".to_string()],
);
assert_eq!(
rt.block_on(db.show_list(ShowListKind::Indexes, None)).unwrap(),
rt.block_on(db.show_list(ShowListKind::Indexes, None))
.unwrap(),
vec!["No indexes in this project yet.".to_string()],
);
}
@@ -246,7 +246,10 @@ fn show_one_relationship_renders_detail_block() {
let rt = rt();
rt.block_on(seed_schema(&db));
let lines = rt
.block_on(db.show_list(ShowListKind::Relationships, Some("orders_customer".to_string())))
.block_on(db.show_list(
ShowListKind::Relationships,
Some("orders_customer".to_string()),
))
.expect("show relationship");
assert_eq!(lines[0], "Relationship `orders_customer`:");
assert_eq!(lines[1], " Customers.id → Orders.customer_id");
@@ -262,7 +265,10 @@ fn show_one_index_renders_detail_block() {
let rt = rt();
rt.block_on(seed_schema(&db));
let lines = rt
.block_on(db.show_list(ShowListKind::Indexes, Some("idx_orders_customer".to_string())))
.block_on(db.show_list(
ShowListKind::Indexes,
Some("idx_orders_customer".to_string()),
))
.expect("show index");
assert_eq!(lines[0], "Index `idx_orders_customer` on Orders:");
assert!(
@@ -329,7 +335,10 @@ fn app_show_tables_dispatches_show_list_command() {
}
)
});
assert!(dispatched, "submit dispatches ShowList(Tables): {actions:?}");
assert!(
dispatched,
"submit dispatches ShowList(Tables): {actions:?}"
);
}
#[test]
@@ -337,10 +346,11 @@ fn app_renders_show_list_lines_as_system_output() {
// Feed the success event directly so the test stays
// self-contained (the worker round-trip is covered above).
let mut app = App::new();
app.output.push_back(rdbms_playground::app::OutputLine::echo(
"show tables",
Mode::Simple,
));
app.output
.push_back(rdbms_playground::app::OutputLine::echo(
"show tables",
Mode::Simple,
));
app.update(AppEvent::DslShowListSucceeded {
command: Command::ShowList {
kind: ShowListKind::Tables,
@@ -403,10 +413,11 @@ fn app_renders_show_relationship_as_a_styled_diagram() {
.expect("found");
let mut app = App::new();
app.output.push_back(rdbms_playground::app::OutputLine::echo(
"show relationship orders_customer",
Mode::Simple,
));
app.output
.push_back(rdbms_playground::app::OutputLine::echo(
"show relationship orders_customer",
Mode::Simple,
));
app.update(AppEvent::DslShowRelationshipSucceeded {
command: Command::ShowList {
kind: ShowListKind::Relationships,
@@ -423,7 +434,10 @@ fn app_renders_show_relationship_as_a_styled_diagram() {
// Both tables, box-drawing, the connector arrow, the actions line.
assert!(text.contains("Orders"), "child box: {text}");
assert!(text.contains("Customers"), "parent box: {text}");
assert!(text.contains('┌') && text.contains('│'), "box drawing: {text}");
assert!(
text.contains('┌') && text.contains('│'),
"box drawing: {text}"
);
assert!(text.contains('▶'), "connector arrow: {text}");
assert!(text.contains("on delete cascade"), "actions: {text}");
// The diagram lines are styled (per-span runs), not plain system.
@@ -436,10 +450,11 @@ fn app_renders_show_relationship_as_a_styled_diagram() {
#[test]
fn app_show_relationship_not_found_shows_friendly_line() {
let mut app = App::new();
app.output.push_back(rdbms_playground::app::OutputLine::echo(
"show relationship nope",
Mode::Simple,
));
app.output
.push_back(rdbms_playground::app::OutputLine::echo(
"show relationship nope",
Mode::Simple,
));
app.update(AppEvent::DslShowRelationshipSucceeded {
command: Command::ShowList {
kind: ShowListKind::Relationships,
@@ -466,10 +481,11 @@ fn app_show_table_renders_relationships_as_compact_diagrams() {
.expect("describe Orders");
let mut app = App::new();
app.output.push_back(rdbms_playground::app::OutputLine::echo(
"show table Orders",
Mode::Simple,
));
app.output
.push_back(rdbms_playground::app::OutputLine::echo(
"show table Orders",
Mode::Simple,
));
app.update(AppEvent::DslSucceeded {
command: Command::ShowTable {
name: "Orders".to_string(),

Some files were not shown because too many files have changed in this diff Show More